Skip to content

mur-roost HTTP API

mur-roost is a local daemon that decides which capsules may spawn which other capsules. It listens on loopback and exposes five endpoints: a health check, one to ask permission to spawn a capsule, one for a session to announce itself, one to retire it, and one to poll a session.

The daemon only referees: it resolves manifests from its registry, compares them against one another, and mints tokens. The runtime of the capsule that asked does the launching, in a process of the child's own.

One thing calls it: a capsule's runtime. A spawn on behalf of a running capsule requires a credential the daemon minted for that capsule's runtime, which is held in runtime memory and is not readable from inside the capsule, so a shell tool cannot make the call itself.


Start the daemon

The installer ships mur-roost beside mur, in the same directory and at the same version:

curl -fsSL https://install.murmur.rs | sh

The published Linux x86_64 binaries need glibc 2.34 or newer — Debian 12+, Ubuntu 22.04+, RHEL 9+. The installer runs each binary once before putting it on your PATH, so a host that cannot start them is told so and left with whatever it already had. On an older distribution, build from source with cargo install murmur-cli.

Start it on loopback:

mur-roost --port 7700 --spawn-allow local/orchestrator --spawn-allow local/worker-a
Flag Default Description
--port 7700 Port to bind on 127.0.0.1
--registry-path $HOME/.murmur/artifacts Local artifact registry the daemon resolves manifests from
--spawn-allow (empty) One capsule full name, <scope>/<name>, that may register without an approval. Repeat the flag per name; --spawn-allow=NAME is also accepted
--max-depth 3 Levels of delegation allowed below a top-level capsule — see Delegation bounds
--max-concurrent 4 Children one session may hold live at once
--max-live-capsules Derived from the host's core count Capsules live on the host at once across every formation, counting those already running when the daemon started — see The machine ceiling
--version — Print the daemon's version and exit without binding a port

--spawn-allow takes a single name per occurrence, not a comma-separated list. It gates the top-level path only — the registrations that present no approval. Started with no --spawn-allow at all, the daemon admits only capsules launched under an approval it granted.

--max-depth, --max-concurrent and --max-live-capsules each take a whole number, and none has a value meaning unlimited: 0 refuses every delegation.

The default for --max-live-capsules is derived at startup: the daemon multiplies the host's core count by 8 and clamps the result to between 16 and 256, so a laptop and a build VM run under different numbers. It prints the figure it arrived at, after a line counting the capsules already running on the host — see Capsules running at startup:

mur-roost: 0 live capsules already running on this host, counted from /home/you/.murmur/running (0 records read in 0.1 ms; 0 stale and 0 unreadable not counted)
mur-roost: listening on 127.0.0.1:7700
mur-roost: machine ceiling 64 live capsules (--max-live-capsules)

The count is taken before the daemon listens, so no request is answered from a count that has not seen the host. A missing ~/.murmur/running is a host running nothing. The daemon refuses to start, exits with status 1 and binds no port when it cannot take the count:

Cause Reason printed
~/.murmur/running exists and cannot be listed — a file where the directory belongs, or a directory this user may not read The path and the OS error
HOME is unset, or not an absolute path the home directory could not be resolved: …
mur-roost: cannot count the capsules already running on this host: /home/you/.murmur/running: Not a directory (os error 20)

One daemon per host

The ceiling is one daemon's. Two mur-roost processes on one host each enforce their own and together exceed it, because roost coordinates nothing across processes. Run one daemon per host.

Any other flag is rejected and the daemon exits. There is no --max-total — see No total cap.

The daemon's own environment holds no provider credential — see Environment variables.

In a container

A parent capsule launches its children as processes of its own runtime, so the daemon, the parent and every child it delegates to run in one container:

  1. Run the container with a seccomp and AppArmor policy that permits unshare(CLONE_NEWUSER), or with --cap-add SYS_ADMIN. A capsule that declares capabilities.spawn.allow or capabilities.shell.allow needs a network namespace of its own, and a container that refuses one refuses the launch with E-CAP-005.
  2. Install mur and mur-roost in the image, and publish or install the parent and its sub-capsules into the store under the container's HOME.
  3. Start mur-roost in the container, with --spawn-allow naming the parent.
  4. Set MURMUR_ROOST_URL=http://127.0.0.1:7700 for the parent and run it with mur run in the same container.
  5. Run mur run --explain-scope for the sub-capsule's manifest in the container. Its mechanism: line is the tier the child applies there, and so the restriction its workdir snapshot reports.

A container that lets mur create a user namespace but not mount the composed root runs a child at landlock+seccomp at best, so a child that declares sealed is refused there.


Endpoints

GET /health

Returns 200 OK once the daemon is listening.

{}

POST /spawn

Ask permission to spawn a capsule. This is where the allow list and the spawn envelope are checked, and the only place they are checked.

Request headers

Header Required Notes
x-murmur-spawn-credential yes The credential the daemon minted for the calling session — see Credentials and approvals

Request body

{
  "name":    "local/worker-a",
  "version": "0.1.0"
}
Field Type Required Notes
name string yes Capsule full name, <scope>/<name>; must be in the calling session's capabilities.spawn.allow
version string yes Capsule version

The body carries no manifest and no capability declaration, and any extra keys in it are ignored.

Success — 200 OK

{
  "approval":      "msa1.eyJ2IjoxLCJz…",
  "name":          "local/worker-a",
  "version":       "0.1.0",
  "sha256":        "9f2c…",
  "expires_at_ms": 1756531200000
}

approval is an opaque token naming the artifact the daemon resolved, by name, version and content hash. name, version and sha256 are that artifact, echoed so the caller launches the same one the referee judged. expires_at_ms is the approval's absolute expiry in unix milliseconds, 60 seconds after it was granted.

Error responses

Status Condition
400 Bad Request Body is not valid JSON, a required field is missing, or name is not a full name, <scope>/<name>
403 Forbidden The credential is absent or not valid, or names a session that is not running — see Refusals
403 Forbidden name is not in the calling session's allow list
403 Forbidden The calling session has no delegation depth left — see Delegation bounds
403 Forbidden This daemon already holds --max-live-capsules live capsules across every formation — see The machine ceiling
403 Forbidden The calling session already holds --max-concurrent live children
403 Forbidden The capsule's manifest declares more capability than the calling session holds — see Spawn envelope
500 Internal Server Error The capsule could not be resolved from the registry

POST /register

A session announcing itself, and taking the credential it will delegate with.

The registrant names an artifact. The daemon resolves that artifact from its own registry and reads the session's grants from its manifest.

A session registers when its manifest declares capabilities.spawn.allow, and when it was launched under an approval — the second because presenting the approval is what marks it spent. Every other capsule never calls this endpoint, needs no daemon, and runs with nothing listening.

Request headers

Header Required Notes
x-murmur-spawn-approval no The approval this session was launched with. Absent means the top-level path, gated by --spawn-allow

Request body

{
  "session_id": "ses_01a000c58eae7ca0901d5e6b7427df28",
  "name":       "local/worker-a",
  "version":    "0.1.0"
}
Field Type Required Notes
session_id string yes The registering session's own id, minted by its runtime. Must not already be registered
name string yes Capsule full name, <scope>/<name>, as installed
version string yes Capsule version

Extra keys are ignored, including a capabilities or envelope block: the session's grants are always the ones in the registry manifest.

Success — 200 OK

{ "credential": "msc1.eyJ2IjoxLCJz…" }

The session is now running, and the credential is what makes its POST /spawn answerable.

Error responses

Status Condition
400 Bad Request Body is not valid JSON, session_id is empty, or name is not a full name, <scope>/<name>
403 Forbidden The approval is not valid, or the session that earned it is no longer running, or session_id is already registered — see Refusals
403 Forbidden The approval has expired, has already been redeemed, or names a different artifact
403 Forbidden No approval was presented and name is not in --spawn-allow, whether or not that name is published
500 Internal Server Error The capsule could not be resolved from the registry

An approval that does not verify is a failed exchange, not a request to be judged by the operator's list instead: it is refused rather than falling back to --spawn-allow.

The two paths check different things first, which decides the status an unusable request comes back with:

Request Checked first Checked next
No x-murmur-spawn-approval name is in --spawn-allow, else 403 Registry resolve, else 500
x-murmur-spawn-approval present Registry resolve, else 500 Approval matches the resolved name, version and content hash, else 403

POST /deregister

A session reporting that it has ended.

Request headers

Header Required Notes
x-murmur-spawn-credential yes The credential this session was minted at registration

Request body

{ "outcome": "complete" }
Field Type Required Notes
outcome string yes complete or failed. Becomes the session's GET /status value

Success — 200 OK

{}

The credential authorises nothing afterwards, because every endpoint that acts on one requires the session it names to be running.

Error responses

Status Condition
400 Bad Request Body is not valid JSON, or outcome is neither complete nor failed
403 Forbidden The credential is absent or not valid, or names a session that is not running

The call is best effort on the runtime's side: a session that has already finished is not failed because the daemon it was reporting to had gone away.


GET /status/{session_id}

Poll a registered session.

Success — 200 OK

{
  "status":          "running",
  "depth_remaining": 2,
  "live_children":   1,
  "live_capsules":   7
}
Field Type Meaning
status string running — registered and not yet retired; complete or failed — the session deregistered reporting that outcome
depth_remaining number Levels of delegation still available below this session, against --max-depth
live_children number Children this session holds right now, counting one it has been approved to launch and has not launched yet
live_capsules number Capsules live on the host across every formation at the moment of the read, against --max-live-capsules: those this daemon admitted, plus those it found running at startup that are still running. The same figure whichever session is polled

Error — 404 Not Found

{ "error": "session not found" }

Session records are held in memory. Restarting the daemon discards them, and every session ID from before the restart then returns 404.


Who launches what

Step Who does it
Ask whether a child may be spawned The parent capsule's runtime, at POST /spawn
Referee the child's manifest against the parent's The daemon
Create the child's directory and place its inputs The parent capsule's runtime
Copy the parent's work folder into the child's, for a capsule listed under capabilities.spawn.workdir_snapshot The parent capsule's runtime, before the child process starts — see Workdir snapshot
Start the child process The parent capsule's runtime, as a mur subprocess
Probe the host for its containment class The child's own runtime, in the child's own process
Record what the child holds The daemon, at the child's POST /register
Stop the child The parent capsule's runtime
Stop the child once the parent's process has ended The child's own runtime, on EOF on its spawner lifeline

Because every child is a process of its own:

  • A daemon crash takes no child with it.
  • Each child has its own process environment and working directory, so a native subprocess started by one child inherits nothing a sibling holds.
  • A child declaring the sealed containment floor gets a mount namespace of its own.

The child's directory

The parent's runtime composes the child's work folder at <parent records directory>/children/<capsule name>-<16 hex>, creates it owner-only (0700 on Unix), and passes it as the child's --workdir. The 16 hex characters are fresh per delegation, so spawning the same capsule twice yields two directories rather than one shared one.

The parent's commands can read a child's work folder and cannot write it. It lies in the parent's records directory, outside the parent's work folder, so it is read-only to the parent's commands wherever the host enforces the records directory. A child cannot reach out of its own work folder. It is removed with the parent's records directory, by mur trace rm or trace.retain.

The folder starts empty, unless the parent lists the capsule under capabilities.spawn.workdir_snapshot.

Workdir snapshot

A capsule listed under capabilities.spawn.workdir_snapshot starts every delegation in a work folder that already holds a copy of its parent's work folder as it stood when the delegation was made. The parent's runtime makes the copy into the child's directory before the child process exists. The child reads the parent's code, runs it and its tests, and writes caches, a .venv or anything else into its copy. The parent's folder is never granted to the child, and the copy is never written back: the parent receives only the child's answer.

The copy is taken once. Later changes to either folder are not seen by the other, and a file the parent's own commands are writing at that moment may be copied mid-write. The copy is removed with the parent's records directory, by mur trace rm or trace.retain.

What keeps the child's commands off the parent's real folder depends on the tier the child applies, and the child reports it as parent_workdir.restriction:

Child's tier restriction What keeps the child's commands off the parent's folder
mountns+pivot_root+landlock+seccomp (sealed) absent The parent's folder is not in the child's composed root
landlock+seccomp (scoped) enforced Landlock refuses every open of it
seccomp-only, or none on macOS (advisory) advisory Nothing in the kernel. The runtime grants it nowhere, but a command that names its path, or follows a copied link to it, reaches it, as it reaches any path the user can

On every tier a WASM tool of the child opens files in the child's own folder only, so no tool reaches the parent's folder.

What the copy holds:

Entry in the parent's folder In the child's copy
Regular file Its content, its rwx bits for user, group and other, and its modification time. Setuid, setgid and sticky bits are dropped
Directory Recreated with its rwx bits and, once its contents are in, its modification time
Symbolic link Recreated and never followed. A target that is an absolute path inside the parent's folder is re-pointed at the same place in the child's copy; every other target is copied as it is
FIFO, socket, device node Skipped, as fifo, socket or device
A file or directory the parent's runtime cannot open Skipped, as unreadable
peer-in and out at the top level — the runtime writes Skipped, as runtime-owned
The parent session's runtime directory, its records directory, and the child's own directory, when one lies inside the work folder Skipped, as runtime-owned

The copied bytes count against the child's own capabilities.resources.workdir_max_bytes, read from the child's manifest, or 10 GiB when it declares none. A parent folder larger than that stops the copy, removes the child's directory and fails the delegation before a child process exists: delegate-task returns status: failed, and a plan capsule step fails, with an output naming the child, the ceiling and capabilities.resources.workdir_max_bytes. A copy that fails for any other reason, such as a full disk, fails the delegation the same way.

Both sides record the copy:

Where What
The parent's mur run --explain-scope spawn workdir snapshot, the listed capsules
The parent's trace.jsonl delegation_start.workdir_snapshot: the source folder, counts, bytes and skipped entries
The child's trace.jsonl session_start.effective_grants.parent_workdir: the same object, plus restriction
The child's MURMUR.md One line under the work folder: the file count and size of the copy, and that changes there do not reach the parent

The child's environment

A delegated child starts from a cleared environment. Its parent's runtime builds the whole of what the child holds:

Name Present when
PATH Always, from the parent's own value
HOME Always, from the parent's own value
USER When the parent's own process holds it, from the parent's value
LOGNAME When the parent's own process holds it, from the parent's value
MURMUR_ROOST_URL Always, so the child registers at the same daemon its parent asked
MURMUR_HOST_WARNINGS_REPORTED Always 1, so the child does not repeat the host warnings its parent already printed, such as W-SEC-013
MURMUR_SPAWNER_LIFELINE Always: the descriptor of the child's own spawner lifeline
MURMUR_SPAWNER The child was given a spawner handle — see The completion path
MURMUR_FORMATION_ID The parent session belongs to a formation. The child joins it, whether or not it was given a spawner handle
Any other name The name appears in both the child's and the parent's capabilities.env.allow, and the parent's own process environment holds it

Every other variable the parent holds is absent from the child, including MURMUR_FORMATION_CHANNEL, MURMUR_FORMATION_PEERS, MURMUR_FORMATION_LIFELINE and the parent's own MURMUR_SPAWNER_LIFELINE: a child that declares any of them receives nothing from the parent's environment, and holds no formation token. A name a child declares that its parent does not is refused at POST /spawn on the capabilities.env.allow axis. The nine names above are the runtime's, and a child that lists one of them under capabilities.env.allow does not displace the runtime's value.

The value copied is the value the parent's process holds at launch. A variable the parent itself does not hold is absent from the child even where both manifests declare it, and a child whose gateway.api_key references it fails at manifest load with E-MAN-003.

A child ends with its parent

Every delegated child — from delegate-task or a plan capsule step, in a formation or not — is handed a spawner lifeline: the read end of a pipe of its own, named by MURMUR_SPAWNER_LIFELINE. The only write end is held by the runtime of the process that launched it, which never writes to it. When that process ends by any means — a clean exit, SIGTERM, SIGKILL, the OOM killer — the kernel closes the write end and the child reads EOF.

What the child does on EOF depends on what it is:

Child On EOF
Agent capsule Appends spawner_ended to its trace, then winds down as a first SIGTERM winds it down: cancels its live tasks, abandons detached shell work, writes session_end and exits. A teardown still running 20 seconds after EOF is cut short with status 143
Script capsule Exits with status 143 at once, as a SIGTERM ends it

EOF after the child's wind-down has already begun — from a SIGTERM or mur stop — writes nothing. The child's diagnostics go to its logs/bootstrap.log once its parent is gone, since its standard error was a pipe to the parent.

A child that delegates hands each of its own children a lifeline of its own, so a chain ends from the top down: a descendant d levels below the process that ended exits within d × 20 seconds, and usually well under a second per level. With the default --max-depth of 3, every descendant of a killed top-level capsule is gone within 60 seconds.

Ending Effect on a delegate-task child
The parent's process is killed The child winds down
The delegating task is cancelled — CancelTask, murmur.StopSession, or the parent's own wind-down on SIGTERM or lifeline EOF The task ends the child before it ends, and records it terminated
The delegating task ends any other way while the child is still running The task ends the child, and records it terminated
The delegation deadline passes The child is ended, and the task continues with a terminated outcome

Ending a child sends it SIGTERM and gives it 5 seconds to wind down before SIGKILL. A task that ends several children signals them all at once, so the whole ending waits one 5-second grace. What the child's own trace records depends on how it went down:

The child Its trace
Exits within the 5 seconds, mid-task Its task cancelled, then session_end with exit_status: canceled, and no spawner_ended
Exits within the 5 seconds, idle session_end with exit_status: ok, and no spawner_ended
Is still running after the 5 seconds, or is killed together with a parent that was itself killed No session_end. mur trace show reads it as ended without a session_end and names its last event

Whatever the child records about itself, its completion.json is the launcher's terminated record, written once the child has exited.

A plan capsule step's child is ended the same way when the step returns, which it does within about a second of the delegating task being cancelled, and otherwise ends with the parent's process. See When the task is cancelled.

On macOS a child can occasionally outlive its parent: if the parent starts another process at the instant it creates the child's lifeline, that process can hold the lifeline open, and the child winds down only once that process has exited too.


The delegation tool

A capsule whose manifest names at least one capsule in capabilities.spawn.allow gains one tool, delegate-task. The grant governs the tool's existence: where the list is empty, the tool is missing from the capsule's directory, from session_start's tools_declared and from the inventory its model is shown, so the model has nothing to call.

Argument Required Meaning
capsule yes The sub-capsule's bare name. The schema's enum holds the bare part of each capabilities.spawn.allow entry — worker for local/worker — so the granted capsules are the only ones the model can name. The runtime maps the name back to the full entry before asking mur-roost
version yes That capsule's exact version. latest, stable and edge are reserved words that resolve to no artifact
task yes The whole of what the sub-capsule is told. It arrives as the child's first user message

A capsule listed under capabilities.spawn.workdir_snapshot is named in one more sentence of the tool's description: it starts with a copy of this capsule's work folder as it is when the call is made, and nothing it changes reaches this capsule's work folder. See Workdir snapshot.

Those three strings are the whole of what the agent supplies. The daemon's address, this session's credential, the approval, the child's directory, the child's process and the A2A conversation with it are composed by the capsule's own runtime, so a delegating capsule needs no capabilities.network.allow entry for the daemon and never sees a token.

What the call returns

The call returns when the child starts, not when it finishes. A result is a JSON object:

Field Type Meaning
delegation_id string dlg_…, the id this delegation is named by in trace.jsonl and on its outcome
session_id string ses_…, the child's own session
capsule, version string The artifact that was launched, capsule by its bare name
status string started or failed. A refused call returns no object — see below
child_workdir string The child's work folder, absolute: <parent records directory>/children/<capsule>-<16 hex>. Present only on started
output string Why there is no delegation, on failed. Absent on started, which has produced nothing yet

started means the sub-capsule is running and holding its task, not merely that a process exists. Everything short of that fails the call in the same turn: a daemon refusal, a child that could not be launched, one that bound no address, and one that did not accept its task within the delivery bound. A child that fails at any of those points is stopped and reaped rather than left running.

This status is the call's own word for how far the call got, not for how the sub-capsule ended. How it ended is the completion's status. The delegation line in trace.jsonl carries one or the other, depending on which of the two it is recording — see Which outcome vocabulary applies.

A refusal comes back instead as a plain sentence naming the manifest key and the entry that failed, with no JSON around it. The delegating capsule's own run carries on: a refused delegation is a failed tool call, and the session continues.

How the outcome arrives

A delegation belongs to the task that made it, on every lifecycle. Once the model ends its turn, the task waits for every sub-capsule it started and continues in the same conversation with each outcome. The task stays TASK_STATE_WORKING while it waits, and its stream says so in a TASK_STATE_WORKING status whose message is one of these:

Status message When
waiting on delegate-task: M delegation(s) outstanding The task starts waiting with a delegation outstanding, and again with the new count each time one ends while another is still outstanding
continuing with M delegation outcome(s) Nothing is outstanding, and the task continues with every outcome

When the task also has call-member calls outstanding, it waits for both together, and the two texts are joined by ;.

The task waits only when its attempt finished with at least one turn of inference.max_turns left. It continues once nothing it handed off is outstanding, so a round of delegations costs one continuation however far apart the outcomes arrive: the delivery spends one turn and writes one task_continued trace line. The wait spends no tokens and counts against no spend ceiling.

Each outcome reaches the model as one block, the sub-capsule's answer under result: at its end:

[delegate-task] delegation dlg_0193… to worker@1.0.0 ended ok:
<untrusted-content source=delegation:worker>
Delegated capsule finished.
delegation_id: dlg_0193…
capsule: worker@1.0.0
session_id: ses_0193…
status: ok
duration_ms: 4210
workdir: /home/me/.murmur/records/home/me/project/ses_0192…/children/worker-3f2a…
result:
The report has three findings. …
</untrusted-content>

The delegating capsule's runtime reads the answer from the result file the sub-capsule left in its own directories, once the sub-capsule has exited, and hands it over inside the delegation:<capsule> fence. What follows result: depends on what the sub-capsule left:

The sub-capsule left The block ends with
A result of at most 64 KiB result: and the whole answer
A result over 64 KiB result:, the first 64 KiB of the answer cut on a character boundary and marked […], then the cut line below
An empty result file result: (the child's result file is empty)
No result file result: none (the child wrote no result file)

A cut answer is kept whole at delegations/<delegation_id>.txt in the delegating session's runtime directory, and the cut line names that place:

result:
The report has three findings. … […]
[cut at 65536 of 72000 bytes; the whole answer is delegations/dlg_0193….txt in the runtime directory]

Every kind of tool reads that file read-only: a WASM tool at murmur-runtime/delegations/<delegation_id>.txt, a shell command at $MURMUR_RUNTIME_DIR/delegations/<delegation_id>.txt. A copy that cannot be written is said in the cut line in place of the path: [cut at <kept> of <total> bytes; the whole answer could not be kept in the runtime directory: <reason>].

Every outcome of the round is delivered in one continuation, one block per delegation in id order. The capsule's own commands cannot read a sub-capsule's records directory, so the block is where the answer reaches the delegating capsule.

A task that ends before an outcome arrives ends the sub-capsule. The delegation trace line records terminated, with a reason naming why:

Why the task ended it reason
The task was cancelled, by CancelTask or by the session winding down the delegating task was cancelled
The attempt finished with no inference turn left the delegating task had no inference turn left to read this outcome
The attempt ended any other way the delegating task ended <exit status> before this sub-capsule finished
No outcome arrived within the backstop below no outcome reached the delegating task within <N>s of this sub-capsule starting

A cancel's task_canceled record and its CancelTask residue name every delegation that was in flight, taken before the task ends them.

Bounds

Bound Value What it covers
Launch 180s From starting the child process to its first --json line
Delivery 30s Retrying the task delivery while the child's listener comes up
Child watch lifecycle.delegation_deadline_secs, default 600s, or MURMUR_DELEGATION_TIMEOUT_SECS How long the started child runs. Counted from the moment it reported itself ready, so the launch bound above is not spent out of it. On expiry the child is ended and a terminated outcome naming the bound in seconds is delivered into the delegating task
Backstop The child watch plus 30s How long the delegating task waits for one delegation's outcome, counted from the launch. On expiry the task ends the child at once, and a terminated outcome saying no outcome arrived joins the round's continuation

Both bounds are the delegating capsule's own runtime's clock. No request is made to the daemon to decide or enforce them, so no daemon has to be reachable for them to fire. A task's whole wait is at most inference.max_turns × (child watch + 30s).

How deep a chain of delegations may go, how many a capsule may have running at once, and how many capsules the host carries are set on the daemon — see Delegation bounds.

What a capsule meant to be delegated to declares depends on which caller delegates to it, and the two want opposite things:

Delegated to by lifecycle.after_task Why
delegate-task exit A sub-capsule reports its outcome when its session ends. One that sleeps between tasks never ends, so its parent hears from it only when the child-watch bound stops it
A plan's capsule step sleep The step reads the answer with an A2A GetTask after the task completes, so the sub-capsule has to still be listening

The completion path

A delegated child tells its parent that it finished. The parent's runtime injects one variable at launch, the child posts one message back at the end of its session, and the parent's door hands the outcome to the task that made the delegation — see How the outcome arrives. A completion never becomes a task of its own.

What is injected

Field of MURMUR_SPAWNER Meaning
url The parent's own A2A endpoint, http://host:port
session_id The parent's session. A completion addressed anywhere else is refused
context_id The conversation the delegation was made from
delegation_id dlg_…, minted by the parent's launcher, one per launch

The value is compact JSON, applied last in the child's environment alongside MURMUR_ROOST_URL, so a child cannot displace it by listing the name in capabilities.env.allow. A capsule nobody delegated has no MURMUR_SPAWNER at all and contacts nobody. A capsule whose MURMUR_SPAWNER cannot be read refuses to launch with E-RUN-020.

What the completion carries

The delegation's identity, the outcome and where the child left its result — never the child's output. The child leaves its result in its own files: out/result.txt in the child's records directory for an agent child, or in its work folder for a script child, which writes its own. The parent's runtime reads it there and hands it over in the outcome block.

Field Meaning
delegation_id The id the parent's launcher minted, echoed back
capsule_name, capsule_version Which capsule ran
session_id The child's own session, so its trace is findable
status ok, error, crashed or terminated. These are the words the delegation trace line carries for a delegation closed from a completion — see Which outcome vocabulary applies
result_path The result file's absolute path, absent when the child wrote none. A record for a reader of completion.json: the parent's runtime does not open it
workdir The child's work folder, absolute
duration_ms How long the child ran
detail The exit status and the child's last stderr lines, on a crashed or terminated outcome

The same fields are written to completion.json in the child's own records directory, with three more that describe the delivery rather than the outcome.

Field of completion.json only Meaning
reported_by child or launcher — which of the two reporters built the record
delivered Whether the notification reached the parent's door
delivery_error Why delivery failed, when one was attempted and refused

How it travels

One JSON-RPC SendMessage to the parent's POST /, carrying the fields above as its message text, with four request headers.

Header Value
A2A-Version 1.0
x-murmur-task-origin completion
x-murmur-delegation-id The delegation_id of the injected handle
x-murmur-completion-session The session_id of the injected handle

The parent's door answers a completion ahead of lifecycle.task_acceptance and the task queue, so one is received under task_acceptance: none and while a single task is busy. The task reads the outcome from the child's completion.json, not from the message.

Completion Door's answer
x-murmur-completion-session is not the session running there — a parent that restarted onto the same address JSON-RPC error -31001 COMPLETION_MISADDRESSED: completion is addressed to session <id>, which is not the session running here
Names a delegation the running task is waiting for, or one already received A JSON-RPC result, {"delegation_id": "dlg_…", "received": true}
Names no delegation JSON-RPC error -31002 COMPLETION_NOT_AWAITED: completion names no delegation in x-murmur-delegation-id, so no task in this session is waiting for it
Names a delegation this session is ending JSON-RPC error -31002 COMPLETION_NOT_AWAITED: delegation dlg_… is being ended by this session; its launcher records the outcome
Names any other delegation, including one its task has already ended JSON-RPC error -31002 COMPLETION_NOT_AWAITED: no task in this session is waiting for delegation dlg_…

Both errors carry a google.rpc.ErrorInfo in the domain murmur.nexus; their reasons and metadata are listed under Agent Card: Completion errors. The child records an error answer's message as its delivery_error. Both delegation headers are read only for a request classified completion, and ignored on every other path.

Who reports, and what happens when nobody can

Situation Reporter status
The child's session ended The child, at the end of its own session ok when its task completed; error when the task failed, spent inference.max_turns, hit a spend ceiling or was canceled, or the session itself failed. The child's own task_end holds the precise status
The child's process ended without recording a completion The parent's launcher crashed
The delegating task ended the delegation The parent's launcher, recorded and posted to nobody; the detail is the parent ended this delegation terminated
The child was still running at lifecycle.delegation_deadline_secs The parent's launcher terminated

Both reporters write the outcome to completion.json before posting it, and rewrite the file with what the posting did. Between those two writes the record reads delivered: false with no delivery_error, so a reader polling the file should wait for one of the two to be set. A completion that could not be delivered is recorded with delivered: false and the refusal's reason, and one line goes to stderr; the launcher retries an undelivered completion once, and after that the file and the line are the record. A failed delivery does not fail the child's own session: status still records how that session ended.

A delegation is never reported twice, whichever reporter gets there first.

A host restart loses a delegation in flight

A parent that sleeps while a child works loses the child entirely if the host restarts: no completion arrives at the parent, and none is written to completion.json afterwards.


Per-session allow lists

mur-roost keeps two levels of capsule allow list, and how a session was launched selects between them.

Registration List consulted Where it comes from
No approval Global The daemon's --spawn-allow flags
Approval present Per-session capabilities.spawn.allow in the manifest of the capsule that owns the session the approval was granted to

A capsule that delegates can spawn only the names listed in its own manifest, even where the global list permits more:

  • Daemon started with --spawn-allow local/orchestrator --spawn-allow local/worker-a --spawn-allow local/worker-b
  • Capsule A's manifest: capabilities.spawn.allow: [local/worker-a]
  • Capsule A registers; its session ID is ses_01a0…
  • Capsule A's runtime sends POST /spawn with name: local/worker-b → 403

Spawn envelope

The credential selects two decisions, not one. The allow list above answers which capsules the spawning capsule may spawn. The envelope answers how much any of them may hold: the daemon lowers the child's registry manifest and refuses the request when the child would hold more capability than its parent on any axis.

The comparison runs at POST /spawn, after the name check. The approval it grants names the artifact it compared by content hash, so the child launched is the one that was judged.

Axis Manifest key Rule
Network allow capabilities.network.allow Every child entry is covered by a parent entry. A bare example.com covers https://example.com; a parent entry of https://example.com covers only that exact form, because the bare form spans both schemes and every port
Unix sockets capabilities.network.unix_sockets A child true requires a parent true
Peer-fetch allow capabilities.peer_fetch.allow The coverage rule above, applied to the separate list
Shell allow capabilities.shell.allow Every child binary name appears in the parent's list
Spawn allow capabilities.spawn.allow Every child capsule name appears in the parent's list
Env allow capabilities.env.allow Every child variable name appears in the parent's list
Filesystem scope capabilities.filesystem.scope A parent that declares no scope holds the whole workdir and covers anything. A parent that declares one covers a child scope equal to it or beneath it, and refuses a child that declares none
Workdir exec capabilities.filesystem.workdir_exec A child true requires a parent true
State stores capabilities.state.store Every store the child's artifacts would open is one the parent's artifacts also open. An artifact that declares state: without a store: opens a store named after its own capsule
Install skill capabilities.install.skill Every child entry is covered by a parent entry: * covers everything, p* covers names and patterns beginning with p, an exact name covers only itself
Install tool capabilities.install.tool The install coverage rule above, applied to the separate list
Containment capabilities.containment The child's floor is at or above the parent's

Containment is the one axis where a difference in the child's favour is allowed. A floor is a requirement rather than a grant, so it may only rise: a scoped parent may spawn a sealed child, and a child that declares advisory under a scoped parent is refused. Whether the host can actually deliver a raised floor is not this daemon's question — it takes no host probe. That is decided in the child's own process, at its own launch, and reported as E-CAP-003 when the host falls short.

A mismatch on any other axis is refused rather than narrowed to fit. Fix it by widening the parent's declaration or narrowing the child's.

A refusal names the manifest key and the child declaration that exceeded:

{
  "error": "capabilities.network.allow: the child declares 'api.example.com', which its parent does not hold — a spawned capsule can never hold more capability than the capsule that spawned it"
}

The grants compared are the ones in the manifest the daemon resolves from the registry.

A registration with no approval has no parent to be within: the global --spawn-allow list is the only gate, and the capsule's own grants are compared against nothing.


Credentials and approvals

A delegated spawn travels on two opaque, MAC'd tokens, presented by two different parties.

Token Minted Names Presented by Lifetime
Credential At POST /register The session it was minted for That session's own runtime, at POST /spawn and POST /deregister The daemon process
Approval At POST /spawn, once the referee has passed One session, and one artifact by name, version and content hash The launched child's runtime, at POST /register 60 seconds, one redemption

The credential is handed to the session's runtime and stays in its memory, out of the capsule's reach. A capsule therefore cannot call the daemon itself; its runtime makes the requests on its behalf.

The approval reaches the child on its standard input, written by the parent and closed immediately.

The approval binds a launch to the artifact the referee actually judged. A different name, a different version, or the same coordinates resolving to different bytes is refused. An approval is marked spent as soon as it verifies, before the artifact is compared, so presenting one for the wrong artifact consumes it.

Both keys live only in memory. Restarting the daemon invalidates every outstanding credential and approval at once.

Refusals

Refusals split into two classes.

Identity failures — an absent, malformed or unverifiable token, a credential naming a session that is not running, an approval whose granting session has ended, a credential presented as an approval or the reverse, and a session_id that is already registered — all answer 403 with one message, on every endpoint:

{
  "error": "not authorised: this daemon answers only a credential it minted for a running session, and an approval it minted for that same session"
}

The response is the same whether or not the named session exists, so no endpoint reveals which sessions are running.

Approval-state failures answer 403 and say what went wrong, because reaching one requires already holding a verifiable approval:

Condition Message
Expired this spawn approval has passed its expiry; an approval is valid for 60 seconds from the POST /spawn that granted it
Replayed this spawn approval has already been redeemed; an approval covers one launch, so ask POST /spawn for another
Different coordinates this spawn approval was granted for 'local/worker-a@0.1.0', not 'local/worker-b@0.1.0'
Different bytes 'local/worker-a@0.1.0' now resolves to a different artifact than the one this spawn approval was granted for (approved sha256 …, resolved sha256 …)

The name-list refusals are their own, and name the list an operator has to edit: capsule 'local/worker-a' is not in parent's spawn_allow at POST /spawn, and capsule 'local/worker-a' is not in --spawn-allow at POST /register.

Trust boundary

Within a single-machine local deployment the process boundary is the trust boundary. The daemon judges the session its credential names, not the session a request body claims, so a caller that reaches the loopback port without a credential can use only the top-level --spawn-allow path. The credential is a bearer token over loopback: anything that can read another session's runtime memory can present that session's credential.


Delegation bounds

Three bounds on delegation, all the operator's and all decided from the daemon's own records — and, for the machine ceiling, from the host's running-capsule records — rather than from anything a capsule sends the daemon.

Bound Flag Default What it counts
Depth --max-depth 3 Levels of delegation below a capsule that registered with no approval
Machine ceiling --max-live-capsules Derived from the host's core count Capsules live on the host at once, across every formation
Concurrency --max-concurrent 4 Children one session holds live at once

All three are checked at POST /spawn, after the name check and before the registry is read, and a refused spawn leaves no record anywhere — no session, no reservation, no workdir and no trace. Nothing is queued: a refused spawn is answered and forgotten, and the caller asks again if it wants another answer.

The order they are asked in is the order of the table, and it decides which refusal a caller hears when more than one applies:

Asked Bound Why here
First Depth Permanent for the asking session. No amount of waiting gives a session at 0 another level, so telling it to come back later would be false
Second Machine ceiling The one bound whose answer does not depend on who is asking, and the one that tells the caller the useful thing: waiting helps, narrowing the formation does not
Third Concurrency About this formation alone

The depth budget

Each session holds a number: how many further levels may hang below it. A session that registered with no approval holds --max-depth. Every other session holds the number sealed into the approval it registered with, which is one less than what its parent held. A session at 0 is refused every spawn it asks for.

The budget rides the approval. POST /spawn seals the child's number into the approval it mints, and reads it back when that child presents the approval at POST /register. No request body can set it, and an approval whose contents were edited is refused with the identity refusal.

This is what terminates a capsule whose capabilities.spawn.allow names itself. The spawn envelope narrows a chain but never ends one — a capsule that names itself narrows to itself — so with --max-depth 3 such a capsule runs at four levels and the fourth level's POST /spawn is refused:

{
  "error": "delegation depth bound reached: this daemon allows 3 levels of delegation below a top-level capsule (--max-depth 3), and this session has none left to spend — a capsule whose capabilities.spawn.allow names itself terminates here rather than recursing"
}

The concurrency cap

A session may hold --max-concurrent children at once. The refusal names the bound and the count, so an operator reading it knows what to raise and what it is being raised past:

{
  "error": "delegation concurrency bound reached: this daemon allows a capsule 2 live children at a time (--max-concurrent 2), and this session already holds 2 — wait for one to finish, or raise --max-concurrent"
}

A child occupies a slot from the moment its parent is approved to launch it, so live_children can exceed the number of sessions that have registered.

Event Effect on the parent's count
POST /spawn is granted The approval reserves a slot
The child redeems the approval at POST /register The reservation becomes a running child; the count is unchanged
The child's POST /deregister The slot is released
The approval's 60-second expiry passes unredeemed The slot is released, whether or not anybody ever presented it
The parent's own POST /deregister Every reservation it holds is released, because an approval it earned can no longer be redeemed by anybody

A session's current figures are readable at GET /status/{session_id} as depth_remaining and live_children.

The machine ceiling

--max-live-capsules bounds what is live on the host as a whole:

  • every running session this daemon tracks, across every unrelated formation
  • every approval it has granted that nobody has redeemed yet
  • every capsule it found running at startup whose process is still there

A parent comfortably inside its own --max-concurrent is refused when the host is full, and the refusal says so — it names a different flag from the concurrency one, because a caller acts differently on the two:

{
  "error": "machine capsule ceiling reached: this daemon allows 3 live capsules on this host across every formation (--max-live-capsules 3), and it is already holding 3 — this is the host's ceiling rather than this capsule's own --max-concurrent, so narrowing the formation does not help; the same request may be granted once other capsules finish"
}
Refusal What it means What acts on it
--max-concurrent This formation is too wide Narrow the formation, or raise the flag
--max-live-capsules The host is full Come back later

Slots are taken and released on the same events as a parent's live_children: a granted POST /spawn reserves one, redeeming the approval turns the reservation into a running session without changing the count, and a POST /deregister or the approval's 60-second expiry gives it back. Expiry is evaluated when the census is read, not swept on a timer. A daemon that has cycled a hundred formations therefore refuses no more than one that has cycled none, even though it never removes a session's record from its store.

Enforced at POST /spawn, counted at every registration. A POST /register presenting no approval is the operator starting a capsule from their own terminal. It counts toward the census once it is running, and the ceiling never refuses it. An operator who starts more roots by hand than the ceiling allows exceeds it, and the next delegated spawn is refused until the census falls back under.

The ceiling counts what this daemon has admitted, plus what it found running when it started — run one daemon per host, as Start the daemon explains.

Capsules running at startup

Before it listens, the daemon reads ~/.murmur/running — the running-capsule records mur ps lists — and counts every capsule whose process is still there. A restarted daemon therefore resumes the census rather than starting it from zero.

The count errs toward refusing. A capsule stops counting only once the daemon has evidence that it is gone, the same evidence mur ps prunes a record on. The check is repeated on every census read, at POST /spawn and GET /status/{session_id}, with no sweep and no timer, and a capsule found gone is never counted again. A session that also registers with the daemon is counted once.

Found at startup Counted
A record whose process is running and started when the record says Yes, until the process is gone
A record whose process id is held but whose start time cannot be read Yes — the holder may be the capsule
A record whose process is running and whose door does not answer Yes — the process still holds memory, and the daemon never contacts a door
A root started by hand before the restart that never registered Yes — the daemon cannot tell which capsules its predecessor admitted, so it counts every one. The count falls as they exit
A record whose process id nothing holds, or is held by a process that started at another time No — stale
A .json file that does not parse, or a record an older mur wrote No — unreadable, and under-counted if its capsule is running
A session that runs on with no record after W-SEC-023 No — under-counted
A script capsule whose run has not ended Yes — its record has no door, and its process decides it
A capsule running under another user's HOME No — under-counted: the daemon reads its own HOME only

The startup line reports the count, and every record read that it left out:

Part of the line Meaning
N live capsules already running on this host Records counted
counted from <dir> The running directory that was read
N records read in T ms Every .json file read, parseable or not, and how long the count took
N stale Records whose capsule is gone
N unreadable Files that did not parse, and records an older mur wrote

The daemon writes nothing in ~/.murmur/running: it creates no directory, changes no mode, and leaves every record where it found it, stale and unreadable ones included. mur ps prunes those.

A bound that cannot be evaluated refuses

Every figure a bound is decided from is read from the record of a running session. Where that record cannot be read the request is refused rather than granted on a default, and the refusal is the identity one. A POST /spawn whose credential names a session that has deregistered, and a POST /register whose approval was granted by a session that has since deregistered, are both refused on that ground.

No total cap

There is no cap on the total number of delegations, and no --max-total flag. An operator who tries to set one gets unknown argument: --max-total and the daemon exits.

The job store is held in memory, so restarting the daemon discards every registration and every count a total would be kept in. After a restart, depth and concurrency start fresh: each is decided from the sessions the daemon tracks, and the restart discards every credential and approval that could delegate under them, so no surviving session can delegate. The machine ceiling resumes from the running directory — see Capsules running at startup.

--max-live-capsules is not a total. It is a census of what is live right now, so a capsule that ends gives its slot back — a total would only ever grow.


Environment variables

Variable Set by Purpose
MURMUR_ROOST_URL The environment of the process that runs the capsule; set on a child by its parent's runtime Base URL the runtime registers at, and the base URL a plan's capsule step asks permission at. When it is unset or blank, a capsule that declares capabilities.spawn.allow refuses to launch with E-RUN-019, and a capsule step fails with MURMUR_ROOST_URL is not set; capsule steps require mur-roost
MURMUR_SESSION_ID The runtime, in every capsule The capsule's own session ID, which its traces carry and which mur run prints
MURMUR_SPAWNER The parent capsule's runtime, on a delegated child only Where the child reports its outcome, and under which delegation id — see The completion path. A value that is not a spawner handle refuses the launch with E-RUN-020
MURMUR_SPAWNER_LIFELINE The parent capsule's runtime, on every delegated child. Not for operators The number of the descriptor holding the child's end of its spawner lifeline. Never handed to a component, a shell command, a native tool, a hook, or a child of the child, which gets a lifeline of its own. Blank is absent. Naming anything but an open pipe's read end numbered 3 or above refuses the launch with E-RUN-020 before any session exists
MURMUR_FORMATION_ID mur run --roster, in each member's mur run environment; set on a member's children by the member's runtime The formation the session belongs to: frm_ followed by 32 lowercase hex digits. Shown on the readiness line and handed to every hook. Unset or blank is a session in no formation. A value that is not a formation id refuses the launch with E-RUN-044. It grants nothing on its own: what a member may call is decided by the formation tokens on its channel
MURMUR_FORMATION_CHANNEL mur run --roster, in each member's mur run environment, and nothing else The number of the inherited file descriptor the member reads its formation channel from: its credentials and its callees' door URLs. Never handed to a delegated child. One that cannot be read refuses the launch with E-RUN-046
MURMUR_FORMATION_PEERS A formation member's runtime, inside each of its WASM components The members it may call at their virtual addresses, as name=http://<name>.formation.invalid pairs separated by single spaces — see What a member's components are handed. Never handed to a native process or a delegated child. Set in mur run's own environment, it refuses the launch with E-RUN-046
MURMUR_FORMATION_LIFELINE mur run --roster, in each member's mur run environment. Not for operators The number of the descriptor holding the member's end of its lifeline — see How a formation ends. Never handed to a component, a shell command, a native tool, a hook or a delegated child. Blank is absent. Set without MURMUR_FORMATION_ID, or naming anything but an open pipe's read end numbered 3 or above, refuses the launch with E-RUN-046
MURMUR_MUR_BINARY The environment of the process that runs the capsule The mur binary a parent starts its children from. Defaults to the running executable, which in production is mur itself
MURMUR_DELEGATION_TIMEOUT_SECS The environment of the process that runs the capsule The single bound on handed-off work, in whole seconds: how long a started sub-capsule is watched, how long a plan capsule step waits for its answer, and how long a call-member call waits for its member's answer. Sets lifecycle.delegation_deadline_secs for the whole process. Default 600. A value that is not a positive integer is ignored

The spawn credential and the spawn approval have no environment variable. Every request to this daemon is made by the capsule's runtime, which holds them; MURMUR_SESSION_ID authorises nothing on its own.

The daemon's own environment needs no provider credential. It reads a capsule's manifest for the capability policy and declared state stores alone, leaving every gateway.api_key unresolved, so a registry of capsules referencing ${OPENAI_API_KEY}, ${ANTHROPIC_API_KEY} or any other variable resolves, registers and spawns in a daemon started with an empty environment. The provider key is read where the keyed request happens: in the capsule's own process, by mur run.