Overview
Use a Bundle when one agent delegates to others via spec.agents. A kind: Bundle document names the root agent and packages every delegate in one directory—you publish and deploy the whole system as a single unit.
A standalone kind: Agent with spec.agents cannot be published alone; validation rejects it with "agents with spec.agents must be published via a Bundle".
| Topic | Chapter |
|---|---|
Delegation semantics | |
Single-agent fields | |
File layout and refs | |
CLI commands |
Directory layout
A bundle is a directory on disk. The bundle root contains bundle.yaml (kind: Bundle) plus every agent manifest reachable from spec.root through spec.agents local paths:
support/
bundle.yaml # kind: Bundle
bundle.lock.json # committed closure lock (run phrony bundles lock)
orchestrator.yaml # root agent (spec.agents → specialists)
specialists/
billing.yaml # vendored leafkind: Bundle
The Bundle document is packaging-only: no model, instructions, or tools. It names the bundle and points at the root agent file.
apiVersion: phrony.com/v1
kind: Bundle
metadata:
name: support
namespace: support
version: 1.0.0
spec:
root: ./orchestrator.yaml| Field | Required | Description |
|---|---|---|
metadata.name | Yes | Bundle name; unique with namespace |
metadata.namespace | Yes | Addressability boundary |
metadata.version | Yes | Semver release label ( 1.2.0 or v1.2.0); bump when the closure changes |
metadata.labels | No | Descriptive fleet metadata |
spec.root | Yes | Bundle-relative path to the root kind: Agent manifest |
Version identity
Each published bundle snapshot has two immutable identities, mirroring standalone agents:
| Identity | Source | CLI / API ref |
|---|---|---|
Semver label | metadata.version in bundle.yaml | support/support@1.2.0 |
Lock hash | sha256(canonical_json(bundle.lock.json)) | support/support@sha256:… |
Both are write-once at publish. After any closure change, run phrony bundles lock, bump metadata.version, then publish.
| Attempt at publish | Result |
|---|---|
New semver + new lock hash | Allowed |
Same semver + same lock hash (re-publish) | Rejected |
Same semver + different lock hash | Rejected — increment metadata.version |
Same lock hash + different semver | Rejected — hash already bound to another semver |
phrony bundles lock does not read or write metadata.version; the lockfile records closure content only.
Commit bundle.lock.json beside bundle.yaml. Run phrony bundles lock after any closure change; CI can gate on phrony bundles validate BUNDLE --require-lock.
Closure walk
During phrony bundles lock, phrony bundles validate, and phrony bundles publish, the CLI performs a depth-first closure walk starting at spec.root:
- Load and compile each vendored member agent.
- Follow every
spec.agentsedge that is notlate_bound. - Local edges (
./specialists/billing.yaml) resolve to another agent file inside the bundle directory. - External edges (
billing.refunds@1.4.0) pin an already-published global agent version. - Cycles in the delegation graph are rejected with a path showing the back-edge.
- Duplicate
metadata.nameamong vendored children in the same bundle is rejected.
The walk emits an ordered member list and a deterministic lockfile. The lock hash is sha256(canonical_json(lockfile))—a content fingerprint of the closure, separate from the hand-authored metadata.version semver.
Lockfile shape
The closure walk produces bundle.lock.json—a committed sidecar beside bundle.yaml. It records a content_hash for every closure member—vendored manifests (computed locally) and pinned external agent versions (resolved from the runtime catalog). The lock hash fingerprints the closure bytes; metadata.version is the human release label stored alongside it at publish. Publish verifies the lockfile locally and on the server before storing it; sessions then run against that frozen closure, not whatever happens to be actively deployed at call time.
phrony bundles lock is the only command that writes the lock. Commit the generated file in version control so PRs and CI can diff closure changes before publish. After a successful publish, the runtime stores the same bytes on bundle_versions.lock and returns the lock hash.
{
"version": "sha256:…",
"root_child_name": "orchestrator",
"members": [
{
"child_name": "orchestrator",
"origin": "vendored",
"content_hash": "…",
"ref": "./orchestrator.yaml"
},
{
"child_name": "billing",
"origin": "vendored",
"content_hash": "…",
"ref": "./specialists/billing.yaml"
},
{
"child_name": "refunds",
"origin": "external",
"namespace": "billing",
"name": "refunds",
"version": "1.4.0",
"content_hash": "…",
"ref": "billing.refunds@1.4.0"
}
]
}origin | Meaning |
|---|---|
vendored | Agent manifest lives inside the bundle; content_hash is computed from the compiled snapshot |
external | References an existing global agent_versions row pinned by namespace.name@version; content_hash is the manifest hash stored on that row |
External members record semver identity and manifest bytes via content_hash. The CLI resolves that hash from the runtime catalog during bundle lock and bundle validate (when a committed lock is compared). Publish resolves agent_version_id from the database and verifies the stored content_hash matches the committed lock; it fails if the pin is missing, retired, archived, or the hash drifts.
| Concern | In committed bundle.lock.json | At publish |
|---|---|---|
Vendored identity | content_hash from compiled JSON | Re-verified from sent manifests |
External identity | namespace / name / version pin + content_hash + ref | Resolve agent_version_id; verify DB content_hash matches lock |
Lock hash | sha256(canonical_json(lock body)) | Must match committed lock; never recomputed with different rules |
Semver label | metadata.version in bundle.yaml | Stored on bundle_versions.version at publish |
agent_version_id pinning | Not in lock | Written onto compiled delegation bindings in each member snapshot |
Changing any vendored member manifest—or any external member's resolved manifest bytes—changes the lockfile hash. Bump metadata.version and re-publish to register a new snapshot.
External hash resolution
Vendored-only bundles stay fully offline: the closure walk compiles local files and hashes them on disk.
When the closure includes pinned external refs (billing.refunds@1.4.0), phrony bundles lock and phrony bundles validate (lock comparison) call GetAgentVersion on the runtime to fill each external member's content_hash. Set --runtime-addr or PHRONY_RUNTIME_ADDR to the catalog that should author the lock—typically the same environment you publish against.
$ phrony bundles lock ./support/bundle.yaml --runtime-addr 127.0.0.1:7777Locks created before external content_hash support omit the field; bundles validate and bundles publish reject them with a message to re-run phrony bundles lock against the intended catalog.
Vendored identity
Vendored members do not occupy a global agents catalog slot. Their identity inside a published bundle is (bundle_version_id, child_name)—for example support@<lock-hash>/billing.
- Vendored
agent_versionsrows haveorigin=vendoredandbundle_version_idset; they have no standaloneagent_id. - External members reference existing global agent versions; the
bundle_membersedge keeps that snapshot alive for retention. - At runtime, each compiled
spec.tools[].agentbinding carries a frozenagent_version_idset during bundle publish. Dispatch uses that id directly—no per-call active-deployment lookup.
Lock, validate, publish
| Command | Purpose |
|---|---|
phrony bundles lock BUNDLE | Walk the closure, resolve external content_hash values when needed, and write bundle.lock.json (the only writer path) |
phrony bundles validate BUNDLE | Walk the closure; resolve external hashes when needed; compare against committed lock when present |
phrony bundles validate BUNDLE --require-lock | Fail when bundle.lock.json is missing (CI gate) |
phrony bundles publish BUNDLE | Require committed lock, verify no drift, then publish |
Typical workflow:
# Edit specialists, bump metadata.version in bundle.yaml, then:
$ phrony bundles lock ./support/bundle.yaml # add --runtime-addr when closure has external refs
$ phrony bundles validate ./support/bundle.yaml --require-lock # CI gate
$ phrony bundles publish ./support/bundle.yaml # → support/support 1.0.1 (sha256:…)
$ phrony bundles deploy support.support@1.0.1
$ phrony bundles run support.support
# CI/scripts can still: deploy support.support@sha256:…Without --require-lock, validate still passes when no lock exists (first-time authoring). Publish always requires a committed lock.
Publish → deploy → run
Publish
phrony bundles publish BUNDLE.yaml (or PublishBundle over gRPC):
- Parse and validate the Bundle manifest (including required
metadata.versionsemver). - Require
bundle.lock.json; re-walk the closure and verify it matches the committed lock. - Send the committed lock bytes in
committed_lock; the server re-verifies before storing. - Reject immutable redeploy: same lock hash with different lock bytes, same semver with different hash, or same hash with different semver.
- Resolve external members from
agent_versions(must exist, not be retired, andcontent_hashmust match the committed lock). - Materialize vendored members as bundle-scoped
agent_versionsrows. - Pin
agent_version_idon every compiled delegation binding in every member snapshot. - Insert
bundles,bundle_versions(semver + lock hash), andbundle_members. - Return semver, lock hash, and the stored lockfile.
gRPC (PublishBundle)
Custom publishers must mirror the CLI contract:
| Field | Requirement |
|---|---|
committed_lock | Required. Canonical bundle.lock.json bytes (same file phrony bundles lock writes). Empty → InvalidArgument. |
members | Same depth-first closure order as the lockfile members array. Order mismatch fails lock verification even when the set is correct. |
Vendored resolved_manifest | Compiled JSON whose content_hash matches the committed lock entry for that member. |
External authoring_ref | Pinned namespace.name@version; must match the lock entry. Server resolves agent_version_id and verifies DB content_hash against the lock. |
The server parses committed_lock, checks version against the body hash, rebuilds a verification lock from members, and rejects drift before persisting. It stores the committed bytes verbatim — it does not regenerate or enrich the lock.
Deploy
phrony bundles deploy support.support@1.2.0 activates a published bundle version by semver. You may also pin by lock hash: support.support@sha256:…. Only the active bundle deployment is eligible for new sessions when @version is omitted on run.
Run
phrony bundles run support.support starts a session against the bundle's root member. Pass an explicit @version (semver or sha256:…) or use --attach / -a to start and attach an interactive view in one step (same as phrony run).
- Resolve the active
bundle_version_idfrombundle_deployments. - Load the root member's
agent_version_idfrombundle_members WHERE is_root. - Create a session with
bundle_version_idset; nested delegation recurses through the frozen member manifests.
Secret resolution uses the union of fromEnv requirements across all frozen closure members. The CLI fetches that list from the runtime (via GetBundleSecretRequirements—same source as bundles secret-requirements), resolves values from the run host environment, and sends the full union on the root session. The runtime stores ciphertext in a bundle secret pool on the root session; nested child sessions inherit only the secret names they declare, decrypted from that pool. Inspect requirements before running with phrony bundles secret-requirements BUNDLE[@VERSION].
Pass bundle_ref in RunSession when calling the runtime API directly. Set version to semver or sha256:…; omit it to use the active deployment. An explicit @version must match the active deployment (comparison is by version id, not string label).
Promotion path
A common lifecycle for a specialist that starts vendored and graduates to a shared service:
- Vendored —
./specialists/billing.yamlinside the bundle closure. - Publish globally — publish
billingas a standalone agent when it is ready to be reused. - Pin external — change the orchestrator's
spec.agentsref tobilling.billing@1.2.0, runphrony bundles lock --runtime-addr …, and re-publish the bundle.
The lockfile records the external pin and its content_hash; runtime dispatch still uses the frozen agent_version_id compiled into the bundle snapshot.
Validation rules
| Rule | Error when violated |
|---|---|
kind | Must be Bundle |
metadata.version | Missing or invalid semver |
spec.root | Missing, absolute, or resolves outside bundle root |
Closure cycles | Back-edge detected during walk |
Duplicate vendored metadata.name | Two local members share the same child name |
External member missing | Pinned namespace.name@version not found at publish |
External content_hash drift | Committed hash does not match agent_versions.content_hash at publish |
Stale external lock | External member in committed lock has no content_hash (re-run phrony bundles lock with --runtime-addr) |
Missing committed lock | bundle publish without bundle.lock.json |
Lock drift | Committed lock does not match recomputed closure (re-run phrony bundles lock; use --runtime-addr when externals are present) |
Lock hash collision | Same hash, different member content on re-publish |
Semver drift | Same semver published with a different lock hash |
Hash bound to other semver | Same lock hash published under a different semver |
Secret union conflict | Two closure members declare the same secret name with different fromEnv values (fails at lock, validate, and publish) |
phrony bundles validate runs the closure walk locally. Vendored-only bundles never contact the runtime. When the closure includes external members, validation resolves their content_hash values from the catalog (same --runtime-addr / PHRONY_RUNTIME_ADDR contract as bundles lock). When bundle.lock.json exists, it must match the recomputed closure or validation fails with a drift error. With --require-lock, validation fails when the lock file is absent.
Up next