Skip to content

Manifest Schema

murmur.yaml serves two roles, each with its own set of fields. An artifact's own manifest declares its identity and what mur build packages. A capsule's manifest declares what mur run launches: the artifacts it installs, the capabilities it grants, and how it reaches a model.


Artifact manifest

The manifest that ships inside a .mur.zip, read by mur build and mur publish.

Field Type Required Notes
name string yes The artifact's bare name, without a scope. Lowercase letters, digits and inner hyphens ([a-z0-9-], no leading or trailing -), non-empty, at most 100 characters. Anything else, a / included, fails the build with E-BLD-001. The artifact installs as <scope>/<name> — see Artifact names.
version string yes latest, stable and edge are reserved and cannot be published (E-REG-004).
description string no What the artifact does. mur index records its first sentence as the artifact's description in the artifact index.
runtime string yes The artifact's role. Two values change how it is packaged when execution is absent: skill packages as static, and tool with implementation: native packages as native. Everything else packages as wasm.
implementation wasm \| native no How a runtime: tool artifact is implemented. Default: wasm.
execution wasm \| native \| static no Declares the registry packaging type directly. When set it is authoritative for mur publish and overrides the derivation from runtime and implementation. Case-insensitive. An unrecognized value is a parse error.
requires_files list no Companion files that must sit beside murmur.yaml — and the complete list of what mur build packages besides the manifest itself. Paths are relative to the source directory and may be nested (assets/logo.png). Default: ["skill.md"] for runtime: skill, empty for every other role; an explicit value, including [], always overrides that default. A skill ships supporting files by listing them here (references/forms.md); a skill folder with no murmur.yaml instead packs every file in it — see mur build skill packaging. A missing file fails the build with E-IO-003, naming the first missing entry. Entries must be plain relative paths to real files: absolute paths, .. components and symlinks are rejected with E-BLD-002. An artifact with a compiled payload must declare it here, or the built .mur.zip contains nothing but murmur.yaml — for a wasm artifact that is E-BLD-003.
input_schema string (JSON) or map no A runtime: tool artifact's parameters, as a JSON Schema object. Offered to the model as the tool's input; every name in its top-level required must be present in a call's input or the call is refused before the tool runs. Default: an object with no properties, and no field required. input is an accepted older spelling. See input_schema.
upstream_auth map no How the artifact's upstream takes its key: header and a value template with {key} exactly once. Read only when the capsule's entry for this artifact declares gateway:; absent or malformed then refuses the launch with E-RUN-025. See upstream_auth: block.
models map no runtime: driver only. Each model id the driver serves, with its max_output_tokens, context_window and optionally max_input_tokens and vision. Default: empty. A capsule on the driver takes its output cap, context window and input ceiling from here. See models: block.
provider_tool map no runtime: tool only. Says the tool is one the model's provider defines itself: kind: computer with display_width and display_height. Default: absent, an ordinary tool. A capsule that declares such a tool must run sealed. See provider_tool: block.

A hook artifact's own manifest carries three more fields — see Hook contract fields.

name: my-tool
version: "0.1.0"
runtime: tool
execution: static
requires_files:
  - config.json

upstream_auth: block

How the artifact's upstream takes its key. Any artifact whose capsule entry may declare gateway: — a tool, hook or driver — declares this block in its own murmur.yaml. The runtime renders the header from the entry's gateway.api_key and attaches it to each request the artifact sends to MURMUR_GATEWAY_ENDPOINT (for the inference driver, also MURMUR_INFERENCE_ENDPOINT). The artifact itself never receives the key.

A web search tool whose upstream takes a bearer token:

name: my-web-search
version: "0.1.0"
runtime: tool
requires_files:
  - tool.wasm
upstream_auth:
  header: Authorization
  value: "Bearer {key}"
Field Type Required Notes
upstream_auth.header string yes A valid HTTP header name. Any header of the same name the artifact sets, in any case, is replaced. Cannot be Host, Origin, Referer, Cookie, Connection, Content-Length, Content-Type, Content-Encoding, Expect, Keep-Alive, TE, Trailer, Transfer-Encoding or Upgrade
upstream_auth.value string yes The header value. Must contain {key} exactly once, which is replaced by the entry's gateway.api_key. Under gateway.keyless: true, no header is sent

When the block is read and refused:

Case Result
Capsule entry declares no gateway: The block is never read
Capsule entry declares gateway:, block absent or malformed mur run refuses with E-RUN-025
Artifact manifest declares inference_auth:, the block's former name, with or without upstream_auth: mur build refuses with E-BLD-004; an artifact already packed refuses the launch with E-RUN-025 when its entry declares gateway:

models: block

The limits of each model a driver serves, declared in the driver's own murmur.yaml beside upstream_auth:. A transport: http capsule on the driver takes each driver choice's output cap and context window from the entry for its model, unless the capsule's manifest sets them, and its input ceiling from the entry alone.

name: driver-glm
version: "0.1.0"
runtime: driver
requires_files:
  - driver.wasm
models:
  glm-5.3:
    max_output_tokens: 131072
    context_window: 200000
  gpt-5.6:
    max_output_tokens: 128000
    context_window: 1050000
    max_input_tokens: 922000
Field Type Required Notes
models.<id> map — One entry per model id. The id is matched by exact string equality with inference.model or an alternate's model: no prefix, alias or case folding, so a driver that accepts a dated id and an alias lists both. Must not be empty. No key besides the four below
models.<id>.max_output_tokens integer yes The most output tokens the model produces in one completion. 1 to 4294967295
models.<id>.context_window integer yes The model's context window in tokens. 1 to 4294967295
models.<id>.max_input_tokens integer no The most input tokens the provider accepts in one request for the model. 1 to 4294967295, and at most context_window. With it, every call's input budget is at most this number
models.<id>.vision boolean no Whether the model reads images. Absent means unknown, and nothing is refused for it. false refuses a transport: http capsule that declares a computer tool and runs this model on any driver choice, with E-RUN-057
Case Result
models: malformed — a max_input_tokens above its context_window included — or declared on an artifact whose runtime is not driver mur build refuses with E-BLD-005, naming the field
A packed murmur.yaml whose models: is malformed mur install and mur publish refuse with E-REG-011, naming name@version and the field, and write nothing to the store or murmur.lock
A stored driver whose models: is malformed mur run refuses with E-RUN-052, naming the driver and the field
The table lists no entry for a choice's model The capsule's own inference.max_tokens and context.max_tokens apply; see Output cap

provider_tool: block

Declared in a runtime: tool artifact's own murmur.yaml, never in the capsule manifest. It says the tool is one the model's provider defines itself. The runtime adds the block, as declared, to the tool's entry in the tool array it hands the driver; which provider tool the driver sends for it is the driver's choice. See Provider tools.

name: computer
version: "0.1.0"
runtime: tool
requires_files:
  - tool.wasm
provider_tool:
  kind: computer
  display_width: 1280
  display_height: 800
Field Type Required Notes
provider_tool.kind string yes computer, the only kind: the tool operates a display, keyboard and pointer, and returns screenshots
provider_tool.display_width integer yes The display's width in pixels. 1 to 65535
provider_tool.display_height integer yes The display's height in pixels. 1 to 65535

No other key is accepted.

Case Result
The block is malformed, or declared on a driver, hook or skill artifact mur build refuses with E-BLD-006, naming the field
A stored artifact whose block is malformed mur run refuses with E-RUN-058, naming name@version and the field
The capsule's effective containment floor is below sealed mur run refuses with E-CAP-003 before the session directory exists, naming every such tool — see Provider tools
A transport: http driver choice's model is vision: false mur run refuses with E-RUN-057
A running session pulls such a tool with pull-artifact Refused on the same two terms, with nothing installed

input_schema

The JSON Schema object a tool offers the model as its parameters. Written as a YAML string holding JSON, or as a YAML mapping:

input_schema: |
  {"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}

Required fields are enforced. Before a call to the tool runs, the runtime checks that every name in the schema's top-level required is a key of the call's input. A call missing one or more of them is refused without running the tool — on transport: http, on transport: process and in a plan tool step alike. The model is handed a failed tool result:

tool-write: missing required field "content"

This call did not run. tool-write's input_schema requires: "path", "content". Call it again with every one of them present. Only the presence of required fields is checked before a call runs — not their types, allowed values or nested contents.

With several fields missing, the first line names all of them: missing required fields "path", "content". The trace records a tool_input_refused beside a tool_call with status: "error"; a plan step records the same text in its plan_step record's error instead. A refusal is an ordinary tool result: the turn continues, and every turn the model spends retrying counts against inference.max_turns.

Checked Not checked
Each name in the top-level required is a key of the input object Value types, enum values, nested objects, $ref
An input that is not a JSON object is missing every required name A null or empty value — the key is there, so the field is present
Path Order of the pre-dispatch checks
A direct call, either transport Required fields, then capabilities.filesystem.read_only, then an on-tool-call policy hook. A call missing a field never reaches the hook
A plan tool step read_only and the policy hook when the step is reached, then required fields when it is invoked

There is no opt-out. A field the tool supplies a default for belongs out of required.

Schema Calls
No input_schema, no required, or required: [] Not checked
A string that is not JSON Not checked
A runtime: skill artifact's schema Not checked
Root is not a JSON object Not checked; W-RUN-004 once per tool per session
required is not an array of strings Not checked; W-RUN-004 once per tool per session

The schema is read when each call is made, so a tool pulled into the session at runtime is checked against its own schema from its first call.


Capsule manifest

The manifest mur run reads.

One murmur.yaml can be both manifests at once: a capsule you also publish carries the artifact manifest fields beside the capsule fields below. mur run and mur doctor accept runtime, implementation, execution and requires_files, take no run-time meaning from them, and do not report them as unrecognized keys (W-SEC-019).

Where artifacts come from is set in ~/.murmur/config.yaml, not in a manifest — see Resolution order. Both manifests refuse a top-level registry: key, whatever it holds, before any other field is checked, so mur install, mur run, mur doctor, mur eval, mur build and mur publish all fail on it with E-MAN-003:

Field Refused with
registry murmur.yaml: 'registry' is not a manifest key

Supported shape

name: my-capsule
version: "0.1.0"

artifacts:
  - name: ana/some-tool   # <scope>/<name>
    version: "1.2.3"
    runtime: tool  # optional, defaults to tool
    gateway:       # optional: the runtime presents this tool's third-party key for it
      endpoint: https://api.tavily.com
      api_key: ${TAVILY_API_KEY}   # optional; literal value or ${ENV_VAR}
  - name: murmur/driver-anthropic
    version: "0.19.0"
    runtime: driver
    gateway:       # required on the transport: http driver
      endpoint: https://api.anthropic.com
      api_key: ${ANTHROPIC_API_KEY}
  - name: murmur/hook-debug
    version: "0.10.0"
    runtime: hook  # lifecycle observer; hidden from the model

capabilities:
  network:
    allow:
      - https://api.github.com  # hosts tools and shell subprocesses may reach; not needed for a gateway's upstream
    unix_sockets: false  # optional, defaults to false: may shell subprocesses create AF_UNIX sockets?
  peer_fetch:            # optional: peers this capsule may redeem a peer-file handle against
    allow:
      - localhost:41234
  filesystem:
    scope: ./workdir
  shell:
    allow:
      - bash           # binaries the agent may invoke as shell tools
    strip_env:         # optional: env var patterns to strip from subprocess environment
      - AWS_*
    baseline_env:      # optional: env var patterns to keep after stripping
      - PATH
    interpreter_runtime:            # optional: host dirs a path-based interpreter needs
      - binary: python3             # MUST already appear in allow above
        dirs:
          - path: /usr/lib/python3.11
            list_dir: true          # the dir's entries are enumerable
          - path: /usr/lib/python3.11/lib-dynload
            list_dir: false         # files openable by exact name, dir not listable
    staged_runtime:                 # optional: pinned host runtime trees to bind-mount into a sealed root
      - binary: python3             # MUST already appear in allow above; MUST NOT also have an interpreter_runtime grant
        source_path: /opt/testbed/conda/envs/django__django   # absolute host path to an already-pinned tree
        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01    # required; never inferred
  env:
    allow:             # optional: host env vars a WASM component (capsule/tool/driver) may observe
      - MY_APP_REGION

network:
  internal_port: 14159  # optional; omit to let the OS assign a free port
  authentication:       # optional; omit for a door that answers every caller
    scheme: bearer      # required in the block; the only scheme
    credentials:        # optional; tokens narrower than the operator token
      watcher:
        scopes: [GetTask, murmur.WatchSession]

context:
  max_tokens: 200000   # optional; overrides the context_window the driver declares for the model
  record: on           # on (default) | off — the durable conversation record
  record_store: shey   # optional; directory under ~/.murmur/conversations/, default: capsule name
  retain:              # optional; omit to keep every record whole and forever
    max_messages: 2000 # truncate the front of the record this launch opens beyond this
    max_age: 90d       # drop a record this capsule owns and has not written to for this long

observability:
  otel_endpoint: http://localhost:4318  # OTLP/HTTP endpoint; absent = no external span export
  eval:
    dataset_id: my-dataset              # optional; labels dataset_run records
    scorers:
      - type: exit_ok
        name: success_check
      - type: max_turns
        name: turn_limit
        max: 5
      - type: max_tokens
        name: token_budget
        max: 100000
      - type: tool_sequence
        name: tool_order
        expected: [bash, python]

trace:
  capture: content    # optional; default meta: also store the bodies behind the trace's hashes
  retain:             # optional; omit to keep every session's records forever
    max_sessions: 50  # keep the newest N sessions, this launch's own included
    max_age: 14d      # and/or drop anything whose ses_ id was minted longer ago than this

inference:
  transport: http
  model: claude-opus-4-5
  driver:
    artifact: murmur/driver-anthropic  # its entry above carries the gateway
    config:
      some_flag: true             # optional free-form JSON object
  compaction:
    threshold: 0.85              # optional; default 0.98
    model: claude-haiku-4-5      # optional; defaults to primary inference model
    system_prompt: |             # optional; defaults to the compaction hook's own prompt
      task = X, currently editing Y, already tried Z.
    # system_prompt_file: compaction-instructions.md  # alternative: load from file
    # dump_summaries: true       # optional; default false
    # The compaction hook is selected by binding, not named here: declare an
    # artifact with `runtime: hook` and `binding: on-compaction` (see artifacts:).
  system_prompt: |              # optional; injected as `system` on every API call
    Always begin responses with CONFIRMED:
  # system_prompt_file: conventions.md  # alternative: load from file
  # instruction_files: [AGENTS.md, CLAUDE.md]  # optional; work-folder files appended to the prompt
  # max_turns: 50               # optional; default: no cap

# Alternative: run a harness CLI as a subprocess (no ANTHROPIC_API_KEY required)
# inference:
#   transport: process
#   driver:
#     artifact: local/my-process-driver  # the process driver that knows this harness
#   max_turns: 50

lifecycle:
  task_acceptance: queue  # none | single (default) | queue
  after_task: sleep       # exit (default) | sleep
  queue_depth: 2          # only meaningful for queue mode; default: 1
  input_timeout_secs: 60  # optional; absent = wait indefinitely for request-input reply
  conversation: threaded  # stateless (default) | threaded

exports:
  files:
    root: out/            # required; relative to the work folder
    mode: read-only       # required; the only accepted value
    max_bytes: 10Mi       # optional; per-file read ceiling, default 10Mi
  peer_files:
    root: out/handoff/    # required; relative to the work folder
    max_ttl: 15m          # optional when after_task: exit (default 1h); required, max 15m, when sleep
    max_bytes: 10Mi       # optional; per-file ceiling on a redeemed read, default 10Mi
  peer_tasks:
    accept: true          # required; whether the door serves tasks from other capsules

control:                  # optional: what a controller may change while the capsule runs
  settings: [inference.max_tokens]
  secrets: [TAVILY_API_KEY]   # each backs some artifact's gateway.api_key: ${NAME}

deploy:                   # optional: read by mur deploy only
  mur_version: "1.0.0"    # optional; the mur release installed on the target, default the running mur

Field reference

Identity

Field Type Required Notes
name string yes Capsule identity: the capsule's bare name, as for any artifact.
version string yes Capsule version.

artifacts

Field Type Required Notes
artifacts list no Defaults to empty.
artifacts[].name string yes (per entry) The artifact's full name, <scope>/<name>, such as murmur/tool-git — see Artifact names. A bare name is refused with E-MAN-003 and a hint naming its scoped forms. An entry that declares source: takes the bare name instead, and a scope on it is refused. The bare part is the tool name the model calls, so two entries may not share one (E-CAP-020).
artifacts[].version string yes (per entry) Optional when source is set — see Local-source artifacts.
artifacts[].runtime tool \| driver \| hook \| skill no Default: tool. tool and skill artifacts are model-visible; driver and hook artifacts are hidden from the model. wasm and native are rejected here — those belong in the artifact's own manifest as implementation.
artifacts[].source string no Local path the runtime resolves this artifact from instead of the registry. Requires local_source: true. See Local-source artifacts.
artifacts[].local_source bool no Opts this artifact into source: resolution. Default: true for runtime: skill, false for every other role; an explicit value overrides that default in both directions. See Local-source artifacts.
artifacts[].prompt_payload bool no Opts this artifact into being named by inference.system_prompt_artifact. Default: true for runtime: skill, false for every other role; an explicit value overrides that default. See inference.system_prompt_artifact.
artifacts[].capabilities map no Per-artifact capability grant, recognized on runtime: hook, runtime: tool and runtime: driver. The baseline differs by role: on a hook, absent means no network and no filesystem at all (see Hook capabilities); on a tool or driver, absent means the unchanged capsule-wide ceiling, and a declared block narrows below it (see Tool and driver capabilities). capabilities.state is the exception to both baselines: absent means no durable store for any role, and a declared block opens one directory outside every workdir. On a tool with a native implementation state is the only sub-block that applies (Native tools). Declaring it on runtime: skill fails with E-MAN-003.
artifacts[].config map no Operator-authored configuration delivered to this artifact alone as the MURMUR_ARTIFACT_CONFIG environment variable, serialized as compact JSON. Recognized on runtime: hook, runtime: tool (WASM and native) and runtime: driver; declaring it on runtime: skill, or at the top level of the manifest, fails with E-MAN-003. Absent, the variable is absent from that artifact's environment. See Artifact config and Choosing a config block.
artifacts[].gateway map no The third-party upstream this artifact reaches through the credential gateway, and the key the runtime presents there. Required on the transport: http driver inference.driver.artifact names. Which other entries accept it is in Credential gateway.
artifacts[].gateway.endpoint string yes (when gateway is set) Upstream URL. A gateway: without it fails with E-MAN-003. See gateway.endpoint validation.
artifacts[].gateway.api_key string yes, unless keyless: true Literal value or ${NAME} reference. A gateway with neither a non-blank api_key nor keyless: true fails with E-CAP-018. See gateway.api_key resolution.
artifacts[].gateway.keyless boolean no true declares an upstream that takes no key: requests go out with no credential header. Default: false. Writing it with api_key fails with E-MAN-003. See Keyless upstreams.
artifacts[].on_overflow drop \| block no Default: drop. Recognized only on runtime: hook; declaring it on any other role fails with E-MAN-003. Governs what happens when an execution_mode: async hook's job queue is full — see Async hook execution. Legal but inert on a hook that turns out to be execution_mode: blocking, which has no queue.
Hook capabilities

A hook runs default-deny: a runtime: hook entry with no capabilities: block gets no network capability (no raw WASI sockets, and an empty outbound allow-list, so every HTTP request is denied) and no directory at all — it cannot read or write any file, not even in its own working directory. Network and filesystem are granted back one hook at a time, from that hook's entry in your own murmur.yaml:

artifacts:
  # default-deny: no network, no filesystem
  - name: local/hook-observe
    version: 0.1.0
    runtime: hook

  # granted exactly one host and exactly one directory
  - name: local/hook-telemetry
    version: 0.1.0
    runtime: hook
    capabilities:
      network:
        allow:
          - https://telemetry.example.com
      filesystem:
        scope: hook-state

  # granted the task text and the agent's result, and nothing else
  - name: local/hook-output-gate
    version: 0.1.0
    runtime: hook
    capabilities:
      task_io:
        read: true

  # granted the capsule's conversation record, and nothing else
  - name: local/hook-recall
    version: 0.1.0
    runtime: hook
    capabilities:
      conversation:
        read: true

  # granted a durable store and nothing else — no project directory at all
  - name: local/hook-notes
    version: 0.1.0
    runtime: hook
    capabilities:
      state: {}

  # writes its records beside the session's trace, outside the workdir
  - name: murmur/hook-eval
    version: 1.0.0
    runtime: hook
    capabilities:
      filesystem:
        records: true

  # each of its calls may run for up to two minutes
  - name: murmur/hook-compact
    version: 1.0.0
    runtime: hook
    capabilities:
      limits:
        deadline_seconds: 120
Key Type Required Description
task_io.read bool yes Whether this hook may read the task's input text and the agent's result text through murmur:task-io/read. Never inferred: a task_io: block that omits it fails with E-MAN-003.
conversation.read bool yes Whether this hook may read the capsule's conversation record through murmur:conversation/read. Never inferred: a conversation: block that omits it fails with E-MAN-003.
state.store string no Directory name under ~/.murmur/state/. Omitted, the capsule name is used. See Durable state.
filesystem.scope string no A directory of the workdir mounted as the hook's current directory. See the rules below.
filesystem.records bool no Mount the session records directory's hooks/ as the hook's current directory, read-write, in place of a scope. Default: false. Recognized only on a runtime: hook entry; declared true together with filesystem.scope, on any other role, or in the capsule-wide capabilities block, it fails with E-MAN-003.
limits.deadline_seconds integer no Wall-clock budget for each of this hook's lifecycle calls, in seconds, on-stage included. Default: the capsule-wide capabilities.limits.deadline_seconds, else 30. Must be > 0. A call past it ends with a message naming this key on this entry.

Rules:

  • Only the capsule operator can grant. The grant is read from your capsule manifest's artifact entry. A capabilities: key inside a published hook artifact is inert.
  • The grant is per-hook. The capsule-wide capabilities block does not reach hooks, and a hook's grant does not widen the capsule.
  • network.allow takes the same entries and the same enforcement as the capsule-wide block — the host, host:port and scheme://host[:port] forms of Network allow entries. Anything not listed is denied.
  • filesystem.scope is a real directory grant. Exactly one directory — <workdir>/<scope> — is mounted as the hook's current directory, and is created if missing. Paths outside that subtree are unreachable. An absolute scope, or one that escapes the workdir via .., fails at launch with E-CAP-002 before any hook component is instantiated.
  • filesystem.records keeps what a hook writes out of the commands' reach. A hook that writes records — murmur/hook-eval's eval.jsonl and its own log — declared records: true writes them into hooks/ beside the session's trace.jsonl, where the capsule's commands can read them and cannot change them, and where mur eval reads hooks/eval.jsonl. The same hook declared scope: . writes them into the workdir. It works on every hook binding, on-stage included.
  • state is a second, independent directory grant. A hook holding one reaches ~/.murmur/state/<store>/ as state/, alongside — or instead of — the filesystem.scope directory mounted as .. The two do not imply each other in either direction, so a hook can hold durable state without being handed the project directory. The store is keyed by capsule, so it survives a launch that gets a fresh work folder. See Durable state.
  • task_io.read grants a host import, not a directory. A granted hook reads the task text and the result text from the runtime itself, so it needs no filesystem.scope for either. An ungranted hook still loads and still runs; every read returns not-granted.
  • task_io is recognized only on a runtime: hook entry. Declaring it on a runtime: tool, runtime: driver or runtime: skill entry, or in the capsule-wide capabilities block, fails with E-MAN-003 — nothing there could enforce it.
  • conversation.read grants a host import, not a directory. No artifact ever gets a filesystem path into ~/.murmur/conversations/, so a granted hook reads the record through the interface and an ungranted one gets not-granted from the call rather than failing to load. Declaring the key on any role other than runtime: hook fails with E-MAN-003; declaring it in the capsule-wide capabilities block is inert and prints W-SEC-016.
  • Only network, filesystem, state, task_io, conversation and limits.deadline_seconds govern a hook. shell, spawn, env, limits.memory_bytes, limits.table_elements, limits.instances, resources and containment parse here but are capsule-wide concerns the runtime does not apply per-hook; declaring one prints W-SEC-006.
Tool and driver capabilities

A runtime: tool or runtime: driver entry takes the same capabilities: key, but the baseline is inherit-and-clamp rather than default-deny. An entry with no capabilities: block runs on the full capsule-wide capabilities ceiling. Declaring a block narrows that one artifact:

capabilities:            # the capsule-wide ceiling
  network:
    allow:
      - https://api.example.com
      - https://other.example.com

artifacts:
  # unchanged: reaches both ceiling hosts, sees the whole workdir
  - name: local/broad-tool
    version: 0.1.0
    runtime: tool

  # narrowed: one host, and only the `cache/` subtree of the workdir
  - name: local/scoped-tool
    version: 0.1.0
    runtime: tool
    capabilities:
      network:
        allow:
          - https://api.example.com
      filesystem:
        scope: cache

  # widened by exactly one directory: a durable store outside every workdir
  - name: murmur/tool-corpus
    version: 0.1.0
    runtime: tool
    capabilities:
      state:
        store: shey
Key Type Required Description
state.store string no Directory name under ~/.murmur/state/. Omitted, the capsule name is used. See Durable state.

Rules:

  • Narrowing only ever subtracts. The effective grant is declaration ∩ ceiling. An entry naming a host the ceiling does not itself allow is dropped, and prints W-SEC-007 naming the artifact and the dropped entry. Staging continues.
  • A bare host does not fit under a scheme-bound ceiling entry. api.example.com spans both schemes and every port, so a ceiling of https://api.example.com does not cover it and it is dropped. Write the narrowing at least as specific as the ceiling entry it sits under.
  • network.allow: [] is a real narrowing to zero, distinct from omitting the key: an explicit empty list denies that artifact all outbound HTTP while its siblings keep the ceiling.
  • filesystem.scope is a real directory grant. <work folder>/<scope> is mounted as that artifact's current directory instead of the whole workdir, and is created if missing. scope: "." is the explicit "whole workdir" grant. An absolute scope, or one escaping via .., fails at staging with E-CAP-002. It is this scope, and not the capsule's containment class, that bounds what a WASM artifact reaches — What bounds a WASM artifact.
  • Only the capsule operator can grant, exactly as for hooks: the block is read from your manifest's artifact entry, never from the artifact's own bundled murmur.yaml.
  • Drivers narrow identically. The artifact named by inference.driver.artifact dispatches through the same path as any WASM tool, so a capabilities: block on its entry applies to every driver call — including one made by a hook's run-inference.
  • state is the one sub-block that widens rather than narrows. It grants a second directory, ~/.murmur/state/<store>/, mounted in the artifact as state/ beside the workdir mounted as .. It opens exactly that one directory: never a workdir path, and never another capsule's store. Declaring it does not change the capsule's achieved containment class. A store name must be a single path segment — see State store name. See Durable state.
  • Only network, filesystem and state apply to a tool or driver. shell, spawn, env, limits, resources and containment parse but are inert here and print W-SEC-008.
  • Only state applies to a tool with a native (non-WASM) implementation. The tool reads the store's absolute path from MURMUR_STATE_DIR; who else can open it depends on the tier — see A native tool's own config and store. Any other sub-block prints W-SEC-008: a native tool runs under the capsule-wide capabilities.filesystem, capabilities.network and capabilities.shell.allow, enforced by the same sandbox as a shell command (see Native tools), and the per-artifact block does not narrow them.
Artifact config

config: on an artifact entry carries operator-authored settings to that one artifact. The runtime serializes the block to compact JSON and sets it as MURMUR_ARTIFACT_CONFIG in that artifact's environment:

artifacts:
  - name: murmur/tool-corpus
    version: 0.1.0
    runtime: tool
    capabilities:
      state: {}
    config:
      types:
        utterance:
          schema: { type: object, required: [text] }
      read_recent: { default: 20, max: 100 }

The tool reads MURMUR_ARTIFACT_CONFIG and gets:

{"types":{"utterance":{"schema":{"type":"object","required":["text"]}}},"read_recent":{"default":20,"max":100}}

Rules:

  • Scoped to the declaring artifact. Each entry's block reaches that artifact and no other. An entry with no config: key gets no MURMUR_ARTIFACT_CONFIG at all, which an artifact reads as an unset variable rather than as an empty object.
  • MURMUR_ARTIFACT_CONFIG is set by the runtime. Naming it in capabilities.env.allow or capabilities.shell.baseline_env does not pass the host's value through, and does not override the block.
  • Types survive the translation. Numbers stay numbers, sequences stay arrays, nested mappings stay objects, and declaration order is preserved, so the same block always produces the same bytes.
  • Config grants nothing. Declaring it opens no directory, reaches no host and leaves the capsule's containment class unchanged.
  • Shape is validated at launch; meaning is not. A block that breaks one of the rules under Artifact config shape fails with E-CAP-010. Which keys a given artifact requires is that artifact's own business, and a missing one surfaces as that artifact's error.
  • A native tool reads it the same way. A runtime: tool entry whose artifact ships a native (non-WASM) implementation gets MURMUR_ARTIFACT_CONFIG in its own process environment on every containment tier — see A native tool's own config and store.
  • Only the capsule operator can configure, exactly as for capabilities: the block is read from your manifest's artifact entry, never from the artifact's own bundled murmur.yaml.
  • Config is per artifact. A top-level config: key fails with E-MAN-003.

mur run --explain-scope lists the artifacts that declare a block, and never what any of them declared:

  artifact config:
    - murmur/tool-corpus

--json emits the same names as configured_artifacts, and trace.jsonl's session_start carries them verbatim as effective_grants.configured_artifacts. Both read artifact config: <none> and [] when nothing declares a block.

Secrets do not belong in a config: block

murmur.yaml is an audit record of what a capsule was allowed to do, and a config: block is plaintext inside it. Give an artifact its credential through a gateway: block with a ${NAME} reference, which leaves only the name in the manifest — see gateway.api_key resolution. Names that look credential-shaped are stripped from every environment the runtime builds; a literal in a manifest field is reported as W-SEC-004.

Credential gateway

A gateway: block on an artifact entry lets that artifact call a third-party API without holding its key. The runtime keeps the key; the artifact sends plain HTTP to the address the runtime hands it, and the runtime sends the request on to gateway.endpoint with the key attached.

artifacts:
  - name: local/tool-web-search
    version: "0.1.0"
    runtime: tool
    gateway:
      endpoint: https://api.tavily.com
      api_key: ${TAVILY_API_KEY}

The fields are in the artifacts: table. The block is read only from the capsule manifest's artifact entry, never from the artifact's own bundled murmur.yaml. How the key is presented comes from the artifact's bundled manifest, its upstream_auth: block. An artifact whose entry declares gateway: and whose manifest has no usable upstream_auth: refuses the launch with E-RUN-025; an artifact without gateway: needs no such block.

Which entries accept the block:

Entry gateway:
runtime: tool (WASM) Accepted
runtime: tool with a native implementation Refused at launch with E-CAP-017
runtime: hook Accepted
runtime: driver named by inference.driver.artifact, transport: http Required
runtime: driver named by an inference.alternates entry, transport: http Required, unless it is also the primary's driver
Any other runtime: driver entry, or any driver entry under transport: process E-MAN-003
runtime: skill E-MAN-003

What the artifact sees and what the runtime does:

Aspect Behaviour
Environment MURMUR_GATEWAY_ENDPOINT, set only for the artifact whose entry declares the block: http://127.0.0.1:9 plus the path of gateway.endpoint, without a trailing /. A driver serving an agent-loop call also receives it as MURMUR_INFERENCE_ENDPOINT.
Request A request to 127.0.0.1:9 is readdressed at gateway.endpoint's scheme, host and port, keeping its own path and query; https upstreams get TLS. Every header named like upstream_auth.header is removed and exactly one is attached, rendered from the key.
Network grant gateway.endpoint is the grant for these requests: they are not checked against capabilities.network.allow or the entry's own capabilities.network. Every other request from the artifact is checked as usual. An allow-list entry naming the upstream warns W-SEC-025.
Scope Only the declaring artifact's own calls use its gateway. A request another artifact sends to 127.0.0.1:9 carries no key and is checked against the allow-list.
Spend Only the gateways of the configured inference driver and of each inference.alternates driver are metered by inference.max_session_tokens and spend.machine_tokens_per_day. Every other gateway is unmetered and warns W-SEC-030 at launch and from mur doctor.
Rejection A 401 from the upstream of the driver choice serving an agent-loop turn fails the task with E-RUN-027. A 401 from any other gateway's upstream goes back to the artifact as the response, and is recorded in the trace.
Trace Each gateway is listed in session_start.gateways.
gateway.endpoint validation

gateway.endpoint alone decides where the key is sent, so it is validated when the manifest is parsed, before any capsule launches or any network call is made.

Value Result
https:// with a host, optional port and path (https://api.anthropic.com/v1) Accepted. The upstream must present a certificate for that host that chains to a public root; a server with a private-CA certificate fails with a TLS error and receives nothing
http:// with host localhost or a loopback IP literal (http://localhost:11434, http://127.0.0.1:8080, http://[::1]:8080) Accepted. localhost is resolved by the host running mur
http:// with any other host, including 0.0.0.0, private ranges and [::ffff:127.0.0.1] Rejected: the key would cross a network unencrypted
Userinfo before @ (https://user:pass@api.example.com, https://api.example.com@127.0.0.1) Rejected. The URL's host is the part after @, so the second example addresses 127.0.0.1; the message names that host
${ anywhere (https://${PROVIDER_HOST}/v1) Rejected: gateway.endpoint is not interpolated
A query or fragment (https://api.example.com/v1?x=1) Rejected: each request keeps its own query
A schemeless or malformed value (api.anthropic.com, "not a url") Rejected
Any scheme other than http/https (ftp://example.com) Rejected

Each rejection is E-MAN-003 naming gateway.endpoint, the artifact entry, the endpoint and the reason.

The endpoint confines the key to its scheme, host and port. Within that origin:

  • The artifact chooses the path. It sets the whole path and query of every request; the endpoint's path only seeds MURMUR_GATEWAY_ENDPOINT.
  • The response reaches the artifact. An upstream path that echoes request headers back hands the key to the artifact. Bind keys only to API hosts.
  • Redirects are returned, not followed. A 3xx goes back to the artifact as the response, and the key is never sent to its Location.
gateway.api_key resolution

api_key accepts two forms:

Form Example
Credential reference api_key: ${ANTHROPIC_API_KEY}
Literal string api_key: sk-ant-xxxx

Only ${UPPER_SNAKE_CASE} is a reference. Anything else is a literal value.

mur run resolves a reference before any session directory exists:

Order Where Rotation
1 credentials.ANTHROPIC_API_KEY in ~/.murmur/config.yaml, when non-empty Re-read while the capsule runs: a key replaced with mur config set -g is used on the artifact's next request
2 The environment variable ANTHROPIC_API_KEY Read once at launch, with W-SEC-027
— Neither mur run refuses with E-MAN-003, naming the artifact, gateway.api_key and both places

A literal is read once at launch, also with W-SEC-027. The re-read cadence, the single retry after a 401, and what removing an entry does are in credentials:. They apply to every gateway.

Keyless upstreams

An upstream that takes no key is declared with keyless: true, such as a local Ollama server behind the inference driver:

artifacts:
  - name: murmur/driver-openai
    version: "0.19.0"
    runtime: driver
    gateway:
      endpoint: http://localhost:11434/v1
      keyless: true
gateway: writes Result
A non-blank api_key Keyed: the runtime attaches the key
keyless: true, no api_key Keyless: every header named like upstream_auth.header is removed and none is attached
api_key (any value, blank included) and keyless: true E-MAN-003
No api_key, or one that is blank or null, without keyless: true E-CAP-018

A loopback endpoint needs keyless: true like any other: the address is never read as a declaration. A keyless artifact still needs its own upstream_auth: block (E-RUN-025), which names the header to remove. A keyless gateway is recorded as credential_source: "keyless" in session_start.gateways.

Local-source artifacts

Any artifact can be sourced from a local path instead of the registry by setting source: and declaring local_source: true. This is the authoring loop for skills, and for any other role that opts in: edit the file, relaunch the capsule, and the change is live — no build/publish round-trip.

artifacts:
  # source points directly at a skill.md file — skill defaults local_source to true
  - name: house-style
    source: ./skills/house-style/skill.md
    runtime: skill

  # source points at a skill folder: skill.md (any casing) and its supporting files
  - name: review-conventions
    source: ./skills/review-conventions/
    runtime: skill

  # a non-skill role must opt in explicitly
  - name: my-tool
    source: ./local/my-tool
    local_source: true
    runtime: tool

Rules:

  • A source: entry is named by its bare name, such as house-style. A scope says where an installed artifact came from, and a source: entry is not installed: name: ana/house-style with source: is refused with E-MAN-003 and the hint drop the scope: name it 'house-style'.
  • local_source gates source:, not the role directly. When local_source is absent its value is derived from the role: true for runtime: skill, false for everything else. Declaring local_source: true on a tool, driver or hook artifact opts it in; declaring local_source: false on a skill opts it back out.
  • version is optional when source is set. A local file has no registry version, so the runtime substitutes the literal local wherever a version is shown (the installed-path label and the MURMUR.md listing). A version set anyway is ignored, and a warning is printed to stderr.
  • Relative paths resolve against the directory containing murmur.yaml, not the current working directory. Absolute paths are used as-is.
  • For a skill, source may point at a skill.md file or at a skill folder.

    source Installed under tools/<name>/
    A file That file as skill.md, read regardless of its name
    A folder Its skill.md, found case-insensitively (SKILL.md, Skill.md, … all match) and installed as lowercase skill.md, and every supporting file at its relative path, chosen by the rules mur build packs a skill folder with. The folder's own murmur.yaml is not installed.
  • Everything downstream is identical to a registry skill: the files are installed under tools/<name>/ in the session's runtime directory and the skill is listed under ## Installed Skills in MURMUR.md.

  • A non-skill artifact declaring local_source: true passes manifest validation, but only a skill-shaped local payload — a skill.md file, or a directory containing one — resolves at launch.

Failure modes (all exit non-zero before the workdir is created):

Condition Code Message
source declared without local_source: true E-MAN-003 artifact '<name>' declares 'source:' but does not declare 'local_source: true' (runtime: <role>)
source path does not exist E-IO-001 skill source path not found: <path>
source directory has no skill.md E-IO-001 skill source directory '<path>' contains no skill.md
source directory holds a symlink, both SKILL.md and skill.md, or a root murmur-build.json E-BLD-002 skill source refused: <what and where>

Local-source artifacts are never written to murmur.lock and are skipped by mur install — they are resolved fresh from disk on every mur run.

inference.system_prompt_artifact

Names an artifact declared in artifacts:, by its entry's name as written, whose content is read once at launch and bound as the system prompt, instead of being called on demand. The named artifact must declare prompt_payload: true, or default to it as runtime: skill does; naming an artifact where prompt_payload is false fails with E-MAN-003. A skill bound this way is excluded from the callable tool inventory, because it is already in the system prompt. See Shape agent behavior with a system prompt.

Overriding the prompt with mur run --system-prompt releases the named artifact back into the callable inventory for that run, and MURMUR.md stops describing it as bound: it is no longer in the system prompt, so there is nothing to double-inject.

capabilities

Field Type Required Notes
capabilities.network.allow list no Host/URL entries the capsule may reach — see Network allow entries for the accepted forms. Governs IP destinations only, TCP and UDP alike. It has no effect on unix-domain sockets, which capabilities.network.unix_sockets governs separately, and none on AF_NETLINK/AF_PACKET, which are always refused. An empty or absent list means no IP destination is reachable. A non-empty list also grants the capabilities.shell.allow binaries read access to the host's TLS trust store.
capabilities.network.unix_sockets bool no Default: false. When false, the capsule's shell subprocess tree cannot create an AF_UNIX socket at all: socket(AF_UNIX, ...) fails with EACCES. Set true only if a shell tool genuinely needs a local daemon socket — the grant is capsule-wide, not a per-socket-path allowlist, so it re-exposes every unix socket the process can reach, /var/run/docker.sock (host root) included. No effect on non-Linux hosts, which have no kernel enforcement — see W-SEC-001.
capabilities.peer_fetch.allow list see notes Peers this capsule may redeem a peer-file handle against — see capabilities.peer_fetch. Required and non-empty when the peer_fetch: block is present. Absent block means no fetching is possible and the fetch-peer-file tool does not exist.
capabilities.filesystem.scope string no Relative scope under the workdir; see Filesystem scope. Omitted, the capsule sees the whole workdir.
capabilities.filesystem.read_only list no Workdir-relative subtrees the capsule may read but must not write, in the same vocabulary as scope. The runtime refuses, before dispatch, any tool call or shell command it can identify as writing one, records it as protected_path_denied and tells the model why. Omitted or [], the whole workdir is writable. See Read-only paths.
capabilities.filesystem.workdir_exec bool no Default: false. When false, nothing the capsule writes into the work folder, its temporary folder or its home folder can be executed — under any name, including one that appears in capabilities.shell.allow. Set true only for compile-and-run workflows (the capsule builds a binary in its workdir and then runs it); doing so makes shell.allow unenforceable for anything inside the workdir, caps the capsule's achieved containment class at advisory on every host, and fires W-SEC-011 at staging. See Executable workdirs.
capabilities.shell.allow list no Shell binaries the agent may invoke (e.g. bash); see Shell allow.
capabilities.shell.strip_env list no Glob patterns for env vars to strip from the subprocess environment (e.g. AWS_*).
capabilities.shell.baseline_env list no Glob patterns for env vars to keep after stripping (e.g. PATH).
capabilities.shell.interpreter_runtime list no Widens an allowlisted binary's filesystem scope to specific host directories its import machinery needs outside the workdir (a path-based interpreter's stdlib). A recognised CPython or Perl interpreter needs none for its own import path — see Derived interpreter runtimes. Fires W-SEC-009 at staging. The directories are host-specific, so declaring them ties the capsule to one image's layout; where the command has a module form, allowlisting the interpreter and invoking the module through it stays correct across images — see E-CAP-006.
capabilities.shell.interpreter_runtime[].binary string yes A binary that must already appear in capabilities.shell.allow — this widens filesystem access alongside an existing exec grant, it never grants exec.
capabilities.shell.interpreter_runtime[].dirs list yes The host directories to grant; must name at least one.
capabilities.shell.interpreter_runtime[].dirs[].path string yes An absolute host path (must start with /) outside the workdir.
capabilities.shell.interpreter_runtime[].dirs[].list_dir bool yes true grants execute, read and directory listing; false grants execute and read only, so files are openable by exact name but the directory cannot be listed. Never inferred — must be written explicitly.
capabilities.shell.staged_runtime list no Names a pinned host runtime tree to bind-mount read-only into a sealed capsule's composed root, so the interpreter is present inside the root rather than reachable outside it. Requires an effective sealed containment floor (E-CAP-004), and is mutually exclusive per binary with interpreter_runtime. See Staged runtime.
capabilities.shell.staged_runtime[].binary string yes A binary that must already appear in capabilities.shell.allow, and must not also have a capabilities.shell.interpreter_runtime grant. This says where an existing exec grant's runtime comes from; it never grants exec.
capabilities.shell.staged_runtime[].source_path string yes Absolute host path (must start with /) of an already-pinned runtime tree — a vendored toolchain directory, a baked-in conda env. Not resolved, discovered or version-sniffed by the runtime, and not required to exist on the machine that merely parses the manifest.
capabilities.shell.staged_runtime[].pin string yes Non-empty, opaque identifier of which build the tree is. Never inferred: it exists so a human can compare the declared pin across two hosts and confirm the same runtime shipped to both.
capabilities.env.allow list no Host env var names a WASM component (capsule, tool or driver) may observe. Omitted or [] grants nothing beyond the runtime's own MURMUR_* injections.
capabilities.limits.memory_bytes integer no Cap on how much memory a component may allocate, in bytes. Default: 536870912 (512 MiB). Must be > 0.
capabilities.limits.table_elements integer no Cap on a component's table growth, in elements. Default: 100000. Must be > 0.
capabilities.limits.instances integer no Cap on the number of component instances one call may create. Default: 1000. Must be > 0.
capabilities.limits.deadline_seconds integer no Wall-clock budget for a single component call, in seconds. Default: 600 for a capsule, tool or driver call; 30 for a hook lifecycle call. An explicit value overrides both defaults, hooks included. A hook's own entry can set its deadline ahead of this value — see Hook capabilities. Must be > 0.
capabilities.resources.max_processes integer no RLIMIT_NPROC headroom for a native subprocess with no cgroup scope: the account's count at that spawn plus this value, in threads on Linux and processes on macOS. No effect on Linux when the subprocess runs in a cgroup scope, where cgroup_pids_max is the bound. Default: 128. Must be > 0. See max_processes by platform.
capabilities.resources.max_open_files integer no RLIMIT_NOFILE hard ceiling on each native subprocess. Default: 1024. Must be > 0.
capabilities.resources.max_file_size_bytes integer no RLIMIT_FSIZE hard ceiling — largest single file a subprocess may write, in bytes. Default: 4294967296 (4 GiB). Must be > 0.
capabilities.resources.cpu_seconds integer no The CPU time, in CPU-seconds, after which each native subprocess gets SIGXCPU, and SIGKILL one second later. Default: not set — no CPU-time limit; a subprocess keeps the RLIMIT_CPU mur was started with. Must be > 0.
capabilities.resources.memory_bytes integer no RLIMIT_AS (Linux) hard ceiling on each native subprocess's address space, in bytes. Default: 2147483648 (2 GiB). Must be > 0. macOS maps this to RLIMIT_DATA, which its kernel does not enforce — see the platform note.
capabilities.resources.cgroup_memory_bytes integer no cgroup v2 memory.max — aggregate memory across the whole subprocess tree, in bytes. Swap is bounded too: the scope's memory.swap.max is 0, so a tree over this ceiling is OOM-killed rather than pushed out to swap. Default: 4294967296 (4 GiB). Must be > 0. Linux only. On a kernel that exposes no memory.swap.max, swap is not bounded and mur doctor says so.
capabilities.resources.cgroup_pids_max integer no cgroup v2 pids.max — aggregate task count across the whole subprocess tree. Default: 256. Must be > 0. Linux only.
capabilities.resources.cgroup_cpu_percent integer no cgroup v2 cpu.max quota as a percentage of one core (200 = two cores' worth). Default: not set — no CPU cap; the scope's cpu.max stays at the kernel's max. Must be > 0. Linux only.
capabilities.resources.cgroup_io_bytes_per_sec integer no cgroup v2 io.max read+write throughput on the workdir's backing device, in bytes/sec. Default: 104857600 (100 MiB/s). Must be > 0. Linux only, best-effort — a workdir whose backing device cannot be resolved (overlayfs, tmpfs, device-mapper) reports io_max.status: unavailable with W-SEC-021 and keeps its other limits on the scope.
capabilities.resources.workdir_max_bytes integer no Ceiling on the work folder, the session's temporary folder and its home folder together, in bytes, checked every 10 seconds; crossing it ends the session with E-RUN-013. See Size ceiling. Default: 10737418240 (10 GiB). Must be > 0. Every platform.
capabilities.containment advisory \| scoped \| sealed no Minimum containment class this capsule requires, in ascending strength. Omitted, the capsule states no requirement — see Containment class. Capsule-wide only; declaring it on a per-artifact entry has no effect and warns at staging — What bounds a WASM artifact names the grant that scopes one artifact.
capabilities.conversation.read bool no Grant of the murmur:conversation/read import, applied per hook only. Declaring it in this capsule-wide block reaches nothing — no artifact can read the conversation record — and prints W-SEC-016 at staging. Put it on the hook entry that needs it: Hook capabilities.
capabilities.state.store string no Durable store name, applied per artifact only. Declaring it in this capsule-wide block reaches nothing — no store is created and no state directory is mounted — and prints W-SEC-014 at staging. Put it on the tool, driver or hook entry that needs it: Tool and driver capabilities, Hook capabilities. See Durable state.
capabilities.plan.submit bool see notes Default: absent, which is deny. true puts the runtime-provided tool submit-plan in the capsule's inventory; a capsule that declares nothing is not offered it. Required when the plan: block is present — a block that omits it is refused at parse. It grants no reach of its own: every step of a plan runs through this capsule's own tools, capabilities.shell.allow and capabilities.spawn.allow. See Plans. mur run --explain-scope reports it as plan submit.
capabilities.install.skill list see notes Default: absent, which is deny. Skills that a pull — manage.pull, or the pull-artifact tool — may install into a running session, as install entries: an exact full name, <scope>/*, <scope>/<prefix>*, or *. A skill is an artifact whose payload is a skill.md, plus any supporting files packed beside it, which install read-only and not executable under tools/<name>/. Capsule-wide only — declaring install on an artifacts[] entry is refused at parse. The same entries let manage.remove and remove-artifact uninstall a skill the capsule pulled. With both install lists empty or absent, every pull and every removal is refused. See capabilities.install. mur run --explain-scope reports it as install skill, and trace.jsonl's session_start carries it as effective_grants.install_skill.
capabilities.install.tool list see notes Default: absent, which is deny. Tools, WASM or native, that a pull may install, as install entries. A pulled tool joins the dispatch allowlist. The same entries let a removal uninstall a tool the capsule pulled. Drivers and hooks are never pullable or removable, under any entry. At least one of skill and tool must be non-empty when the install: block is present — an empty block is refused at parse. Capsule-wide only. mur run --explain-scope reports it as install tool, and trace.jsonl's session_start carries it as effective_grants.install_tool.
capabilities.install.from [stores] \| [stores, sources] no Where a pull finds what it installs. Default: [stores], the project store then the global store. [stores, sources] (in either order) also fetches a version no store holds from the GitHub source of the artifact's scope; the runtime fetches it, records the owner id in murmur.lock and writes it to no store. An empty list, a list without stores, a duplicate and any other value are refused with E-MAN-003. mur run --explain-scope and mur doctor report it as install from, and trace.jsonl's session_start carries it as effective_grants.install_from. A spawned child declaring sources under a parent without it is refused.
capabilities.spawn.allow list no Full names, <scope>/<name>, of the capsules this capsule may spawn as sub-capsules. A bare name is refused with E-MAN-003, and so are two entries with the same bare part, because delegate-task offers each capsule to the model by its bare part. mur-roost matches each spawn request's capsule name against this list and refuses a name that is absent from it — see Per-session allow lists for the worked example. capabilities.shell.allow governs the executables the capsule runs itself. A non-empty list means the capsule has a subprocess tree, so it is bound by capabilities.resources and needs a network namespace on Linux (E-CAP-005). It also means the session registers with mur-roost at launch, so the daemon holds the ceiling it referees against: with no daemon reachable at MURMUR_ROOST_URL the launch is refused with E-RUN-019. A non-empty list is also what puts the runtime-provided tool delegate-task in the capsule's inventory, with the bare part of each name as the tool's capsule argument — see The delegation tool. A capsule that declares none is offered no such tool. How deep a chain of delegations may go and how many children one session may hold at once are the daemon's, not this field's — see Delegation bounds. mur run --explain-scope reports it as spawn allow, and trace.jsonl's session_start carries it as effective_grants.spawn_allow.
capabilities.spawn.workdir_snapshot list no Default: []. Full names from capabilities.spawn.allow whose every delegation — from delegate-task or a plan capsule step — starts the child in a copy of this capsule's work folder as it stands when the delegation is made, instead of an empty folder. The child reads, runs and writes the copy; nothing it writes reaches this capsule's work folder, and this capsule receives only its answer. Each entry is refused with E-MAN-003 when it is a bare name, is listed twice, or is absent from capabilities.spawn.allow. The copy counts against the child's own capabilities.resources.workdir_max_bytes: a work folder larger than that fails the delegation before the child starts. See Workdir snapshot. mur run --explain-scope reports it as spawn workdir snapshot, and trace.jsonl's session_start carries it as effective_grants.spawn_workdir_snapshot.

A capabilities.network.allow host that fails DNS resolution at launch is skipped rather than treated as an error: the run proceeds with that host contributing no addresses to the launch-time IP allowlist a shell subprocess falls back to when it reaches a destination by address rather than by name. This only ever shrinks what a shell binary can reach. When the failure was a resolver that did not answer within five seconds — as opposed to a name that does not exist — the runtime says so on stderr and carries on:

[capsule-runtime] warning: the network allowlist host 'api.example.com' could not be resolved at
launch: the resolver did not answer within 5s (a subprocess reaching it by literal address is
denied for this run)

Each unresolvable host costs up to that five seconds at launch. Names are still resolved for the capsule while it runs, so a host named here stays reachable by name whatever happened at launch; see Capsule name resolution. Malformed host syntax, as opposed to a resolution failure, is still rejected outright with E-CAP-001.

A WASM component never inherits the host process's environment. capabilities.env.allow is the only way to expose a host variable. A declared-but-unset host variable is omitted rather than reported. A declared name is judged by name alone:

The name Result
Matches a credential backstop pattern (see Lock down a capsule's capabilities for the list) or a capabilities.shell.strip_env pattern The capsule is refused with E-CAP-016
Credential-shaped: contains api_key, token, secret or password, or has a segment such as KEY, PASS, CREDENTIALS, DSN or AUTH Reaches every WASM component, reported once by W-SEC-024, which lists the full rule
Anything else Reaches every WASM component

The field governs WASM components and capsules delegated to through capabilities.spawn.allow — never the capsule process itself, which is started by the operator's own shell and inherits that shell wholesale. So a root capsule needs no env.allow entry for a variable only its own process reads; the field is about what the capsule hands onward.

The runtime omits an unset name at launch rather than refusing it, so a run reaches the delegation before an absent variable shows itself. mur doctor reports the whole spawn.allow closure's requirement ahead of a run, marking every declared name set or unset — by name only, never by value.

Process environment

A process the runtime starts on the host — a transport: process harness, a shell command or a native tool — begins with an empty environment and receives the variables below, in order. A later row wins over an earlier one for the same name. A WASM component receives only what capabilities.env.allow declares, as described above.

A transport: process harness:

Order Source Variables
1 capabilities.env.allow The host's value of each declared name the host sets, after the credential backstop
2 The driver's launch plan Each name its env-set lists
3 The host account USER and LOGNAME, each with the host's value when the host sets it and absent when it does not. No env.allow entry or launch plan names a different account. harness_start's account_env_unset lists the names the host did not set
4 The session MURMUR_RUNTIME_DIR, the session's runtime directory, and MURMUR_RECORDS_DIR, its records directory

The harness's version check runs with rows 1 and 3 only.

A shell tool call, a plan's shell step, or a native tool:

Order Source Variables
1 Baseline The host's value of each of PATH, HOME, USER, LOGNAME, LANG, LC_ALL, CARGO_HOME, RUSTUP_HOME and TERM the host sets
2 The session's identity A shell tool call in a session with inference: only: MURMUR_CAPSULE_NAME, MURMUR_CAPSULE_VERSION, MURMUR_SESSION_ID, MURMUR_CAPSULE_URL and the MURMUR_INFERENCE_* variables
3 capabilities.shell.baseline_env The host's value of each listed name the host sets
4 Credential backstop and capabilities.shell.strip_env Removes every name that matches a credential pattern or a strip_env pattern, baseline names such as USER and LOGNAME included
5 The session's home folder HOME and USERPROFILE, both the session's home folder, home/ in its records directory, never the host's home
6 The session MURMUR_RUNTIME_DIR and MURMUR_RECORDS_DIR, as for the harness
7 The session's temporary folder TMPDIR, TMP and TEMP: /tmp where the session maps /tmp onto its temporary folder, and the folder's absolute path where it does not. No earlier row sets them, capabilities.shell.baseline_env included

A delegated child mur starts from an empty environment and receives the parent's USER and LOGNAME beside its PATH and HOME, each only when the parent holds it. The child's environment lists every variable it receives.

capabilities.plan

Grants the capsule's agent one runtime-provided tool, submit-plan, which runs a plan of steps and returns every step's result in one reply. The plan JSON format is Plans.

capabilities:
  plan:
    submit: true

submit is never inferred: a plan: block that omits it is a parse error naming capabilities.plan.submit, and submit: false is the same grant as an absent block.

The grant is capsule-wide. Declaring it on a per-artifact entry reaches nothing — a plan is submitted by the agent, not by an artifact.

A plan opens no path this capsule does not already have. Each step is dispatched by the session that submitted it: a tool step reaches the tools in the capsule's own inventory, a shell step the binaries in capabilities.shell.allow, and a capsule step the names in capabilities.spawn.allow. What the grant changes is how much of that one model turn can reach without another turn in between.

capabilities.install

Grants a capsule permission to install skills and tools while the session runs, and to uninstall them again:

Capsule Pulls with Removes with
A script capsule murmur:artifact-manager/manage.pull manage.remove
An agent capsule, transport: http The pull-artifact tool The remove-artifact tool

Under transport: process the block is refused at parse with E-MAN-003: the harness owns its tool list, so a pulled artifact could never reach it. Both paths make the same checks.

capabilities:
  install:
    skill: [ana/code-review, "ana/style-*"]
    tool: [local/jq]
    from: [stores, sources]
Key Admits
skill Artifacts whose payload is a skill.md, declared runtime: skill
tool Artifacts whose payload is a WASM component or a native binary, declared runtime: tool

An artifact matches only the list for its own kind: skill: ["*"] admits no tool. A driver or hook is refused under every entry, and so is an artifact whose declared role does not match its payload.

A pull resolves from the project artifact store, falling back to the global store under ~/.murmur: what mur install or mur publish has already placed on this host. With from: [stores, sources], a version neither store holds is fetched from the scope's source with the checks mur install makes, and pinned in murmur.lock with the scope's owner id; no store gains it. A pull names a full artifact name and a version that is a single path segment; a bare name, a github: reference, a path, or a version containing / or .. is refused. A pull with no version takes the newest version, by semver precedence, that fits the WIT level this mur serves and has a payload for this platform; on a tie the store wins. A version that does not fit is refused with E-WIT-LEVEL-001, naming the newest that does.

A pulled artifact runs on this capsule's own grants. A pulled tool joins the dispatch allowlist, so it can be called; it gains no per-artifact capabilities and no gateway, and the capabilities: block in its own bundled murmur.yaml is ignored. It is marked untrusted in the tool list and its results are fenced — see Artifact origin.

manage.remove uninstalls only an artifact the capsule pulled:

Artifact manage.remove, remove-artifact
Pulled, and pinned in murmur.lock as origin: runtime Removed
Declared in this murmur.yaml's artifacts: Refused, whatever its pin origin
Pinned in murmur.lock as origin: operator Refused
A hook or a driver Refused

A capsule spawned through capabilities.spawn.allow holds only the install block its own manifest declares. mur-roost refuses a child whose entries its parent's do not cover, or that declares from: [stores, sources] under a parent that does not — see Spawn envelope.

capabilities.peer_fetch

Names the peers this capsule may redeem a peer-file handle against. Declaring it gives the agent one runtime-provided tool, fetch-peer-file.

It sits beside capabilities.network rather than inside it because fetching a peer's bytes lands a file in this capsule's own workdir: that is an ingestion path, and therefore a prompt-injection surface, which deserves its own operator control.

allow uses the same syntax and the same matcher as capabilities.network.allow, and is a separate list:

  • Declaring a destination here does not widen capabilities.network.allow.
  • A destination in capabilities.network.allow is not redeemable unless it also appears here.

An empty allow: [] is a parse error rather than a silent deny — E-MAN-003, naming capabilities.peer_fetch.allow. The check runs before any connection is opened, so a refused peer is never contacted.

network

Field Type Required Notes
network.internal_port integer no The port the capsule's A2A HTTP server binds. When set, the runtime binds exactly that port and fails with E-RUN-010 if it is already in use. When omitted, the OS assigns a free port. Either way the bound port is the one mur run prints and the one MURMUR_CAPSULE_URL carries.
network.authentication object no Makes the A2A door refuse every caller that presents no token this session minted. Default: absent, a public door that answers every caller that reaches the port. See network.authentication.

The top-level network: block configures the capsule's A2A door. It is unrelated to capabilities.network, which is where the capsule itself may connect.

network.authentication
Field Type Required Notes
network.authentication.scheme bearer yes How a caller authenticates: Authorization: Bearer <token>. bearer is the only value; any other is E-MAN-003.
network.authentication.credentials map of name → object no Default: none. Each entry mints one more token beside the operator token, reaching only its scopes.
network.authentication.credentials.<name> object — <name> matches ^[a-z][a-z0-9_-]{0,31}$. operator is reserved.
network.authentication.credentials.<name>.scopes list yes Non-empty. Each entry once, from the scopes below.

The runtime mints one operator token holding every scope, and one token per declared credential. mur run prints them. A credential exists to hand out less than the operator token.

Scope Reaches
SendMessage Starting a task with SendMessage
SendStreamingMessage Starting or continuing a task with SendStreamingMessage; the connection carries that task's events while it is open
murmur.WatchSession Watching every task on the session
GetTask Reading a task the credential started
ListTasks Listing the tasks the credential started, a page at a time
CancelTask Cancelling a task the credential started
SubscribeToTask Streaming the rest of a live task the credential started
murmur.StopSession Cancelling every task on the session and reporting the residue
resources/files The operator resource plane under /resources/files

A scope names a method. A declared credential's token reaches only the tasks a request carrying it started; the operator token reaches every task. murmur.WatchSession and murmur.StopSession reach every task whoever holds them — see Agent Card: Who sees a task. GetExtendedAgentCard takes no scope: every valid token reaches it. What the door answers each caller, and the public and extended cards, are in Agent Card: Security.

A scope that is a retired name of a door method is refused with E-MAN-003, and the hint names the scope to write in its place:

Retired scope Write instead
message/send SendMessage
message/stream SendStreamingMessage
tasks/get GetTask
tasks/cancel CancelTask
agent/getAuthenticatedExtendedCard Nothing: remove it. Every valid token may call GetExtendedAgentCard
stream/watch murmur.WatchSession
session/stop murmur.StopSession

See Agent Card: Migrating from A2A 0.3.

A capsule declaring both network.authentication and a non-empty capabilities.spawn.allow is refused with E-MAN-003: a delegated child posts its outcome to its parent's door, and a child cannot be issued a token for that door.

inference

The inference block is optional; a capsule with no inference block runs its tools without a model. These fields are read under both transports:

Field Type Required Notes
inference.transport http \| process no Default: http. http routes every call through a WASM driver artifact; process runs a harness CLI as a subprocess. See Inference configuration.
inference.max_turns integer no Maximum LLM inference calls per task. Default: no cap. Must be > 0. A hook's counted run-inference call is one of them: it is in task_end.turns, and an attempt reopened after it is handed only the turns the task has left. See Turn cap.
inference.system_prompt string no Text injected verbatim as the system parameter on every inference call. At most one of system_prompt, system_prompt_file and system_prompt_artifact may be set.
inference.system_prompt_file string no Path to a file whose content is injected as the system prompt, relative to the manifest directory.
inference.system_prompt_artifact string no The name, as written, of an artifact declared in artifacts: whose payload is bound as the system prompt — see inference.system_prompt_artifact.
inference.instruction_files list of strings no Default: none. Files in the work folder appended to the system prompt at launch, each as one labelled block after the manifest's own prompt. Each entry must be a relative path with no .. component; an empty, absolute or escaping entry fails with E-MAN-003. See inference.instruction_files.

These fields are read under transport: http. Setting any of them except inference.model under transport: process is a manifest error:

Field Type Required Notes
inference.model string yes Model identifier passed to the driver.
inference.max_tokens integer no Maximum output tokens the model may generate per turn, for every driver choice. Default: the max_output_tokens the driver's models: table declares for the choice's model; a choice with neither refuses the launch with E-RUN-052. Must be > 0; not clamped at the top end. A value at or above a choice's context window leaves it no input budget and refuses the launch with E-RUN-055. Distinct from context.max_tokens — see Output cap.
inference.tool_refresh compaction \| immediate no When an artifact installed mid-session, other than by the model's own pull-artifact, reaches the tool list the model is offered. Default: compaction. See Tool refresh.
inference.alternates list no Default: empty. Other driver choices the agent loop may be switched to while the capsule runs. Requires inference.driver in control.settings or control.agent_settings. See Driver alternates.
inference.retry map no How a driver call that failed transiently is made again: max_retries (0–20, default 6), initial_delay_ms (1–60000, default 1000) and max_delay_ms (1–3600000 and at least initial_delay_ms, default 60000), each an optional integer. Refused under transport: process, where the harness retries its own provider calls. See Retrying transient failures.

This field is read under both transports, against a different measurement on each:

Field Type Required Notes
inference.max_session_tokens integer no Most tokens this session may spend on inference in total. No default: absent sets no session ceiling. Must be > 0. See Session spend ceiling.

The driver's upstream and key are gateway.endpoint and gateway.api_key on its artifacts: entry. A manifest that writes either under inference:, under any transport, fails with E-MAN-003:

Field Refused with
inference.endpoint was removed; set gateway.endpoint on the artifacts: entry '<driver>' instead
inference.api_key was removed; set gateway.api_key on the artifacts: entry '<driver>' instead

With no driver named, the message reads on the artifacts: entry inference.driver.artifact names.

inference.driver is read under both transports, with a meaning per transport. Under either, the artifact it names must be declared in artifacts: with runtime: driver, and a value of config that is not a mapping fails the manifest parse with E-MAN-003.

inference.driver.artifact, inference.alternates[].driver.artifact and inference.system_prompt_artifact name an artifacts: entry by its name exactly as written: murmur/driver-anthropic for a resolved entry, the bare name for a source: entry. A name no entry declares is refused with E-MAN-003; when it is the bare part of exactly one declared entry, the message names that entry: artifact 'driver-anthropic' is not declared in artifacts: (the declared 'murmur/driver-anthropic' has that name; refer to it as 'murmur/driver-anthropic').

Field transport: http transport: process
inference.driver.artifact Required. The inference driver. Its entry must carry gateway.endpoint. Required. The process driver. Its entry may carry none of gateway:, capabilities: or config:.
inference.driver.config Optional object. Settings any driver of this role would act on, serialized to compact JSON and set as MURMUR_INFERENCE_DRIVER_CONFIG for the driver, every WASM tool and every shell tool in the session. See Choosing a config block. Optional object, handed to the process driver as JSON.
Driver alternates

A driver choice is a named pair of one driver artifact and one model. The choice named primary is inference.driver.artifact with inference.model; each inference.alternates entry is another. The agent loop runs on primary at launch, and a controller (control.settings: [inference.driver]) or the agent (control.agent_settings: [inference.driver]) switches it to another declared choice by name. Nothing is pulled or installed: every alternate's driver is an artifacts: entry.

artifacts:
  - name: murmur/driver-anthropic
    version: "0.19.0"
    runtime: driver
    gateway: { endpoint: https://api.anthropic.com, api_key: ${ANTHROPIC_API_KEY} }
  - name: murmur/driver-openai
    version: "0.19.0"
    runtime: driver
    gateway: { endpoint: https://api.openai.com/v1, api_key: ${OPENAI_API_KEY} }
inference:
  model: claude-sonnet-4-5
  driver:
    artifact: murmur/driver-anthropic
  alternates:
    - name: haiku
      model: claude-haiku-4-5
      driver:
        artifact: murmur/driver-anthropic
    - name: gpt
      model: gpt-5
      driver:
        artifact: murmur/driver-openai
control:
  settings: [inference.driver]
Field Type Required Notes
inference.alternates[].name string yes Matches ^[a-z][a-z0-9-]{0,62}$. Unique among the alternates. primary is reserved.
inference.alternates[].model string yes Model identifier the driver sends. Must not be empty.
inference.alternates[].driver.artifact string yes An artifacts: entry with runtime: driver. The entry must carry its own gateway:, unless it is the primary's driver.
inference.alternates[].driver.config — — Refused. inference.driver.config applies to every declared driver; per-driver settings go in that driver's artifacts[].config.

Every refusal is E-MAN-003, naming the field:

Manifest Field named
inference.alternates under transport: process inference.alternates
Alternates with inference.driver in neither control.settings nor control.agent_settings inference.alternates
A name that is primary, repeated, or outside the pattern inference.alternates[i].name
An empty or missing model inference.alternates[i].model
A driver that is not a declared runtime: driver entry, or whose entry has no gateway: inference.alternates[i].driver.artifact
A driver.config on an alternate inference.alternates[i].driver.config
The same driver and model as the primary or another alternate inference.alternates[i]

What follows a switch:

Inference call Served by
Agent-loop turns The choice in use: its driver, its model, its gateway and credential
Compaction, a hook's run-inference, seed summarization The primary. inference.compaction.model names a model of the primary's provider
MURMUR_INFERENCE_* as tools and hooks see it The primary, for the whole session

A driver-choice call sets MURMUR_INFERENCE_ENDPOINT, MURMUR_INFERENCE_MODEL and MURMUR_INFERENCE_DRIVER in that driver's environment to the choice's own values. Each choice has its own output cap and context window, resolved at launch, and an agent-loop call uses the cap and window of the choice it is served by. A controller's change to inference.max_tokens applies to every later call whatever the choice. Every choice's gateway is metered like the primary's. An alternate whose credential is found nowhere at launch does not refuse the launch: it is staged unavailable and warns W-RUN-003. Nothing a controller or the agent selects survives a restart. What the conversation carries across a switch is in Switching drivers.

These fields are read under transport: process:

Field Type Required Notes
inference.command string no The executable to run instead of the one the process driver's describe() names. A value containing / is used as a path; a bare name is looked up on PATH. Invalid for transport: http.
inference.model string no Model identifier handed to the driver. Omitted, the harness uses its own configured default.

The inference.compaction block takes effect only under transport: http; the process transport has no compaction step. Under transport: process, threshold, model, system_prompt, system_prompt_file and dump_summaries are accepted and inert, and max_tokens and driver_config are refused with "is not valid with transport: process".

Field Type Required Notes
inference.compaction.threshold float (0.0–1.0] no Fraction of the call's input budget at which compaction fires. Default: 0.98. Checked after each response against the provider's count of the call when the driver reports usage, and the runtime's estimate otherwise — see Context compaction.
inference.compaction.model string no Model override for compaction calls. Defaults to the primary inference model.
inference.compaction.system_prompt string no System prompt override for compaction calls, passed verbatim to the compaction hook. No trimming, length limit or format check. Omitted, the compaction hook picks its own default prompt. Mutually exclusive with inference.compaction.system_prompt_file.
inference.compaction.system_prompt_file string no Path to a file whose content is passed verbatim as the compaction system prompt. Relative to the manifest directory; read when the session launches. Mutually exclusive with inference.compaction.system_prompt.
inference.compaction.dump_summaries bool no Default: false. When true, every committed compaction appends one JSON line to out/compaction-summaries.jsonl in the session's records directory, recording the verbatim summary text plus token counts.
inference.compaction.max_tokens integer no Output cap of every run-inference call a hook makes while it serves on-compaction — the threshold trigger, mur run --resume … --resume-mode compact and a seed's overflow summary — and the amount a spend ceiling reserves for each. Default: the cap any other hook call sends, under Output cap sent. Must be > 0. A value above the max_output_tokens the primary driver's models: table declares for the compaction model (inference.compaction.model, else inference.model) refuses the launch with E-RUN-054; a model the table does not list takes any value. A value at or above the compaction model's window (context.max_tokens, else that table entry's context_window) leaves compaction calls no input budget and refuses the launch with E-RUN-055. Invalid for transport: process.
inference.compaction.driver_config mapping no Laid over inference.driver.config for the same calls inference.compaction.max_tokens caps, and no others. Shallow: each top-level key replaces the same-named key of inference.driver.config or is added beside it, and every other key is kept; a nested mapping replaces the whole value. With no inference.driver.config, the mapping alone is sent. The driver validates the merged object as it validates inference.driver.config, and names it inference.driver.config in its refusal. Default: none, so compaction calls get inference.driver.config unchanged. A non-mapping value is refused. Invalid for transport: process.

context

Field Type Required Notes
context.max_tokens integer no The context window every driver choice's input budget is cut from: compaction and the seed budget measure against that budget. Default: the context_window the driver's models: table declares for the choice's model; with neither, the choice has no window, and compaction does not run on it. Must be > 0. A value at or below a choice's output cap leaves it no input budget and refuses the launch with E-RUN-055. Read only under transport: http, like the inference.compaction block it drives. Distinct from inference.max_tokens, the per-turn output cap.
context.seed_budget float (0.0–1.0) no Default: 0.10. Fraction of the primary choice's input budget at launch an on-task-start hook's seed-context may occupy. The product, rounded down, is sent to the hook as task-start-event.budget-tokens; task-start-event.context-window carries the window. Requires a window: with none there is no ceiling, and a returned seed is refused with reason: "no_budget". Inert under transport: process, where a seed is refused with reason: "unsupported_transport".
context.seed_overflow_margin float (0.0–1.0) no Default: 0.10. Slack above context.seed_budget, as a fraction of it, within which an over-budget seed has its oldest messages dropped rather than being handed to the compaction hook. Requires a window and is inert under transport: process, exactly like context.seed_budget.
context.record on \| off no Default: on. Whether the runtime keeps a durable conversation record for this capsule. off turns the mechanism off: nothing is created under ~/.murmur/conversations/, and a hook granted capabilities.conversation.read reads an empty page. Under transport: process no record is written either way, and off additionally turns off the harness session map.
context.record_store string no Default: the capsule name. Directory under ~/.murmur/conversations/ this capsule's records live in. One path segment: no /, no . or .., not absolute, not starting with a dot — anything else refuses the launch with E-CAP-011. Accepted and inert alongside record: off.
context.retain block no What bounds this capsule's conversation records. Omitted, nothing is ever deleted. See Retention.
context.retain.max_messages integer ≥ 1 no Messages to keep. At each launch, the record that launch opens — the context named by mur run --context — is truncated to its newest N; the older ones are dropped and the header line records the drop. A launch with no --context mints a context per task and opens no record to truncate; bound those with context.retain.max_age.
context.retain.max_age duration no Age beyond which a record this capsule owns is removed whole, measured from the last write to its conversation.jsonl.
context.images block no Limits on the images a tool returns and an A2A message carries. Omitted, every key below takes its default. See Returning an image.
context.images.keep integer ≥ 1 no Default: 5. How many of the newest images in the conversation the driver receives as image bytes. Each older one reaches it as one line naming its media type, size and sha256. Does nothing under transport: process, where the harness holds the conversation.
context.images.max_bytes byte size no Default: 5Mi. The largest image accepted, as a bare number of bytes or with a Ki, Mi or Gi suffix; 10MB is refused. Must be > 0. A larger tool attachment is refused too_large; a larger A2A image refuses the message with -32602.
context.images.max_per_turn integer ≥ 1 no Default: 5. How many images one model turn's tool calls may return between them, and one A2A message may carry. Past it, a tool attachment is refused turn_limit and an A2A message is refused with -32602. Under transport: process it counts the images of one tool call.

observability

Field Type Required Notes
observability.otel_endpoint string no OTLP/HTTP endpoint for OTel span export (e.g. http://localhost:4318). When set, the runtime exports one span per session event as the event happens, and injects MURMUR_OTEL_ENDPOINT into every hook component's environment — see OTel span emission. When absent, no outbound telemetry is sent and trace.jsonl is the only output. An empty string counts as absent.
observability.eval.dataset_id string no Labels the dataset_run record in eval.jsonl and is forwarded to hooks as MURMUR_DATASET_ID. Useful when diffing runs from multiple datasets.
observability.eval.scorers list no Scorer configurations. When present with at least one valid scorer, murmur/hook-eval writes eval.jsonl. When absent or empty, the hook is a no-op and logs a warning.
observability.eval.scorers[].type string yes (per entry) One of exit_ok, max_turns, max_tokens, tool_sequence, llm_judge. An unrecognized type is skipped with a message on stderr. llm_judge is unimplemented: it parses, logs a warning and emits no score.
observability.eval.scorers[].name string no Key this scorer's records appear under in eval.jsonl. Defaults to the scorer's type.
observability.eval.scorers[].max integer no Upper bound for max_turns and max_tokens. Default: 10 for max_turns, 100000 for max_tokens. Ignored by every other type.
observability.eval.scorers[].expected list no Ordered list of tool names that must appear as a subsequence of observed calls, for tool_sequence. Defaults to empty. Ignored by every other type.

trace

Field Type Required Notes
trace.capture none \| meta \| content no Default: meta. How much of each turn's driver request trace.jsonl keeps — see the table below.
trace.retain block no What bounds the sessions beside the running one: each session's records directory, and, for a launch without --workdir, the work folder the runtime created for it, removed together. A --workdir folder is never touched. On a roster's entry member, it also bounds the roster's formation directories. Omitted, nothing is ever deleted. See Retention.
trace.retain.max_sessions integer ≥ 1 no Sessions to keep in the running session's records group, counting the running session itself. The rest are removed whole: the records directory, with its trace.jsonl, blobs/, out/, home/, tmp/ and children/, and, for a launch without --workdir, its work folder <manifest-dir>/workdir/<session-id>. On an entry member, also the project's formation directories to keep, counting the current formation's.
trace.retain.max_age duration no Age beyond which a session is removed, measured from the millisecond timestamp inside its own uuid-v7 ses_ id. No file metadata is read. On an entry member, also the age beyond which an ended formation directory is removed, measured from its frm_ id.
trace.capture inference content hashes a2a_task_received.message_sha256 blobs/ tool_call.output
none — — — —
meta written written — —
content written written written written

The hashes are system_sha, tools_sha, response_sha and message_shas on each inference event, and the hash of the message text of each task the door let in, on its a2a_task_received event. Under content the body behind each one is also written to <session_id>/blobs/<sha256>, verbatim and unredacted — bodies can be large, and a blob holds the wire payload or the received message as sent.

lifecycle

Field Type Required Notes
lifecycle.task_acceptance none \| single \| queue no Default: single. How the capsule accepts incoming A2A tasks — see lifecycle.task_acceptance.
lifecycle.after_task exit \| sleep no Default: exit. What the capsule does after completing a task — see lifecycle.after_task.
lifecycle.queue_depth integer no Default: 1. Maximum number of pending, not-yet-started tasks the capsule holds under task_acceptance: queue, where it is at least 1: 0 there is refused with E-MAN-003. Under single and none it has no effect. A task beyond this limit is answered in TASK_STATE_REJECTED. For a roster member, W-ROS-001 warns when more members may call it than it holds at once.
lifecycle.input_timeout_secs integer no Maximum seconds to wait for a SendMessage reply after a tool component calls request-input, or to a driver's safety checks on a tool call. Absent means wait indefinitely — see lifecycle.input_timeout_secs.
lifecycle.conversation stateless \| threaded no Default: stateless. Whether tasks sharing a contextId accumulate history — see lifecycle.conversation.
lifecycle.max_task_reopens integer no Default: 1. Maximum times an on-task-end hook (commit_policy: reopen-task) may reopen a single task. 0 is a valid explicit value and disables reopening. Reopening never grants turns past inference.max_turns; see Task reopening.
lifecycle.shell_grace_secs integer no Default: not set — a command runs in the foreground until it exits. Seconds a shell command runs in the foreground before it is demoted to the background — see lifecycle.shell_grace_secs.
lifecycle.delegation_deadline_secs integer no Default: 600. Seconds a delegated sub-capsule may run before it is ended, and a call-member call may spend offering a busy member the task and waiting for its answer — see lifecycle.delegation_deadline_secs.
lifecycle.deadline_notice_secs integer no Default: five minutes, or a tenth of the deadline when that is shorter, and at least 1. Seconds before a mur run --deadline at which the agent is told, once, that the session is about to end — see The deadline notice. 0 sends no notice. Must be ≥ 0. Inert without --deadline.
lifecycle.keep_working object no Absent: a task ends at its first turn end. Sends message after each turn end, up to limit times per task — see lifecycle.keep_working.
lifecycle.keep_working.message string yes, in the block The text sent as the next user message, verbatim. Must not be blank.
lifecycle.keep_working.limit integer yes, in the block Most continuations one task receives. Must be ≥ 1.
lifecycle.done_check object no Absent: a task ends when the model stops. Runs command each time the agent stops, and reopens the task while it fails, up to limit times per task — see lifecycle.done_check.
lifecycle.done_check.command string yes, in the block The command to run in the work folder, split into words as the shell tool splits a command. Its first word must be listed in capabilities.shell.allow.
lifecycle.done_check.limit integer yes, in the block Most reopens a failing check causes in one task. 0 runs and records the check without reopening.
lifecycle.done_check.timeout_secs integer no Default: 600. Seconds one run may take before the runtime kills it. Must be ≥ 1.

exports

Opens surfaces of the capsule to processes outside it, on the capsule's HTTP listener:

Block Opens
exports.files A read-only view of one workdir subtree for the operator, addressed by path — see Resource plane
exports.peer_files A read-only view of one workdir subtree for other capsules, addressed by handle — see exports.peer_files
exports.peer_tasks The A2A door to tasks sent by other capsules — see exports.peer_tasks

Each block is its own authoriser: declaring one grants nothing about another, and a capsule may declare any of them or none.

Field Type Required Notes
exports.files block no The operator-facing file surface, addressed by path. Absent means every request to it is refused with no_resource_plane.
exports.peer_files block no The peer-facing file surface, addressed by handle — see exports.peer_files. Absent means the capsule mints nothing and every redeem is refused with no_peer_plane.
exports.peer_tasks block no Whether the door serves tasks from other capsules — see exports.peer_tasks. Absent means it does not.
exports.peer_tasks.accept boolean yes true serves peer tasks; false refuses them, the same as an absent block. Omitting it inside the block is E-MAN-003.
exports.files.root string yes Subtree of the work folder the export opens — the directory the agent's tools see as .. Must be relative, non-empty and free of ... Need not exist when the capsule launches. A root that resolves outside the workdir — because it already exists as a symlink pointing out of it — refuses the launch with E-CAP-007.
exports.files.mode read-only yes read-only is the only accepted value.
exports.files.max_bytes integer or suffixed string no Default: 10Mi (10485760). Per-file read ceiling: a file above it is still listed, with its real size, and refused on read with too_large. Accepts a bare byte count or one suffixed Ki, Mi or Gi. Must be greater than zero.

Declaring an export leaves the achieved containment class unchanged. Containment bounds what the capsule reaches outward; an export widens what an operator reaches inward, and hands the agent no capability at all.

exports.peer_files

Names the one subtree a peer-file handle may address. Declaring it gives the agent one runtime-provided tool, share-file, and opens GET /resources/peer/<handle> on the capsule's listener.

Field Type Required Notes
exports.peer_files.root string yes Subtree of the work folder a handle may name. Must be relative, non-empty and free of ... Need not exist when the capsule launches. A root that resolves outside the workdir refuses the launch with E-CAP-007. Independent of exports.files.root, and neither is derived from the other.
exports.peer_files.max_ttl integer or suffixed string see notes Ceiling on a minted handle's lifetime. A bare integer is seconds; s, m and h suffixes are accepted. Must be greater than zero. Optional under lifecycle.after_task: exit, where it defaults to 1h; required and at most 15m under lifecycle.after_task: sleep, which otherwise refuses the launch with E-CAP-008.
exports.peer_files.max_bytes integer or suffixed string no Default: 10Mi (10485760). Per-file ceiling on a redeemed read; a larger file is refused with too_large. Accepts a bare byte count or one suffixed Ki, Mi or Gi. Must be greater than zero.

There is no list verb and no path addressing on this plane. share-file clamps a requested ttl down to max_ttl and never up.

exports.peer_tasks

States whether the capsule's A2A door serves a task another capsule sends it.

exports:
  peer_tasks:
    accept: true
Field Type Required Notes
exports.peer_tasks.accept boolean yes true serves peer tasks. false refuses them. Default when the block is absent: refuse. peer_tasks: {} is E-MAN-003 naming exports.peer_tasks.accept; a value that is not a boolean, or a peer_tasks that is not a block, is E-MAN-002 naming the field.

A peer task is a request whose x-murmur-task-origin header is peer. The sending capsule's runtime stamps it on every message sent through murmur:message/send, and the sending agent cannot set it. A capsule that does not accept peer tasks answers every such request on its door with:

HTTP/1.1 403 Forbidden
content-type: application/json

{"error":"peer_not_accepted","message":"this capsule does not accept tasks from peers"}

The response carries no www-authenticate header and is the same for every method, path, task id and body. The agent card states the setting as peerTasks, so a caller can read it before sending.

Request Governed by exports.peer_tasks
x-murmur-task-origin: peer, any method or path on the door yes
No origin header, or any origin but peer and completion no — classified event and governed by network.authentication alone
A completion a delegated child posts back to this session no — handed to the task that made the delegation
A task delivered to this capsule as a delegated child no — it carries no origin header
/resources/peer/ no — governed by exports.peer_files and the handle
The control surface no — governed by the control token
GET /.well-known/agent-card.json no — always served

The origin header is the caller's claim: a caller that does not identify as a peer is not refused by this setting. On a door declaring network.authentication the token is checked first, so a caller without one is answered 401 before this setting is read.

exports.peer_tasks is independent of network.authentication: accepting peer tasks neither requires nor implies a token, and leaves the door's securitySchemes and its achieved containment class unchanged. murmur:message/send presents no token, so a door that declares network.authentication answers a message sent through it 401, whatever accept says.

Only the capsule's own murmur.yaml sets it: there is no mur run flag, environment variable or control.settings entry for it. It names no peer, so accept: true serves every caller that identifies as a peer.

control

What a controller holding the session's control token may change while the capsule runs, over the control surface, and what the agent may change about itself. Absent, control: {}, or all three lists empty means the capsule has no control surface and mints no token. A block that lists only control.agent_settings also mints no token.

Field Type Required Notes
control.settings list of strings no Default: empty. Settings a controller may change, each listed once. Accepted: inference.max_tokens and inference.driver, both valid only under inference.transport: http; inference.driver also requires at least one inference.alternates entry.
control.secrets list of strings no Default: empty. Credential names a controller supplies at run time. Each must be a credential name (uppercase letters, digits and _, not starting with a digit), listed once, and referenced by some artifact's gateway.api_key as ${NAME} — but not by the configured transport: http driver's. A name keying an alternate driver's gateway is accepted. The global config and the environment are never consulted for a listed name, and mur doctor reports it as supplied by a controller rather than as unset.
control.agent_settings list of strings no Default: empty. Settings the agent may change about itself, each listed once. Accepted: inference.driver, on the same terms as in control.settings. The grant is the switch-driver tool.

control: requires an inference: block: only an agent capsule serves the listener the control surface is on. Every refusal above is E-MAN-003, naming control.settings, control.secrets, control.agent_settings or control and quoting the entry. An unknown key under control: is reported as W-SEC-019. Nothing a controller sets is persisted: a restart starts from these values again.

deploy

Settings read by mur deploy run alone. mur run and mur doctor read nothing in deploy:.

deploy:
  mur_version: "1.0.0"
Field Type Required Notes
deploy map no Absent or deploy: {}, every field takes its default.
deploy.mur_version string no The mur release mur deploy run downloads from GitHub releases, caches at ~/.murmur/bin/mur-{version}-{platform} and installs on the target. Default: the version of the mur running mur deploy run. A blank value takes the default. --mur-binary takes precedence over it. A mur built from main reports an X.Y.Z-dev version that has no release to download, so a deploy run from it needs deploy.mur_version or --mur-binary, and otherwise fails with E-DEPLOY-006.

A top-level mur_version key is refused with E-MAN-003, whatever its value; the pin is written as deploy.mur_version.


Choosing a config block

Two blocks carry operator-authored settings into an artifact: inference.driver.config, and config: on that artifact's own entry in artifacts:. One question decides which one a setting belongs in.

Does this setting mean anything to a different implementation of the same role?

Answer Block The setting is about
Yes inference.driver.config Being a driver — endpoints, timeouts, retry behaviour, anything the runtime or any driver would act on.
No config: on that artifact's own entry in artifacts: Being this artifact — a provider quirk, a feature only this implementation has, a knob whose name would be meaningless to a sibling.

A driver may use both blocks. A tool or a hook has no inference: block, so it only ever uses config:.

The two blocks reach different environments:

Block Environment variable Reaches
inference.driver.config MURMUR_INFERENCE_DRIVER_CONFIG The driver, every WASM tool and every shell tool in the session.
artifacts[].config MURMUR_ARTIFACT_CONFIG The declaring artifact alone.

For the rest of what a driver or a tool is handed, see Driver and tool environment.

Yes — any driver would act on it

A retry budget means the same thing behind any provider, so it describes the role:

inference:
  model: claude-opus-4-5
  driver:
    artifact: murmur/driver-anthropic
    config:
      max_retries: 3
      request_timeout_seconds: 60

Name a driver in front of another provider instead and both keys still read the same way. These key names illustrate the question; each driver documents the keys it reads.

No — the key belongs to one artifact

murmur/driver-anthropic reads prompt_cache and prompt_cache_ttl from its own entry in artifacts::

artifacts:
  - name: murmur/driver-anthropic
    version: "0.19.0"
    runtime: driver
    config:
      prompt_cache: enabled
      prompt_cache_ttl: 1h

A cache_control breakpoint is an Anthropic construct: no sibling driver has one to place, and other providers cache with no marker at all. Under inference.driver.config these two keys would read as settings every driver understands, which none of them do.

A tool or a hook has only this block, so every setting either role reads arrives through it:

artifacts:
  - name: murmur/tool-corpus
    version: 1.0.0
    runtime: tool
    capabilities:
      state: {}
    config:
      read_recent: { default: 20, max: 100 }

The line is convention

The runtime validates the shape of both blocks and never their meaning. inference.driver.config must be a mapping or the manifest fails to parse with E-MAN-003; artifacts[].config must satisfy Artifact config shape or the launch fails with E-CAP-010. Neither check can read what a key means, so a setting written into the wrong block still reaches its artifact and still works. The line holds because operators keep it, not because the host refuses to cross it.


Inference configuration

transport: http — WASM driver

The default transport. Murmur loads a WASM driver artifact and routes every inference call through it. The driver must be declared in artifacts: with runtime: driver and a gateway: block naming the provider; a driver that is named but not installed fails with E-RUN-006.

artifacts:
  - name: murmur/driver-anthropic
    version: "0.19.0"
    runtime: driver
    gateway:
      endpoint: https://api.anthropic.com
      api_key: ${ANTHROPIC_API_KEY}

inference:
  transport: http
  model: claude-opus-4-5
  max_tokens: 4096        # optional per-turn output cap; default: the driver's models: entry
  max_session_tokens: 2000000   # optional session spend ceiling; default none
  driver:
    artifact: murmur/driver-anthropic

Turn cap: inference.max_turns

inference.max_turns caps the inference turns one task may take, across its reopens and continuations. A task that spends them ends max_turns_reached. Omitted, a task runs until the model ends it. What bounds a task with no turn cap:

Bound Ends
inference.max_session_tokens The session's inference, once its tokens are spent
spend.machine_tokens_per_day Every run on the machine, once the day's tokens are spent
mur stop, or CancelTask The session, or the one task
lifecycle.delegation_deadline_secs of the capsule that delegated the task A delegated sub-capsule
mur run --deadline, its formation's, or its parent's The session, and every sub-capsule it delegates to
A deadline the launcher imposes, such as a CI job's timeout Whatever the launcher kills

Without a deadline, mur sets no wall-clock limit on a session you launch with mur run.

Output cap: inference.max_tokens

inference.max_tokens is the per-turn output cap — the max_tokens field of the payload the runtime hands the driver, which every driver forwards verbatim to its provider API. It is provider-agnostic and reaches every driver through the same wire field, so no driver.config entry is needed for it.

context.max_tokens is the context window; inference.max_tokens bounds a single response.

At launch, each driver choice — the primary and every alternate — resolves its limits once, from the manifest or else from the models: entry its driver declares for its model:

Limit Manifest sets it Else the driver's table lists the model Else
Output cap inference.max_tokens, for every choice max_output_tokens The launch is refused with E-RUN-052, naming the choice, the model, the driver and inference.max_tokens
Context window context.max_tokens, for every choice context_window No window: compaction does not run for that choice, and no seed budget applies
Input ceiling — max_input_tokens, when the entry declares it None

Every inference call has one input budget: the input ceiling, or the context window less the output cap that call sends, whichever is smaller. Compaction's threshold and the context.seed_budget are fractions of it.

Term Value
Window context.max_tokens, else the serving driver's context_window for the model
Output cap The cap the call sends, from the table below
Ceiling The serving driver's max_input_tokens for the model, when declared
Budget min(ceiling, window − output cap); a tie goes to the ceiling
Driver entry (window / cap / ceiling) Manifest Budget Basis Fires at threshold 0.5
1050000 / 128000 / 922000 — 922000 max_input_tokens 461000
1050000 / 128000 / — inference.max_tokens: 6000 1044000 context_window_less_output_cap 522000
1050000 / 128000 / 922000 context.max_tokens: 200000, inference.max_tokens: 8192 191808 context_window_less_output_cap 95904
1000000 / 8192 / — context.max_tokens: 200000, inference.max_tokens: 200000 — refused, E-RUN-055 —
Case Result
No window No budget: compaction does not run and no seed budget applies
A choice's output cap at or above its window The launch is refused with E-RUN-055, naming the window, the cap and the ceiling
inference.compaction.max_tokens at or above the compaction model's window The launch is refused with E-RUN-055
A controller sets inference.max_tokens at or above any choice's window Refused 422 no_input_budget on the control surface; the cap in force is unchanged

A choice whose model the table does not list draws W-RUN-009 once at launch, unless the manifest sets both keys. session_start records each choice's numbers, its input budget and where each came from in model_limits, every inference line records its call's input_budget, and mur doctor prints them.

Validation is one-sided: 0 is rejected at parse time, but a value larger than a given model's documented maximum is neither rejected nor clamped — an over-large cap surfaces as the provider's own error at request time.

Call Output cap sent
An agent-loop turn The cap of the choice serving it. A controller's change to inference.max_tokens through the control surface is the cap of every later turn, whatever the choice; until one is made, GET /control shows the cap the next turn uses
A hook's run-inference while the hook serves on-compaction: the threshold trigger, mur run --resume … --resume-mode compact, seed summarization inference.compaction.max_tokens when set; otherwise as any other hook's call
Any other hook's run-inference The max_output_tokens the primary driver's table declares for the model the hook calls — its own model, inference.compaction.model, or inference.model. When the table lists none, the primary choice's cap

The anthropic driver caps extended thinking's budget_tokens to max_tokens - 1, so lowering this value also squeezes a configured thinking budget.

When a turn hits the cap

On transport: http, a turn the provider stopped at the cap ends that turn, not the task:

The capped turn Result
The task's last allowed turn under inference.max_turns The task ends ok; every surface carrying the reply marks it as a fragment
The second in a row with no text and no tool call, even when it is also the last allowed turn The task ends failed, task_failed cause output_cap
Any other The runtime drops any tool call in it unrun, adds one user message stating the cut, and the task goes on

inference.max_turns: 1 allows one counted inference call per task, an agent-loop turn and a hook's run-inference call alike, so a capped first turn is always the last and ends the task ok with the fragment. A turn cut at the output cap gives the message's text and what a capped turn skips; W-RUN-001 lists what each outcome leaves behind.

A driver reaches this path by reporting stop_reason: "max_tokens" in its response. A driver whose provider names the same condition differently normalizes it to that value.

Session spend ceiling: inference.max_session_tokens

inference.max_session_tokens caps the tokens one session spends on inference. It counts every driver call the session makes — each agent turn, and each hook's run-inference call, including compaction and seed summarization. What each call is charged depends on the transport and on what the provider reported, and each line of the trace's inference records shows which:

Call Charged
transport: http, the line carries both input_tokens_actual and output_tokens_actual The provider's request, input_tokens_actual + cached_tokens + cache_write_tokens, plus its completion, output_tokens_actual. Thinking is never added
transport: http, any other line input_tokens + output_tokens, the runtime's own counts of the request it built and the response it read
transport: http, a failed call The provider's request plus completion when its error payload reported both, otherwise the runtime's estimate of the request alone
transport: process input_tokens + output_tokens, the counts the harness reports and its driver relays — see transport: process

Under transport: http, before each driver call the runtime admits the call only if these three fit under the ceiling together:

Part Value
Used What every earlier call was charged, plus what calls still in flight reserved
The call's input input_tokens of the request about to be sent: the runtime's estimate, anchored on the provider's last count of the conversation when there is one
The call's most output The output cap the call sends

A session with inference.max_session_tokens set therefore reserves the whole output cap for every call: with the cap taken from the driver's table, that is the model's full output ceiling. Set inference.max_tokens to reserve less.

When an agent turn's request is measured depends on whether any spend ceiling covers the session:

In effect The request is measured
inference.max_session_tokens, spend.machine_tokens_per_day, or both Before it is sent, and admitted by that number
Neither Alongside the call, while the provider answers

Either way the trace, the on-inference hook event and the spend meter carry the same input_tokens.

A call that does not fit is never sent. The refusal appears as:

Surface What it says
trace.jsonl A spend_ceiling_reached line naming the limit, the ceiling, what was used and what the call needed
out/result.txt in the records directory stopped: spend ceiling reached: …, ending with retrying will not get past it
A2A terminal status TASK_STATE_FAILED, with the same spend ceiling reached: … text as its message
A hook's run-inference err with the same text
task_end, session_end exit_status: "spend_ceiling_reached"
mur run status: spend_ceiling_reached, E-RUN-040, exit code 1

The first refusal latches. That task ends, and every later call and task in the session is refused without reaching the provider; the session itself stays up.

A compaction hook whose call is refused decides what happens next:

The hook returns Outcome
none or replace-context The session continues, and the next agent turn's admission decides
An error The task and session end with exit_status: "spend_ceiling_reached", not failed, and out/result.txt in the records directory reads stopped: spend ceiling reached: …

A compaction hook error that follows no refused call ends the session as failed.

A call that is cancelled, fails to dispatch or returns a failed status is charged its input alone. The sum of a session's inference lines never exceeds the ceiling, except by how far a response's measured output_tokens runs past the call's reserved output — the runtime's count of a response can be larger than the cap the provider applied.

Setting Bounds
inference.max_session_tokens Tokens across the whole session, input and output
inference.max_tokens One response's output
context.max_tokens The conversation's size against the context window, which drives compaction
spend.machine_tokens_per_day Tokens across every run on the machine per UTC day

Under transport: process there is nothing to admit: the harness reaches its provider itself, and the runtime learns what a turn cost only once that turn is over. Each closed turn is charged and the ceiling is asked afterwards, so a ceiling stops the next turn rather than the one that reached it, and the session's total can overshoot by at most one harness turn. The run stops the same way it does under http — the same sentence, the same spend_ceiling_reached line, the same exit_status — and the harness is killed at once rather than given its exit grace. A ceiling set against a process driver whose describe() reports no usage is refused at launch with E-RUN-038.

A delegated child is bounded by its own inference.max_session_tokens and by the machine ceiling, not by its parent's session ceiling.

Retrying transient failures: inference.retry

A driver call that fails transiently is made again: the same request, through the same driver choice, after a wait. This covers every agent turn and every hook's run-inference, which is how compaction calls the model. The model never sees the failed attempt, no hook fires for it, and nothing from it is committed to the conversation, the continuation or out/result.txt.

inference:
  retry:
    max_retries: 6          # 0–20; 0 disables retrying
    initial_delay_ms: 1000  # 1–60000
    max_delay_ms: 60000     # 1–3600000, and >= initial_delay_ms
Key Type Default Bounds
max_retries integer 6 0–20. The most retries one call makes after its first attempt; 0 disables retrying
initial_delay_ms integer 1000 1–60000. The backoff before the first retry
max_delay_ms integer 60000 1–3600000, and at least initial_delay_ms. The longest backoff, and the longest wait a provider may ask for

A value outside its bounds fails the manifest parse with E-MAN-003 naming the key, as inference.retry.<key>; a max_delay_ms below initial_delay_ms names inference.retry.max_delay_ms.

A failure is transient when one of these holds, checked in order:

Rule Retried
The provider rejected the credential Never
The driver's result carries transient: "false" No
The driver's result carries transient: "true", as a driver reports a stream that broke after the provider answered 200 Yes
The connection broke before a response arrived: reset, terminated, timed out, or the response was incomplete Yes
The provider answered 408, 429, or a 5xx other than 501 and 505 Yes
Anything else, including a refused connection, a DNS or TLS failure, and every other 4xx No

The wait before retry k (counting from 0) is drawn uniformly from [d/2, d], where d = min(max_delay_ms, initial_delay_ms × 2^k). When the provider's response carried retry-after-ms or retry-after (seconds, or an HTTP date), the wait is exactly that value.

The runtime stops retrying, and the call fails as it would have without retries, in four cases:

Case retry_stopped on the call's inference record
max_retries retries were already made max_retries
The provider asked for a wait longer than max_delay_ms retry_after
The wait would end at or after the session's deadline deadline
The spend ceiling refuses the next attempt None: the ordinary spend_ceiling_reached line and ending are written

When a failed call was retried, or one of the first three cases stopped its retrying, the task's failure reason ends with the number of attempts and the stop. The reason is what task_failed records, out/result.txt holds and E-RUN-040 prints:

The call Reason
Took 3 attempts, then stopped at max_retries HTTP 503: … (3 attempts; retry stopped: max_retries)
Was not retried, because the wait would pass the deadline HTTP 429: … (1 attempt; retry stopped: deadline)
Was retried once, then failed on a failure that is not transient HTTP 400: … (2 attempts)

The failed call's inference record keeps the provider's error text alone in error, and names the same facts in attempts and retry_stopped.

Every attempt is admitted against the spend ceilings before it is sent, and no admission is held during a wait. What a failed attempt that is retried costs:

The attempt failed on Charged
A provider status, or a connection that broke before a response Nothing
A failure the driver marked transient What any failed call is charged: the provider's report from its error payload, otherwise the request's estimate

A cancel during a wait ends the task as canceled, in phase inference. A hook's call has no cancel of its own and does not consult the hook's limits.deadline_seconds: a wait that outlasts it ends the hook as a slow provider call would.

Text a failed attempt already streamed to an A2A client stays on the stream, followed by a final empty text frame; the answer the task commits is the retry's.

Each retry writes one inference_retry line to trace.jsonl. The call keeps one inference record, which carries attempts when it took more than one. Retries do not count as turns, against inference.max_turns, or in the turn and token totals.

Refused under transport: process with E-MAN-003: is not valid with transport: process; the harness retries its own provider calls.

Tool refresh: inference.tool_refresh

The tool list the model is offered is built from the session's installed artifacts at the start of each task, and every inference call of that task sends it unchanged until the installed set changes. A change the model made itself, with pull-artifact or remove-artifact, is in the tool list of its next inference call, whatever this field says: the model asked for it and calls it next. That call misses the provider's prompt cache for its whole prompt, and its tools_refreshed line names the trigger agent.

Any other change, such as a script capsule's manage.pull, is added at the boundary inference.tool_refresh names:

Value The installed artifact is offered from Cost
compaction (default) The first inference call after a compaction replaces the context, or the start of the next task, whichever comes first The tool list and system prompt miss the provider's prompt cache on that call. The conversation already misses it after a compaction
immediate The next inference call That call misses the provider's prompt cache for its whole prompt

The tool list sits at the head of the prompt the provider caches, so any change to it is a cache miss for everything after it — see Measured cost.

Compaction runs only when the conversation reaches the context window, which may be many turns away or never within a task.

A session that installs nothing sends the same tool list on every call under either value.

What the model can do with an artifact once it is offered depends on its runtime:

Pulled artifact Offered Callable
runtime: skill Yes, with no input parameters Yes: calling it returns its skill.md
The skill named by inference.system_prompt_artifact No —
A WASM or native tool not declared in artifacts: Yes Yes: a pulled tool joins the dispatch allowlist
runtime: driver or runtime: hook No —

Each refresh writes a tools_refreshed line to trace.jsonl just before the inference line of the call that carries the new list, and session_start.tool_refresh records the value in effect.

Refused under transport: process with E-MAN-003: the harness owns its tool list.

Measured cost

Three consecutive calls of one conversation against OpenAI gpt-5.6-luna through murmur/driver-openai, each within seconds of the last, with the same system prompt and each call's messages beginning with every message of the call before:

Call Tool list Input tokens Read from cache
Before the install One skill 3,325 3,303
First call carrying the new list One skill added, sorting first 3,359 0
The call after Unchanged since the previous call 3,378 3,356

The same session's tool list and system prompt count 347 tokens by the runtime's own estimate: that is what a compaction refresh gives up, against the whole prompt for an immediate one.

transport: process — harness subprocess

Murmur runs a harness CLI as a subprocess and communicates over stdin/stdout, driving it through the process driver the manifest names. Authentication is whatever that harness is already configured with: credentials under this transport are the harness's own, and murmur neither holds nor controls them.

artifacts:
  - name: local/my-process-driver
    version: "0.1.0"
    runtime: driver
capabilities:
  env:
    allow: [HOME]        # every variable the driver's describe() requires
inference:
  transport: process
  driver:
    artifact: local/my-process-driver
  max_turns: 10
Behaviour Details
Binary inference.command when set, otherwise the executable the driver's describe() names, looked up on PATH. A binary that cannot be resolved refuses the launch with E-RUN-006.
Version check The binary is run with the driver's version arguments. A version the driver was not tested against, or one that cannot be read, is W-RUN-002 and the run proceeds.
Tool dispatch When the capsule declares tool artifacts, murmur stands up a loopback tool server and hands the driver its URL, its token and the capsule's tool names; the driver points its harness at them, the model calls those tools, and murmur executes them. An image a tool attaches to its result is returned after the result's text as an MCP image item, under the context.images checks, with max_per_turn counted per tool call; whether the model sees it is up to the harness. With no tools declared, the harness runs with no tools.
Environment The harness starts with an empty environment, then receives the host values of the names capabilities.env.allow declares, whatever the driver's launch plan sets, the host's USER and LOGNAME, and the session's runtime and records directories. See Process environment.
Turn limit One model action — a message or a tool call — is one turn, bounded by inference.max_turns. A turn past the limit ends the run with E-RUN-033.
Inactivity limit A run with neither a line of harness output nor a tool call for 600 seconds is killed with E-RUN-035. A harness that is working is never interrupted for taking a long time.
Interrupt grace A harness sent a graceful interrupt has 10 seconds to end on its own before it is killed. Both this and the inactivity limit are fixed, not manifest settings.
Cancellation CancelTask interrupts the harness the way the driver's describe() declares: stdin-message writes the launch plan's interrupt bytes to its stdin, signal-int sends it SIGINT, and unsupported kills it outright. A graceful interrupt gets the interrupt grace above. The task is TASK_STATE_CANCELED either way, and the capsule, its queue and its conversation keep going.
Result The harness's final result text is written to out/result.txt in the session's records directory. A turn the harness reports as failed is E-RUN-033, naming why.
Observability Session, inference and tool hooks, trace.jsonl and OTel spans are all emitted normally.
Token counts The harness's own, as its driver reports them, in the inference line's input_tokens and output_tokens — this transport has no runtime estimate, so input_tokens_actual and output_tokens_actual stay absent. A driver that reports no usage leaves both counts absent, which is not the same as zero.
Spend ceilings inference.max_session_tokens and spend.machine_tokens_per_day are enforced against those reported counts.
Hook run-inference Unavailable: a hook's run-inference call returns an error, sends nothing, counts toward no spend ceiling, and writes nothing to the trace.
Compaction Does not run. context.max_tokens and inference.compaction parse but are inert under this transport, except inference.compaction.max_tokens and inference.compaction.driver_config, which are refused; the harness manages its own context.
Tool refresh The harness's. inference.tool_refresh is refused with E-MAN-003; the tool list is bound once per task.
Context seeding Does not run. The context.seed_budget keys parse but are inert, and a seed-context an on-task-start hook returns is recorded as a rejected context_seed with reason: "unsupported_transport".
Conversation The harness's. Murmur keeps no message list and writes no conversation.jsonl; it maps each context to the harness's own session id in a harness session map, which is what lifecycle.conversation: threaded and mur run --resume continue. A session the harness cannot find fails the turn with E-RUN-036.

Process driver

A process driver is a runtime: driver artifact that exports the murmur:driver/process interface: it knows how to drive one harness. It is granted nothing: no environment, no files, no network, and no Murmur host interface. It needs no inference_auth: block.

capabilities:
  env:
    allow: [HOME]            # every variable the driver's describe() requires
artifacts:
  - name: local/my-process-driver
    version: "0.1.0"
    runtime: driver
inference:
  transport: process
  driver:
    artifact: local/my-process-driver
    config: {}               # optional; handed to the driver as JSON

mur run loads the named driver and checks it in this order. The first check that fails refuses the launch:

Check Refused with
The artifact exports murmur:driver/process at the version the served WIT level lists E-WIT-LEVEL-001 when it exports only another version; E-RUN-029 when it exports none
It instantiates with no grants, and describe() returns usable variable names E-RUN-032
Every variable describe() requires is declared in capabilities.env.allow E-CAP-019
describe() reports usage, or no spend ceiling is in effect E-RUN-038

A transport: http manifest whose driver exports the process interface is refused with E-RUN-030.

inference.system_prompt / system_prompt_file

inference.system_prompt and inference.system_prompt_file inject a static prompt as the top-level system parameter on every API call, including the first turn and every subsequent turn in a multi-turn session.

# Option A: inline text
inference:
  system_prompt: |
    You are a strict code reviewer.
    Always respond in JSON.

# Option B: load from a file next to murmur.yaml
inference:
  system_prompt_file: conventions.md

Rules:

  • At most one prompt source. Setting more than one of system_prompt, system_prompt_file and system_prompt_artifact fails with E-MAN-003.
  • File paths are relative to the directory containing murmur.yaml, not to the work folder.
  • The file is read once at launch, before any inference call. If it is missing or unreadable, mur run exits with E-RUN-009 before making any API call.
  • File content is used verbatim — whitespace is preserved and no trimming is applied. Inline system_prompt text is trimmed at parse time.
  • With no prompt source set, no system parameter is emitted.
  • A run can override whatever is declared here. mur run --system-prompt replaces all three sources for that invocation only and leaves murmur.yaml untouched.

inference.instruction_files

inference.instruction_files lists files in the work folder that become part of the system prompt. Repositories keep their conventions for coding agents in files such as AGENTS.md and CLAUDE.md; listing them hands those conventions to the agent on both transports.

inference:
  system_prompt: "You review pull requests."
  instruction_files: [AGENTS.md, CLAUDE.md]

A listed file becomes part of the system prompt, so list a file only for a repository you trust. Its text is not marked as untrusted content.

At launch the runtime reads each listed file once and appends one block per file to the system prompt, after the manifest's own prompt (or the mur run --system-prompt text that replaces it), in manifest order. Every task of the session gets the same prompt. An edit to a listed file applies from the next session.

Each block is a header line and the file's text, with trailing line endings removed:

Case Header
Read whole [Instruction file: AGENTS.md]
The same file listed again under another name [Instruction file: AGENTS.md (also listed as CLAUDE.md)]
Larger than 32 KiB [Instruction file: AGENTS.md, first 32768 of 51200 bytes]
Both [Instruction file: AGENTS.md (also listed as CLAUDE.md), first 32768 of 51200 bytes]

Each entry resolves against the work folder. What happens to it:

Entry Result status in the trace
A UTF-8 text file in the work folder, or a link to one Read; contributes a block read
No such path, or a link whose target does not exist Skipped missing
A file an earlier entry already read, under another name Named in the earlier block's header same_file
A link that leads outside the work folder Not read outside_work_folder
A directory, FIFO, socket or device Skipped not_a_file
Bytes that are not UTF-8 Skipped not_utf8
Nothing but whitespace Skipped empty
Any other read failure Skipped unreadable

Rules:

  • Entries are relative to the work folder. An empty entry, an absolute path or any .. component fails the launch with E-MAN-003, naming inference.instruction_files[<index>] and the entry. ./AGENTS.md and AGENTS.md name the same file.
  • 32 KiB per file. A larger file is cut at the last whole character at or below 32768 bytes, and its header gives both sizes. The limit matches the default of Codex's project_doc_max_bytes, so a file Codex loads whole loads whole here. It is fixed, not a manifest field.
  • Only the listed paths are read. A file with a listed name in a subdirectory is not loaded; the session's MURMUR.md lists where such files are, in its ## Instruction Files section, which is present only when this field is set.
  • @path imports inside a listed file are not followed. The text is used as written.
  • Nothing outside the work folder is read, including the host user's own ~/.claude/CLAUDE.md or ~/.codex/AGENTS.md, and no parent directory of the work folder is searched.
  • The trace records every entry. session_start.instruction_files gives each entry's status, and for a read file its size, the bytes included and their SHA-256. See session_start.
  • An empty list reads nothing and is recorded as []. Without the field the system prompt is exactly what the prompt fields make it.

On transport: process the composed prompt is what the process driver is handed. The driver-claude-code driver keeps Claude Code's own loading of CLAUDE.md off, so these blocks are the only project instructions the harness gets — see Run a capsule on a subscription.


Hook artifacts

Declare hook artifacts with runtime: hook in artifacts:. Hook artifacts are WASM components that implement murmur:hook/lifecycle; the runtime calls them at fixed lifecycle points, and what it does with a successful return value is set by the hook's own commit_policy.

artifacts:
  - name: murmur/hook-debug
    version: "0.10.0"
    runtime: hook

observability:
  otel_endpoint: "http://localhost:4318"
Behaviour Details
Model visibility Hook artifacts are not included in the tool inventory or the MURMUR.md installed-tool list.
Invocation order Multiple hooks are invoked in manifest declaration order for each event.
Failure handling A hook that returns an error does not abort the agent loop. The error is appended to logs/hook-<name>.log in the session's records directory. For an execution_mode: async hook it is also recorded as a hook_dispatch_error event in trace.jsonl, since the log is otherwise the only place it appears. A blocking hook's error is additionally surfaced to the agent loop, which is fatal only for compaction.
Workdir access A hook sees one directory, and only where its entry in the capsule manifest grants a filesystem.scope — see Hook capabilities.
Reference hook murmur/hook-debug writes one JSON object per event to workdir/hook-debug.jsonl.
Call deadline Each hook lifecycle call gets its own wall-clock budget: capabilities.limits.deadline_seconds on the hook's own artifacts: entry, else the capsule-wide capabilities.limits.deadline_seconds, else 30 seconds — well below the capsule-wide 600-second default, so one wedged hook cannot stall a session for most of ten minutes per event. Time a hook spends waiting on its own run-inference calls counts against it.

Hook contract fields

These three fields live in the hook artifact's own murmur.yaml, not in the capsule manifest that installs it. They are the hook author's declaration of what the hook does, so a capsule operator cannot change them by editing their own manifest.

Field Values Default Meaning
binding on-stage, on-session-start, on-task-start, on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end absent — the hook receives every event Which lifecycle event(s) the hook is dispatched for.
execution_mode blocking, async blocking Whether the agent loop waits for the hook. A binding that commits an arm must be blocking, so async requires commit_policy: none. on-stage must be blocking.
commit_policy none, write-manifests, replace-context, reopen-task, seed-context, deny none What the runtime does with the hook's successful output.

binding is the single source of truth for what a hook can commit, and commit_policy is checked against it when the capsule is staged. Each binding honors exactly one output, so it admits exactly one commit_policy — plus none, which is always valid and means the hook only observes:

binding Valid commit_policy
on-stage write-manifests or none
on-compaction replace-context or none
on-task-end reopen-task or none
on-task-start seed-context or none
on-shell deny or none
on-tool-call deny or none
on-inference none only — on-inference commits an artifact output, which has no commit_policy spelling
on-session-start, on-session-end none only — these events commit nothing
absent (all events) any value except deny

Declaring a commit_policy the binding cannot honor is an error at capsule-staging time, before the hook component is compiled or run. For example binding: on-task-end with commit_policy: replace-context fails with:

hook my-hook@1.0.0 invalid config: commit_policy 'replace-context' is not valid for binding
'on-task-end'; binding 'on-task-end' honors commit_policy 'reopen-task'

commit_policy: deny additionally requires an explicit binding: of on-shell or on-tool-call. It is the one policy an omitted binding: cannot carry: deny is answered at a decision point standing in front of a call, and a hook that does not name which of the two events it gates would be asked to decide on calls it was never written to judge. Omitting binding: with commit_policy: deny fails with:

hook my-hook@1.0.0 invalid config: commit_policy 'deny' requires an explicit binding: 'on-shell'
or 'on-tool-call'; a hook with no binding: is dispatched at every event, including decision
points it was not written to decide

A hook declaring commit_policy: deny is a policy hook. It is called immediately before the call it gates is dispatched, is handed the resolved identity of what is about to run, and returning deny(reason) means the call does not happen. See Policy hooks for what a policy hook decides on and what happens when one fails.

See What each handler can commit for the runtime side of the same table.

Async hook execution

An execution_mode: async hook is instantiated once for the session and reused for every event: state the hook keeps in memory — a running counter, a buffered span, an open client — survives across calls. Each async hook has its own bounded, ordered job queue and a dedicated worker that drains it one call at a time, so dispatching an event to an async hook returns immediately and calls to that hook are never reordered or run concurrently with each other. Every async hook's queue is drained, and its in-flight call awaited, before the session ends, so a queued on-session-end call — a final metrics export, for example — is not lost when the session tears down. A hook still working when the drain's bounded budget runs out is abandoned and reported the same way any other hook fault is.

on_overflow: on the capsule's own artifacts: entry controls what happens when that hook's queue is full, which only happens when the hook is falling behind the rate of lifecycle events:

Value Behaviour
drop (default) The event is discarded and counted. Dispatch never waits, so a slow or stuck async hook cannot delay the agent loop.
block Dispatch waits for the hook's worker to make room. No event is lost, at the cost of putting a slow hook back on the critical path.
artifacts:
  - name: murmur/hook-grafana
    version: "0.11.0"
    runtime: hook
    on_overflow: block   # wait for room instead of dropping events under load

An async hook's output is always discarded, even an arm that would be honored for a blocking hook on the same binding: nothing waits for its answer, so there is nowhere to apply it. That is why execution_mode: async is only valid with commit_policy: none.


Capability validation

Network allow entries

Accepted forms:

  • full URL: https://api.example.com
  • host only: api.example.com
  • host + port: localhost:11434

A URL entry must carry no path, query or fragment, and its scheme must be http or https. Anything else fails with E-CAP-001.

A request an artifact sends through its credential gateway does not consult this list, so a gateway's upstream need not appear in it.

Filesystem scope

  • must be relative, not absolute
  • cannot escape the workdir via ..

A scope that breaks either rule fails with E-CAP-002. See What bounds a WASM artifact.

Read-only paths

capabilities.filesystem.read_only lists workdir-relative subtrees the capsule reads but does not write:

capabilities:
  filesystem:
    read_only:
      - tests
      - bench/fixtures

Each entry follows the same two rules scope does — relative, and no .. that escapes the workdir — and an entry that breaks either fails with E-CAP-012 at staging, before any registry pull, workdir creation or component instantiation. An empty or whitespace-only entry is dropped at parse.

An entry names a subtree of the workdir root, matched a path component at a time:

Entry Covers Does not cover
tests tests, tests/a, tests/a/b, ./tests/a, <workdir>/tests/a tests2, testsuite/a, atests, build/tests

A candidate path is resolved against the workdir before it is matched: an absolute path, a relative one and a symlink into the subtree all produce the same rule and the same recorded path. A path that resolves outside the workdir is not covered by any entry — what reaches it is decided by the directory the artifact is granted and, on a kernel-enforcing host, by Landlock.

What the runtime refuses. The check runs on the resolved call, before dispatch and before any policy hook is asked. It refuses what it can positively identify as a write:

Call Identified as a write when
Shell (-c script body) A redirection — >, >>, >\|, &>, &>>, N>, N>> — names a covered path
Shell A covered path is in a write-target position of tee, rm, rmdir, unlink, shred, truncate, mkdir, touch, chmod, chown, patch (any non-flag argument), mv, cp, install, ln (the last one), sed (with -i/--in-place), or dd (the of= value)
Tool A covered path is the string value of dest, dest_path, destination, destination_path, target_path, output_path, out_path, new_path or to
Tool A covered path is the string value of path, file_path, filepath, filename or file, and the same JSON object also carries content, contents, text, data, body, new_str, new_string, replacement, patch or diff

Tool-input keys are matched case-insensitively with - and _ folded, and the pairing rule is evaluated per JSON object, so a nested edit list is read the same way a flat input is. A path with no content key beside it is a read.

A refused call does not run: no shell record and no tool_call record is written, and the model receives an error naming the path, the rule, that nothing ran, and that the path is still readable. See protected_path_denied.

What a tool declares about its own input. A tool artifact says which of its inputs are filesystem destinations, which are payload it only stores, and which destinations it derives from an input rather than reading whole — with JSON Schema's format keyword on its own properties and one keyword at the root of its own input_schema:

Declaration Written on Effect
"format": "murmur-destination" A string property The value at that location is checked against every read_only entry, wherever in the input it sits
"format": "murmur-opaque" An object or array property The key-name rules above do not descend into that subtree
"murmur-destinations": [...] The schema root, beside type and properties Every entry is resolved against the input and checked the same way
input_schema: |
  {"type":"object","properties":{
    "edits":{"type":"array","items":{"type":"object","properties":{
      "path":{"type":"string","format":"murmur-destination"}}}},
    "note":{"type":"object","format":"murmur-opaque"}}}

An annotation refines where the runtime looks, never whether it refuses: nothing in a schema permits a path, and every refusal is still decided by the operator's own read_only entries.

Case Behaviour
A tool that declares nothing Judged by key name, exactly as the table above describes
murmur-opaque on a string property Ignored; the key-name rules keep running on the object that carries it
murmur-destination inside a subtree marked murmur-opaque Still checked
murmur-opaque on the schema's top level The key-name rules do not run on that tool's input at all; only its declared destinations are checked
An annotation behind a $ref Not resolved, so that tool keeps the key-name rules

A refusal a declared destination triggered names the location in the model's error and in the trace record: edits[].path for the schema above.

Destinations derived from an input. A tool whose real write is a fixed path under one of its inputs — a graph database under <repo_path>/.murmur, where repo_path is a read source — declares it at the schema root, in a murmur-destinations array:

input_schema: |
  {"type":"object",
   "properties":{"repo_path":{"type":"string"},"file":{"type":"string"}},
   "murmur-destinations":["{repo_path}/.murmur"]}

Each entry is a braced input location, optionally followed by / and a relative suffix:

Entry Means
{repo_path}/.murmur .murmur under every string at repo_path
{repo_path} Every string at repo_path, unmodified
{paths[]} Every string in the paths array
{edits[].path} Every path string in the edits array

The empty list, "murmur-destinations": [], declares that the tool writes nothing named by or derived from its input.

The location is spelled the way a refusal names it — object keys joined with ., [] for an array step. The braces are mandatory, so repo_path/.murmur is malformed. A suffix is relative and stays inside what the location named: a leading /, an empty component, a . or .. component and a \ anywhere are each rejected, as is an entry naming a location the schema does not declare.

Validity is all-or-nothing. One malformed or dangling entry, or a value that is not an array of strings, discards the whole list: no derived destination is checked and the tool is judged exactly as one that declared no list at all.

A capsule that declares read_only and installs a tool whose schema leaves a path-shaped or destination-shaped property to the key-name rules fires W-SEC-018 at staging, naming the tool and every such property. Which declaration silences which property:

Declaration Silences the warning for
"format": "murmur-destination" The property it sits on, and no sibling
"format": "murmur-opaque" The property it sits on, only where the schema declares that property an object or array — the only place it takes effect — and everything inside that subtree
"murmur-destinations": [...], the empty list included The whole tool

What no declaration can say. A property that is a destination under some input values and a read source under others: a repo a tool writes under checkout, reset --hard, stash pop, pull, merge and cherry_pick, and reads under log, diff, show and status. Neither a format value nor a murmur-destinations entry takes a condition, so such a property stays undeclared and falls back on its name: it is judged by the key-name rules if its name is in the tables above, and not checked at all if it is not. repo is in neither table, so a tool with that shape is contained there only by capabilities.filesystem.scope.

What it does not refuse. Everything the dispatch check cannot positively identify — command substitution, eval, a binary outside the table above, and an allowlisted interpreter's own file I/O. Declaring read_only alongside an interpreter in capabilities.shell.allow fires W-SEC-017 at staging, naming that binary. Nor does it descend into a subtree a tool declared murmur-opaque, so a destination the same tool did not declare is not seen there. The declaration is also not a defence against a malicious artifact: see Access control.

mur run --explain-scope and mur doctor both print the declared subtrees and whether the protection is enforced for every call the runtime can read as a write or advisory against a named interpreter, so it can be checked before a capsule is launched.

State store name

capabilities.state.store names one directory under ~/.murmur/state/, so it is a single path segment:

  • non-empty
  • contains no /
  • is not . or .., and does not begin with .
  • is not absolute

A name that breaks any of these fails with E-CAP-009 at staging, before any registry pull, workdir creation or component instantiation, and creates nothing under ~/.murmur/state/. mur run --explain-scope refuses the same names with the same code. Omitting store: uses the capsule name, which is read from your own manifest and never from the artifact's bundled one.

Artifact config shape

artifacts[].config is delivered as one environment variable holding JSON, so the runtime checks that the block can travel that way:

Rule Refusal
The block is a mapping E-CAP-010
Every key in that mapping is a string E-CAP-010
The block serializes to JSON E-CAP-010
The serialized JSON is at most 65536 bytes E-CAP-010

A block that breaks any of these fails at staging, before any registry pull, workdir creation or component instantiation, and leaves no work folder or records directory behind. mur run --explain-scope refuses the same blocks with the same code. An oversized block is refused rather than truncated, and the message names both the size it serialized to and the limit.

config: written with no value under it is an empty block, not an absent key, and is refused on the same terms. Omit the key to deliver no variable.

Shell allow

  • Each entry is a bare binary name (bash, jq): one path component, with no /, no whitespace, and not . or ... The session runs the binary of that name on the PATH mur run starts with.
  • An entry that is not a bare name is refused with E-MAN-003 when the manifest is read, before anything is staged or installed. To allow a binary outside the usual PATH, name it bare and put its directory on PATH for mur run:
PATH=/opt/uv/bin:$PATH mur run --task "…"
  • A capabilities.shell block present with an empty allow list is rejected at parse time.
  • A synthetic tool manifest is written to tools/<binary>/murmur.yaml in the session's runtime directory at session start for each listed binary; the agent discovers these alongside artifact-backed tools.
  • The tool's command argument carries the arguments alone — the runtime supplies the binary. A shell interpreter (bash, sh, zsh, fish, dash, ksh) is the exception: its command is a whole shell line, run as the interpreter's -c argument.
  • The result the agent reads back holds the exit code and each output stream that printed anything; the command is in the agent's own tool call. A call to bash with printf 'out\n'; printf 'err\n' >&2 reads back:
Exit code: 0
Stdout:
out

Stderr:
err
Part Present
Exit code: <n> Always, first
Stdout: and the output When the command printed to stdout
Stderr: and the output When the command printed to stderr
Output truncated. Full output written to <path> When stdout or stderr passed 16 KiB; the log at <path> holds both in full

When the runtime attributes the command's failure to one capabilities.resources limit, a line naming that limit and what to do follows the result; see Which limit a subprocess hit.

Install entries

Each entry in capabilities.install.skill and capabilities.install.tool takes one of four forms:

Form Example Matches
Exact full name ana/code-review That artifact only
Scope and prefix ana/style-* Every artifact in the scope whose bare name begins with the prefix
Scope ana/* Every artifact in the scope
Wildcard * Every artifact of every scope
  • An exact name is a full name, <scope>/<name> — see Artifact names.
  • A prefix uses the same characters as a name, does not begin with -, and ends in a single *. It may end in - before the *.
  • An entry with no scope, such as code-review or style-*, is refused at parse with a hint naming its scoped forms. Anything else is refused too, naming the key and the entry: a * anywhere but the end, a second *, uppercase, a second /, :, whitespace, or an empty string.
  • Only * reaches across scopes, so a child capsule's entries are covered by its parent's only when they stay within the scopes its parent's entries name.
  • A block that names no entry in either list is refused at parse, naming capabilities.install. Omit the block to grant no install.
  • An unknown key inside the block, such as driver:, grants nothing and warns with W-SEC-019.

Retention

Three stores grow as capsules run:

Store Bounded by Enforced Each deletion is reported as
Sessions: each records directory under ~/.murmur/records/, and the work folder the runtime created for a launch without --workdir trace.retain At launch, by the runtime A retention event in the running session's trace
Conversation records under ~/.murmur/conversations/ context.retain At launch, by the runtime A retention event in the running session's trace
Formation directories under ~/.murmur/formations/, each with its records subtree under ~/.murmur/records/ The roster entry member's trace.retain At the end of every mur run --roster, by the launcher A [mur run] retention: line on the launcher's stderr

There are no defaults. A capsule with no retain: block deletes nothing, ever.

Rule Effect
The block is omitted Nothing is deleted. The stores it would bound grow without limit.
The block is present but empty (retain: {}, or retain: with nothing under it) Refused at parse time, naming the block. Omitting the block is how a capsule declares no policy.
Both keys are present ANDed. A session, record or formation directory survives only if it is inside both limits.
A key is 0 Refused at parse time, naming the key.

Durations are written as an integer optionally suffixed s, m, h or d; a bare integer is seconds.

What each key measures

Key Age is measured from Unit removed
trace.retain.max_sessions — One session, whole: its records directory and, without --workdir, its work folder
trace.retain.max_age The uuid-v7 timestamp inside the ses_ id One session, whole
trace.retain.max_sessions, on an entry member — One formation directory and its records subtree, whole
trace.retain.max_age, on an entry member The uuid-v7 timestamp inside the frm_ id One formation directory and its records subtree, whole
context.retain.max_messages — The oldest messages of one record
context.retain.max_age The last write to conversation.jsonl One context directory, whole

A session is an independent unit — nothing references it and nothing spans two of them — so it is removed whole: its records directory, with its trace.jsonl, blobs/, out/, home/, tmp/ and children/, and, for a launch without --workdir, its work folder <manifest-dir>/workdir/<session-id>. The decision is made on the records group's ses_* names. A record is one unit that grows, so it is trimmed from the front: the newest max_messages stay, each keeping the id it has always carried, and the header line records what went.

What retention never touches

Never removed Why
The running session's own directory, or any ses_ id at or after it A capsule launched while this one is running is inside the same workdir, and its session is not this session's to delete.
The launch's own formation directory or a later one, one still running, another project's, or one with no formation.json An entry member's policy reaches only its own project's ended formations. See Removing formation directories.
The context the launch is using Retention must not delete the conversation it is about to continue.
A record whose header names another capsule Two capsules can share a context.record_store; neither prunes the other's history.
A record with no header line A record written by a capsule that declares no context.retain is unowned, and the age sweep never removes it. It is adopted — and the policy starts applying — on the next launch that opens it under --context. mur conversation rm is what reaches an abandoned one.

Lifecycle

The lifecycle: block controls how long a capsule runs and how many tasks it accepts. Omitting it is equivalent to:

lifecycle:
  task_acceptance: single
  after_task: exit
  queue_depth: 1
  conversation: stateless
  max_task_reopens: 1
  # shell_grace_secs: absent, so a shell command runs in the foreground until it exits
  delegation_deadline_secs: 600
  # keep_working: absent, so a task ends at its first turn end — see lifecycle.keep_working
  # done_check: absent, so a task ends when the model stops

lifecycle.task_acceptance

Value Behaviour
none Capsule runs the launch's own task — --task, or a task.md or input.txt in the workdir — if it has one, then exits. The door answers SendMessage with JSON-RPC error -32601, and SendStreamingMessage and SubscribeToTask with -32004 — see Agent Card: Errors.
single (default) Capsule runs one task — the launch's own, or else the first A2A task — then exits. A message while a task is queued or active, the launch's own task included, is answered with a task in TASK_STATE_REJECTED: task rejected: capsule is busy.
queue Capsule accepts up to queue_depth pending tasks simultaneously. Tasks are processed serially; new tasks are accepted as soon as the pending queue drops below queue_depth. Under after_task: exit, a task still queued when the session ends is never started: it ends TASK_STATE_REJECTED — see Tasks queued when the session ends.

lifecycle.after_task

Value Behaviour
exit (default) The session ends as soon as the task finishes, whether the task came from --task or over A2A. A launch that receives no task ends when the idle timeout fires. A task the door accepted that the session never started ends TASK_STATE_REJECTED — see Tasks queued when the session ends.
sleep The capsule loops back to wait for the next task. Only useful with task_acceptance: queue; with single it behaves like exit.

Tasks queued when the session ends

Every lifecycle except task_acceptance: queue with after_task: sleep stops taking work once its task is done. A task the door accepted before that point but the session never started is refused, not run: running it would contradict after_task: exit.

Task What it gets
Accepted, never started, when the session stops taking work TASK_STATE_REJECTED over GetTask, a terminal TASK_STATE_REJECTED status on the stream, and one task_rejected trace line. No task_start, task_end, hook dispatch or model request
Cancelled while still queued Stays TASK_STATE_CANCELED, with its task_canceled trace line. Never TASK_STATE_REJECTED
A message arriving after the session stops taking work Answered with a task in TASK_STATE_REJECTED at the door. Nothing is recorded in the trace

A refused task does not change the session's own outcome: session_end.exit_status, the on-session-end hook's exit_status and the mur run exit code are what the tasks that ran make them. To have queued work run, declare task_acceptance: queue with after_task: sleep.

lifecycle.input_timeout_secs

Controls how long the capsule waits for a SendMessage reply after a WASM tool component calls murmur:task/task#request-input, or after a driver attaches safety checks to a tool call. A safety check that times out leaves its call unrun.

Value Behaviour
absent (default) Wait indefinitely — the task stays in TASK_STATE_INPUT_REQUIRED until a reply arrives, the task is cancelled, or the process is killed.
N (positive integer) If no SendMessage arrives within N seconds, the tool call fails and the attempt ends. The task ends TASK_STATE_FAILED, and its final status carries message input-timeout, unless an on-task-end hook reopens it.

Example — require a reply within 5 minutes:

lifecycle:
  task_acceptance: single
  input_timeout_secs: 300

See request-input WIT import and Pause the agent loop for human input.

lifecycle.shell_grace_secs

How long a shell command runs in the foreground before the runtime moves it to the background. Every command starts in the foreground.

Value Behaviour
not set (default) A command runs in the foreground until it exits, and its output returns to the turn. The background-command tool is not offered.
N (positive integer) A command exiting within N seconds returns its output to the turn. One that runs longer moves to the background.
0 The first check after the spawn moves the command to the background, unless it has already exited by then.

Whatever the value, a command still in the foreground also moves to the background when its task is canceled, or when a transport: process harness stops waiting for the call.

A backgrounded command hands the turn a work id of the form wrk_<hex> and the fact that it is still running. The work id is valid for the rest of the session. With shell_grace_secs set, the runtime-provided background-command tool takes it and reports the command's state and output so far, waits for it to exit, or stops it.

From the moment it moves to the background, the command's stdout and stderr are written to logs/<work_id>.log in the session's records directory as they are read:

Command: <binary> <arguments>

<output: what was read before the move, stdout then stderr, then both streams in the order read>

Exit code: <n>

The Exit code: line is appended when the command exits. A foreground command whose output passed 16 KiB writes logs/shell-<ms>.log instead, once, with Stdout: and Stderr: sections.

How the exit reaches the agent:

Lifecycle Delivery
shell_grace_secs set background-command returns the exit code, status and the last 16 KiB of output once the command has exited
task_acceptance: queue with after_task: sleep Also a task with origin completion in the bg lane, carrying the work id, the exit code and the log's path. Not enqueued when background-command already returned the exit

A non-zero exit, a signal kill and a resource limit are told apart by the status field on the command's shell_completed record. It is written once, when the exit reaches the agent by either delivery, and its delivered_via names which.

A task that ends while background commands are still running names them in its A2A result, as metadata.murmur.runningCommands, and in the running_commands field of its task_end record. The session's end kills them.

When a command exits while something it started holds its output

Each shell command starts as the leader of its own process group, with /dev/null as its stdin. A process it starts in the background with & stays in that group and, unless its output is redirected, holds the call's stdout and stderr open after the command itself has exited. Once the command's own process exits, the call:

  1. Waits up to 1 second for its stdout and stderr to close, and never past the end of a lifecycle.shell_grace_secs window. Output a background process prints and exits within that second is part of the result.
  2. Kills every process still in the command's process group with SIGKILL, if the output is still open, and returns what was read, including what the dying processes wrote.
  3. Stops waiting 2 seconds after the kill if the output is still open, and returns what was read by then.

A command that moved to the background is finished the same way when it exits, so its exit is recorded even when it left processes holding its output.

When step 2 or 3 is reached, the model reads a note after the output saying what happened:

Holder Note
Processes in the command's process group They were still holding the output and were killed
A process that left the group, such as one started with setsid It still holds the output and keeps running until the session ends, which kills it

A process meant to keep running after its call returns redirects its output away from the call:

server >server.log 2>&1 &

It holds neither pipe, so the call returns as soon as the command exits and later calls in the same session can reach it. The session's end kills it with everything else the session started.

The kill in step 2 takes the whole process group. If one command starts a redirected server and also leaves another process holding its output, the server is killed with it, so redirect every background process a command starts.

When a session ends with a command still running

The session does not wait for it. It kills it, together with every other process the session started — a server a shell call put in the background with & included. The command's log keeps what it printed before the kill. One report goes to stderr and to logs/bootstrap.log in the session's records directory, naming every such command's work id, binary, command text and how long it had been running, and each command also gets a shell_abandoned line in trace.jsonl.

[capsule-runtime] 1 background shell command discarded at session end.

Session ses_01k4m2y7xq8f9v3n5r7t9w1c2e ended while 1 demoted shell command was unaccounted for. Every process the session started was killed when it ended; what is known about each command is below.

work_id: wrk_0199a3f1c2d47e8ab5c6d7e8f9a0b1c2
binary: /usr/bin/bash
command: sleep 45; echo done
state: killed at session end after 45012 ms
result: none — it was killed before it finished, so no task carries an exit code. What it printed before the kill is in /home/dev/.murmur/records/home/dev/proj/ses_01k4m2y7xq8f9v3n5r7t9w1c2e/logs/wrk_0199a3f1c2d47e8ab5c6d7e8f9a0b1c2.log.

A session's end kills every command it demoted and every process its shell calls started in the background. Declare lifecycle.task_acceptance: queue with lifecycle.after_task: sleep for a session that outlives its task and can be told the result instead.

A command that finishes during teardown, after the agent last read it, is reported with its exit code instead:

state: finished during teardown after 2014 ms
status: ok
exit_code: 0
output: /home/dev/.murmur/records/home/dev/proj/ses_01k4m2y7xq8f9v3n5r7t9w1c2e/logs/wrk_0199a3f1c2d47e8ab5c6d7e8f9a0b1c2.log (91 bytes)
result: on disk — it finished too late for any task to carry it back to the agent.

status is ok only for a clean, zero exit with nothing attributed against it. A resource_limit line names the ceiling a command was stopped against, and a wait_error line names a failure in waiting for the command rather than in the command itself; either explains a status: error that the exit code alone does not.

What a backgrounded command costs when the host dies

Every way a session ends — its task finishing, SIGTERM, a spawner or formation ending it — kills the commands it moved to the background. A runtime that is itself killed — SIGKILL or an out-of-memory kill — takes them with it on Linux: the session's cgroup scope has a watcher process that kills everything in it once the runtime is gone (see Network reach limits for when they outlive it). Either way, no exit code survives: logs/<work_id>.log keeps the output written before the kill, with no Exit code: line. The work is a full run's compute spent for nothing, so lifecycle.shell_grace_secs is also a decision about how much compute one lost host can waste.

The record of the demotion does survive. The shell_detached line is written and flushed at the moment the command moves to the background, so a SIGKILL or a process crash keeps it, and mur run --resume <session> reports every such command that has no matching completion as a loss — once, naming the work id, the binary, the command and when it was detached. A host power loss is the case that does not hold: the line is flushed, not synced to the disk, so the last few seconds of the file can go with the machine.

A demotion whose own record cannot be written does not fail the command or the session. The command runs, the turn keeps its handle, and the failure is announced on stderr — with the consequence that a later resume has nothing to find and that command's loss is never reported.

lifecycle.delegation_deadline_secs

The single bound on work a capsule hands off: delegations and call-member calls. The capsule's own runtime holds the clock; no daemon has to be reachable for the deadline to fire.

It covers every wait on handed-off work, one per caller:

Caller What the deadline bounds
delegate-task How long the started sub-capsule is watched. On expiry the sub-capsule is ended and the delegating task continues with a terminated outcome
A plan's capsule step How long the step waits for the sub-capsule's answer. On expiry the step fails and the sub-capsule is stopped
call-member How long a call may spend offering a busy formation member the task and waiting for its answer, together. On expiry the calling task gets a timed_out answer from a member that held the task, which is not stopped, or a rejected answer from one that stayed busy
Value Behaviour
600 (default) A sub-capsule has 10 minutes from the moment it reports itself ready. A call-member call has 10 minutes from the call
N (positive integer) The same, with an N-second window
0 For a plan step, the first poll after the task is delivered gives up. A call-member call times out before its first read of the member's task, and a busy member is never offered the task again

A capsule that does not declare it hands off work under the 600-second default. The MURMUR_DELEGATION_TIMEOUT_SECS environment variable sets the same bound for a whole process when it names a positive integer; the declared value applies otherwise, and 600 when neither is given. Getting the sub-capsule started is bounded separately — see Bounds — so a slow host does not spend this window on staging.

A sub-capsule also runs under its parent's own deadline, as its session deadline. Whichever of the two falls first ends the sub-capsule.

Reaching the deadline ends the sub-capsule. Under delegate-task the delegating task, which waits for every sub-capsule it started on any lifecycle, continues with an outcome carrying the delegation's dlg_ id, status: terminated and a detail naming the bound in seconds — the same shape every delegation outcome takes, described in How the outcome arrives. A task that has heard nothing 30 seconds past the deadline ends the sub-capsule itself.

lifecycle:
  delegation_deadline_secs: 120

lifecycle.keep_working

Keeps a task's agent working past the point where the model ends its turn. Each time an attempt ends at a turn end — the model ended its turn with no tool call and nothing it handed off is outstanding — the runtime sends message as the next user message in the same conversation and the task goes on. Absent, a task ends at its first turn end.

lifecycle:
  keep_working:
    message: "Keep working on the task. Check your work against what was asked."
    limit: 20
Field Rule
message Required. Sent verbatim and unfenced. Must not be blank after trimming; the text is sent as written, surrounding whitespace included.
limit Required. At least 1. To turn the option off, omit the block.

A missing or blank message, a missing limit and limit: 0 are refused with E-MAN-003, naming the field: murmur.yaml: invalid lifecycle config for 'lifecycle.keep_working.limit': must be at least 1; omit lifecycle.keep_working to turn the option off.

The model is told nothing about the option beyond message itself and the keep_working field of session-status, so write message to stand on its own. Every continuation resends the whole context, so cost rises with each one. What stops the task, what it ends with, and how the deadline treats it are in Keeping a task working.

lifecycle.done_check

Says what "done" means for a task. Each time the agent stops — the model ended its turn with no tool call, nothing it handed off is outstanding, and lifecycle.keep_working has stopped — the runtime runs command in the work folder. Exit 0 ends the task. Any other result reopens the task with the command's output, until limit reopens are spent. Absent, a task ends when the model stops.

capabilities:
  shell:
    allow: [bash, cargo]
lifecycle:
  done_check:
    command: "cargo test --quiet"
    limit: 3
    timeout_secs: 600
Field Rule
command Required. Must not be blank. Split into words as the shell tool splits a command: whitespace separates words, quotes group them, a backslash escapes the next character, and nothing is expanded. The first word is the program, and must be listed in capabilities.shell.allow. Write bash -c '…' for pipelines and &&
limit Required. How many times one task may be reopened by a failing run, counted across hook reopens. 0 runs and records the check at each stop without reopening
timeout_secs Default 600, at least 1. A run still going then is killed with its process group and counts as a failed run

Each of these is refused with E-MAN-003, naming the field:

Field Message
lifecycle.done_check.command is required, must not be blank, must name a program, or runs '<program>', which is not in capabilities.shell.allow
lifecycle.done_check.limit is required
lifecycle.done_check.timeout_secs must be at least 1

limit is separate from lifecycle.max_task_reopens: a check reopen does not spend a hook's budget, and max_task_reopens: 0 does not turn the check off. A task that reaches limit with the check still failing ends ok with its last answer, and task_end.done_check.met is false. The order at a stop, what each budget does to the check, and the feedback a reopened attempt receives are in Done check.

lifecycle.conversation

Controls whether a task starts from the conversation its contextId already has. It governs what a task loads, never what is recorded: both modes append every message to the conversation record, which context.record is what turns off.

Value Behaviour
stateless (default) Every task starts with an empty message history. The contextId on the incoming message is recorded but has no effect on context.
threaded A task that arrives with a contextId starts from the whole conversation record for that context, including messages an earlier session wrote. Each completed task also writes a per-task result to out/result_<taskId>.txt in the session's records directory, alongside the shared out/result.txt.

Under transport: process the harness holds the conversation, so the setting decides what the harness is handed rather than what the runtime loads: stateless launches it on a new session every task, and threaded launches it on the session the harness session map already holds for that context.

This setting is the capsule's own policy, and mur run --resume <session> overrides it for one launch: that launch loads the record even under stateless.

A conversation outlives the session that started it. Threaded tasks reaching one capsule over A2A need a long-running capsule (task_acceptance: queue, after_task: sleep), since with task_acceptance: single the capsule exits after the first task; two separate mur run --context <id> launches continue one conversation without it.

lifecycle:
  task_acceptance: queue
  after_task: sleep
  queue_depth: 2
  conversation: threaded

Idle timeout

How long a capsule waits for the next A2A message depends on the lifecycle it declares:

Lifecycle Behaviour when no message arrives
task_acceptance: queue with after_task: sleep Waits indefinitely. Shutdown is the host's responsibility.
Every other combination Waits 30 seconds for a task when the launch was given none — no --task, and no task.md or input.txt in the work folder when it started. If none arrives, the session ends without calling the model.

Set the MURMUR_A2A_TIMEOUT_SECS environment variable to change the 30-second window.

CLI overrides

mur run accepts two flags that override the manifest's lifecycle without editing the file:

mur run --manifest murmur.yaml --lifecycle-task-acceptance queue --lifecycle-after-task sleep

Values follow the same snake_case names as the manifest fields.