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 runs nothing. It holds no capsule runtime, stages no session, creates no directory, takes no host probe and starts no process — it resolves manifests from its registry, referees them against one another, and mints tokens. The launching is done by the runtime of the capsule that asked, 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 orchestrator --spawn-allow 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 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 this daemon may hold live at once across every formation — 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:
mur-roost: listening on 127.0.0.1:7700
mur-roost: machine ceiling 64 live capsules (--max-live-capsules)
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 and holds no state
outside its own memory. 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.
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": "worker-a",
"version": "0.1.0"
}
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Capsule 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": "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.
The response carries no capsule_url and no session_id, because nothing was started.
Error responses
| Status | Condition |
|---|---|
400 Bad Request |
Body is not valid JSON, or a required field is missing |
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 lowers the manifest into a spawn envelope itself; the request never states what the session holds. A request that could state its grants would be a request that could declare its own ceiling.
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": "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 name, as published |
version |
string | yes | Capsule version |
Extra keys are accepted and change nothing. A body carrying its own capabilities or envelope
block registers exactly the same grants as one carrying neither: 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, or session_id is empty |
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 still carries a valid MAC afterwards — nothing can un-mint one — but every endpoint that means anything requires the session it names to be running, so it authorises nothing from here on.
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 this daemon holds live across every formation at the moment of the read, against --max-live-capsules. 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 |
| 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 |
Three problems are absent rather than solved by this split. A daemon crash takes no child with it,
because nothing a child needs lives in the daemon's address space. Each child has its own process
environment and working directory, so a native subprocess started by one child inherits nothing a
sibling shares. And a child declaring the sealed containment floor enters a mount namespace of
its own, because every containment mechanism is installed per process and the child is a process.
The child's directory
The parent's runtime composes the child's directory at
<parent accessible workdir>/.murmur/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 retains write access to a running child's directory. That is deliberate: the parent creates the directory and places the child's inputs in it before launch, and the directory sits beneath the parent's own accessible workdir, which is a single preopen the WASI layer cannot carve a hole in. It is convenient — it is how a parent streams inputs to a child that is already running — and it is a channel the spawn envelope does not cover. A child cannot reach out of its own directory, so nothing flows the other way, but a parent can write into a running child's workspace without any grant saying so.
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 |
MURMUR_ROOST_URL |
Always, so the child registers at the same daemon its parent asked |
MURMUR_SPAWNER |
The child was given a spawner handle — see The completion path |
| 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. A name a child declares that its
parent does not is refused at POST /spawn on the capabilities.env.allow axis.
The four 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
inference.api_key references it fails at manifest load with
E-MAN-003.
The daemon resolves a child manifest's ${VARIABLE} references against its own process
environment when it referees a spawn. Start mur-roost in an environment holding every variable
the capsules it referees reference: a reference the daemon cannot resolve fails the spawn, for a
manifest the child itself would have loaded.
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 name. The schema's enum holds exactly this capsule's capabilities.spawn.allow, so the granted names are the only names the model can supply |
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 |
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 the outcome that arrives later |
session_id |
string | ses_…, the child's own session |
capsule, version |
string | The artifact that was launched |
status |
string | started, failed or refused |
child_workdir |
string | The child's directory, relative to the delegating capsule's accessible workdir — where its own trace.jsonl is, and where its result will be. 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
What the sub-capsule did reaches the delegating capsule afterwards, as its own task with
origin completion in the bg lane,
carrying the same dlg_ id the call returned. That is the whole of what a turn issuing several
delegations has to do differently: issue them, end the turn, and handle each outcome as it lands.
See The completion path for what one carries.
A delegating capsule has to be able to receive one. Under the default lifecycle block the
session ends with the task that made the delegation, and every outcome is posted to a session that
is already gone. A capsule that delegates declares:
| Key | Value | Why |
|---|---|---|
lifecycle.task_acceptance |
queue |
An outcome is an inbound task; a capsule accepting one task at a time never takes it |
lifecycle.queue_depth |
at least the number of delegations one turn issues | Each outcome in flight occupies a slot |
lifecycle.after_task |
sleep |
The session has to outlive the task that delegated |
A capsule that declares capabilities.spawn.allow and leaves that block unable to receive a
completion is warned at launch with W-SEC-020. The launch is not
refused: a capsule that delegates and deliberately does not wait is legitimate.
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 completion is posted to the delegating capsule, naming the bound in seconds |
The child-watch bound is the delegating capsule's own runtime's clock. No request is made to the daemon to decide or enforce it, so no daemon has to be reachable for it to fire.
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 the daemon's, not this tool's — see Delegation bounds. A delegation the daemon refuses comes back as a failed tool call carrying the refusal.
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 nothing reaches its parent until the child-watch bound stops it |
A plan's capsule step |
sleep |
The step reads the answer with an A2A tasks/get 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 outcome arrives at the
parent as a task with completion origin in the background lane — behind anything a person or a
peer is waiting for.
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. The completion task runs under it |
trust |
trusted or untrusted — the trust class of the parent task that made the delegation |
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 result is — never the child's output. The result stays in the child's own directory, which is inside the parent's single preopen, so a parent that wants it reads the file deliberately through an ordinary tool call.
| 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 |
Workdir-relative path to the result, absent when the child wrote none |
workdir |
The child's directory, absolute — the root result_path is relative to |
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 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 message/send to the parent's POST /, carrying the fields above as its message
text, with four request headers.
| Header | Value |
|---|---|
x-murmur-task-origin |
completion |
x-murmur-task-trust |
The trust of the injected handle |
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 refuses a completion whose x-murmur-completion-session is not the session
running there — the shape a parent that restarted onto the same address leaves behind — with the
JSON-RPC error completion is addressed to session <id>, which is not the session running here.
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 or error |
| The child's process ended without recording a completion | The parent's launcher | crashed |
| The parent ended the delegation itself | The parent's launcher, recorded and posted to nobody | 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 orchestrator --spawn-allow worker-a --spawn-allow worker-b - Capsule A's manifest:
capabilities.spawn.allow: [worker-a] - Capsule A registers; its session ID is
ses_01a0… - Capsule A's runtime sends
POST /spawnwithname: 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, and it runs exactly once per
delegation: the approval it grants names the resolved artifact by content hash, and that hash
determines the manifest the comparison read.
| 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 |
| 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 memory there. Nothing the capsule can read carries it: not the workdir, not an environment variable, not a tool result, not an error message. A capsule therefore cannot call the daemon itself; its runtime makes the requests on its behalf.
The approval reaches the child on the child process's standard input, written by the parent and
closed immediately — not on the argument vector and not in the environment, both of which any
process running as the same user can read out of /proc.
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"
}
Two requests differing only in whether the session they name exists get byte-identical responses — same status line, same headers, same body — so no endpoint can be used to discover 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 'worker-a@0.1.0', not 'worker-b@0.1.0' |
| Different bytes | '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 'worker-a' is not in parent's spawn_allow at POST /spawn, and
capsule '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 rather than from anything a capsule says about itself.
| 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 this daemon holds live 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 computes the child's number, seals it into the MAC'd
approval it mints, and reads it back out when that child presents the approval at POST /register.
Between those two moments no party states it: the parent's runtime never sees the number, the
POST /register body has no field it could arrive in, and an extra depth_remaining key in that
body is read by nothing. An approval whose payload was edited fails its MAC and 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 this daemon holds live on the host as a whole: every running
session across every unrelated formation, plus every approval it has granted that nobody has
redeemed yet. 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 — refusing would be mur run failing on a
machine the operator has just chosen to use, and this daemon referees delegation rather than
admitting the operator's own launches. 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.
One daemon per host. The ceiling counts what this daemon has admitted. Two mur-roost
processes on one host each enforce their own ceiling and together exceed it; roost holds no state
outside its own memory and coordinates with no other process. Run one daemon per host.
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. The three bounds above are unaffected: each is decided from the sessions the daemon is currently tracking, and a restart that discards those sessions also discards every credential and approval that could delegate under them.
--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_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 delegation bound, in whole seconds: how long a started sub-capsule is watched, and how long a plan capsule step waits for its 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 inference.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 inference turn happens: in the capsule's own process, by mur run.