---
title: "Roost API"
description: "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…"
canonical_url: "https://docs.murmur.nexus/reference/roost-api"
last_updated: "2026-09-09T13:38:07.000Z"
---

# 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:

```bash
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:

```bash
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](#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](#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](#no-total-cap).

The daemon's own environment holds no provider credential — see
[Environment variables](#environment-variables).

---

## Endpoints

### `GET /health`

Returns `200 OK` once the daemon is listening.

```json
{}
```

---

### `POST /spawn`

Ask permission to spawn a capsule. This is where the allow list and the
[spawn envelope](#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](#credentials-and-approvals) |

**Request body**

```json
{
  "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`**

```json
{
  "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](#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](#delegation-bounds) |
| `403 Forbidden` | This daemon already holds `--max-live-capsules` live capsules across every formation — see [The machine ceiling](#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](#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**

```json
{
  "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`**

```json
{ "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](#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**

```json
{ "outcome": "complete" }
```

| Field | Type | Required | Notes |
|---|---|---:|---|
| `outcome` | string | yes | `complete` or `failed`. Becomes the session's `GET /status` value |

**Success — `200 OK`**

```json
{}
```

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`**

```json
{
  "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`**

```json
{ "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](#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](#spawn-envelope).
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`](/reference/diagnostics.md#index).

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](/reference/observability-schemas.md#delegation-outcome).

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](/concepts/access-control.md#task-origin-and-trust-class) `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](#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`](/reference/manifest.md#lifecycle-task-acceptance) | `queue` | An outcome is an inbound task; a capsule accepting one task at a time never takes it |
| [`lifecycle.queue_depth`](/reference/manifest.md#field-lifecycle) | at least the number of delegations one turn issues | Each outcome in flight occupies a slot |
| [`lifecycle.after_task`](/reference/manifest.md#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`](/reference/diagnostics.md#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`](/reference/manifest.md#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](#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`](/reference/manifest.md#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`](/reference/diagnostics.md#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](/reference/observability-schemas.md#delegation-outcome) |
| `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`](/reference/manifest.md#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 /spawn` with `name: 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`](/reference/diagnostics.md#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:

```json
{
  "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:

```json
{
  "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](#refusals).

This is what terminates a capsule whose `capabilities.spawn.allow` names itself. The
[spawn envelope](#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:

```json
{
  "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:

```json
{
  "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}`](#get-statussession_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:

```json
{
  "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`](/reference/diagnostics.md#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](#the-completion-path). A value that is not a spawner handle refuses the launch with [`E-RUN-020`](/reference/diagnostics.md#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`](/reference/manifest.md#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`.
