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:
- Run the container with a seccomp and AppArmor policy that permits
unshare(CLONE_NEWUSER), or with--cap-add SYS_ADMIN. A capsule that declarescapabilities.spawn.alloworcapabilities.shell.allowneeds a network namespace of its own, and a container that refuses one refuses the launch withE-CAP-005. - Install
murandmur-roostin the image, and publish or install the parent and its sub-capsules into the store under the container'sHOME. - Start
mur-roostin the container, with--spawn-allownaming the parent. - Set
MURMUR_ROOST_URL=http://127.0.0.1:7700for the parent and run it withmur runin the same container. - Run
mur run --explain-scopefor the sub-capsule's manifest in the container. Itsmechanism:line is the tier the child applies there, and so therestrictionits 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
sealedcontainment 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 /spawnwithname: 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.