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 Lowercase letters, digits and inner hyphens ([a-z0-9-], no leading or trailing -), non-empty, at most 100 characters. Anything else fails the build with E-BLD-001.
version string yes latest, stable and edge are reserved and cannot be published (E-REG-004).
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 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.

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

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).

Supported shape

name: my-capsule
version: "0.1.0"

artifacts:
  - name: some-tool
    version: "1.2.3"
    runtime: tool  # optional, defaults to tool
  - name: murmur-hook-debug
    version: "1.0.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 inference
    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 guest (capsule/tool/driver) may observe
      - MY_APP_REGION

network:
  internal_port: 14159  # optional; omit to let the OS assign a free port

context:
  max_tokens: 200000   # enables context compaction; omit to disable
  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 directory forever
    max_sessions: 50  # keep the newest N session directories, this launch's own included
    max_age: 14d      # and/or drop anything whose ses_ id was minted longer ago than this

inference:
  transport: http
  endpoint: https://api.anthropic.com
  model: claude-opus-4-5
  api_key: ${ANTHROPIC_API_KEY}  # optional; literal value or ${ENV_VAR}
  driver:
    artifact: murmur-driver-anthropic
    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
  # max_turns: 10               # optional; default 10

# Alternative: spawn a CLI as a subprocess (no ANTHROPIC_API_KEY required)
# inference:
#   transport: process
#   command: claude              # CLI binary name; must be on PATH
#   model: claude-haiku-4-5-20251001
#   max_turns: 10

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 accessible workdir
    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 accessible workdir
    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

Field reference

Identity

Field Type Required Notes
name string yes Capsule identity.
version string yes Capsule version.
mur_version string no Pins the mur runtime version this capsule requires — see mur_version.

mur_version

Pins the exact version of the mur binary that must run this capsule.

mur_version: "1.0.0"
Command Behaviour
mur deploy run Downloads mur-{version}-{platform} from GitHub releases and installs it on the target VM, regardless of which mur version is running locally. The binary is cached at ~/.murmur/bin/mur-{version}-{platform} and reused on subsequent deploys. Omitted, the running mur binary's version is used.
mur run Prints a warning to stderr when the running mur version does not match. The run continues.

artifacts

Field Type Required Notes
artifacts list no Defaults to empty.
artifacts[].name string yes (per entry) Artifact name.
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. 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 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[].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: murmur-hook-observe
    version: 0.1.0
    runtime: hook

  # granted exactly one host and exactly one directory
  - name: murmur-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: murmur-hook-output-gate
    version: 0.1.0
    runtime: hook
    capabilities:
      task_io:
        read: true

  # granted the capsule's conversation record, and nothing else
  - name: murmur-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: murmur-hook-notes
    version: 0.1.0
    runtime: hook
    capabilities:
      state: {}
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.

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.
  • state is a second, independent directory grant. A hook holding one reaches ~/.murmur/state/<store>/ as state/ in its guest, 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 session workdir. 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 and conversation govern a hook. shell, spawn, env, limits, 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: broad-tool
    version: 0.1.0
    runtime: tool

  # narrowed: one host, and only the `cache/` subtree of the workdir
  - name: 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. <accessible workdir>/<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 preopen, ~/.murmur/state/<store>/, mounted in the guest 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, as does a grant on a tool with a native (non-WASM) implementation, which never runs through the WASI tool path at all.
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 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 no config. A runtime: tool entry whose artifact ships a native (non-WASM) implementation runs as a host subprocess, so a config: block there delivers nothing and prints W-SEC-015. The launch continues.
  • 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. Pass credentials with a ${VAR} reference, which resolves from the environment at launch and leaves only the variable name in the manifest — see inference.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.

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 directory containing skill.md (any casing)
  - 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:

  • 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 directory. For a directory, the runtime finds skill.md case-insensitively (SKILL.md, Skill.md, … all match) and installs it as lowercase skill.md. A file path is read directly regardless of its name.
  • Everything downstream is identical to a registry skill: the file is installed to workdir/tools/<name>/skill.md and 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

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: 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.
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 session workdir 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). 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 guest (capsule, tool or driver component) 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. Must be > 0.
capabilities.resources.max_processes integer no RLIMIT_NPROC headroom for each native subprocess — how much past the runtime's own uid baseline its tree may add, in the unit the kernel enforces against (threads on Linux, processes on macOS). Default: 128. Must be > 0. See the per-uid note.
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 RLIMIT_CPU hard ceiling on each native subprocess, in CPU-seconds. Default: 3600 (1 hour). 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. Default: 4294967296 (4 GiB). Must be > 0. Linux only.
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: 200. 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 the other three controllers.
capabilities.resources.workdir_max_bytes integer no Ceiling on total session-workdir size, in bytes, enforced by a periodic check. Default: 10737418240 (10 GiB). Must be > 0. Every platform. Under the sealed containment class this also bounds /tmp, which is backed by a directory inside the session workdir — see the fixed capsule device set.
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 preopen exists — 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, and the runtime's guidance on when to plan in its system prompt; a capsule that declares nothing is offered neither. 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.spawn.allow list no Capsule names this capsule may spawn as sub-capsules. 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 these names 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.

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 guest 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 guest, reported once by W-SEC-024, which lists the full rule
Anything else Reaches every guest

The field governs WASM guests 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.

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.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.

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 spawns a CLI subprocess. See Inference configuration.
inference.max_turns integer no Maximum LLM inference calls per task. Default: 10. Must be > 0.
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 Name of an artifact declared in artifacts: whose payload is bound as the system prompt — see inference.system_prompt_artifact.

These fields are read under transport: http, and setting any of them under transport: process is a manifest error:

Field Type Required Notes
inference.endpoint string yes Base URL for inference API requests. Must be https:// (any host) or http:// with a loopback host — see Endpoint scheme and host validation.
inference.model string yes Model identifier passed to the driver.
inference.driver.artifact string yes Inference driver artifact name; must be declared in artifacts: with runtime: driver.
inference.driver.config object no 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. A value that is not a mapping fails the manifest parse with E-MAN-003. See Choosing a config block.
inference.provider.artifact string no Accepted older spelling of inference.driver.artifact; inference.driver.artifact wins when both are set.
inference.api_key string no Literal value or ${ENV_VAR} reference — see inference.api_key resolution.
inference.max_tokens integer no Maximum output tokens the model may generate per turn. Default: 8192. Must be > 0; not clamped at the top end. Distinct from context.max_tokens — see Output cap.
inference.max_session_tokens integer no Most tokens this session's driver calls may use in total, as the runtime measures them. No default: absent sets no session ceiling. Must be > 0. See Session spend ceiling.

These fields are read under transport: process:

Field Type Required Notes
inference.command string yes CLI binary to spawn; must be on PATH. A command whose base name is codex selects the codex wire protocol; anything else selects the Claude Code protocol. Invalid for transport: http.
inference.model string no Model identifier passed to the CLI. Omitted, the CLI uses its own configured default.

The inference.compaction block parses under either transport but takes effect only under transport: http; the CLI subprocess loop has no compaction step, so under transport: process these fields are accepted and inert:

Field Type Required Notes
inference.compaction.threshold float (0.0–1.0] no Fraction of context.max_tokens at which compaction fires. Default: 0.98.
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 workdir, recording the verbatim summary text plus token counts.

context

Field Type Required Notes
context.max_tokens integer no Token budget for the session. Required to enable compaction; omit to disable it. Must be > 0. 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 context.max_tokens 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. Requires context.max_tokens: without it 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 context.max_tokens 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. Inert under transport: process, which writes no record either way.
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.

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.include_tool_output bool no Retired; use trace.capture. Accepted as an alias — true for capture: content, false for capture: meta — and its use prints a warning. Setting it alongside trace.capture is an error, even when the two agree.
trace.retain block no What bounds the session directories beside the running one. Omitted, nothing is ever deleted. See Retention.
trace.retain.max_sessions integer ≥ 1 no Session directories to keep, counting the running session itself. The rest are removed whole, taking their trace.jsonl and blobs/ with them.
trace.retain.max_age duration no Age beyond which a session directory is removed, measured from the millisecond timestamp inside its own uuid-v7 ses_ id. No file metadata is read.
trace.capture inference content hashes blobs/ tool_call.output
none
meta written
content written written written

The hashes are system_sha, tools_sha, response_sha and message_shas on each inference 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 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. Tasks beyond this limit receive state: "rejected".
lifecycle.input_timeout_secs integer no Maximum seconds to wait for a message/send reply after a tool component calls request-input. 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: 10. 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 — see lifecycle.delegation_deadline_secs.

exports

Opens read-only views onto parts of the accessible workdir, served over the capsule's HTTP listener without an inference turn — see Resource plane. The two blocks are separate authorisers over separate subtrees: declaring one grants nothing about the other, and a capsule may declare either, both or neither.

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.files.root string yes Subtree of the accessible workdir 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 accessible workdir 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.


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:
  endpoint: https://api.anthropic.com
  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: 1.0.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; a driver that is named but not installed fails with E-RUN-006.

inference:
  transport: http
  endpoint: https://api.anthropic.com
  model: claude-opus-4-5
  api_key: ${ANTHROPIC_API_KEY}
  max_tokens: 4096        # optional per-turn output cap; default 8192
  max_session_tokens: 2000000   # optional session spend ceiling; default none
  driver:
    artifact: murmur-driver-anthropic

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. Omit it and the runtime sends 8192. 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 session-wide token budget that decides when compaction fires, counted across the whole conversation; inference.max_tokens bounds a single response. They are parsed and validated independently and never share a default.

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.

Two interactions worth knowing:

  • The anthropic driver caps extended thinking's budget_tokens to max_tokens - 1, so lowering this value also squeezes a configured thinking budget.
  • Hook-initiated completions, such as the compaction hook's own summarization call, always use the built-in 8192 default. This field caps the agent's own responses, not the runtime's internal calls.
When a turn hits the cap

A turn the provider stopped at the cap still produces a result: the session ends ok, the runtime attempts no continuation turn, and every surface carrying the reply marks it as a fragment. W-RUN-001 lists what the turn 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 it counts is the runtime's own measurement, input_tokens + output_tokens, the same numbers the trace's inference lines carry, so summing those lines checks the ceiling.

Before each driver call the runtime admits the call only if these three fit under the ceiling together:

Part Value
Used Every earlier call's input_tokens + output_tokens, plus what calls still in flight reserved
The call's input input_tokens of the request about to be sent
The call's most output inference.max_tokens (default 8192) for an agent turn; the built-in 8192 for a hook's completion

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 stopped: spend ceiling reached: …, ending with retrying will not get past it
A2A terminal status 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"

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 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, which drives compaction
spend.machine_tokens_per_day Tokens across every run on the machine per UTC day

transport: http only: under transport: process the CLI reaches its provider with its own credentials, murmur sees no spend, and the field is a manifest error. A delegated child is bounded by its own inference.max_session_tokens and by the machine ceiling, not by its parent's session ceiling.

Endpoint scheme and host validation

inference.endpoint is validated when the manifest is parsed, before any capsule launches or any network call is made.

Value Result
Any https:// URL, any host (https://api.anthropic.com) Accepted
http:// with host localhost, or a loopback IP literal (http://127.0.0.1:11434, http://[::1]) Accepted — this covers local model servers such as Ollama
http:// with a non-loopback host (http://api.attacker.example.com) Rejected
A schemeless or malformed value (api.anthropic.com, "not a url") Rejected
Any scheme other than http/https (ftp://example.com) Rejected

Each rejection names the endpoint and the reason. The check runs only for transport: http, which is the only transport that accepts endpoint at all.

transport: process — CLI subprocess

Murmur spawns inference.command as a subprocess and communicates over stdin/stdout. No ANTHROPIC_API_KEY is required — authentication uses whatever the CLI is already configured with. Credentials under this transport are the CLI's own, and murmur neither holds nor controls them. The base name of command selects the wire protocol: codex speaks the codex-exec dialect, anything else speaks the Claude Code dialect. No WASM driver artifact is needed or staged.

inference:
  transport: process
  command: claude        # must be on PATH
  model: claude-haiku-4-5-20251001
  max_turns: 10
Behaviour Details
Pre-flight check mur run verifies command is on PATH before staging the session. If it is not found the run exits with E-RUN-006 and a hint naming that CLI's install page.
Tool dispatch When the capsule declares tool artifacts, murmur stands up a loopback MCP server and points the CLI at it, so the model calls the capsule's own tools and murmur executes them. The CLI's built-in host tools are disabled. With no tools declared, the CLI runs as pure inference.
Turn limit Each assistant response counts as one turn, bounded by inference.max_turns.
Wall-clock limit One subprocess run — all turns and tool calls — is capped at 10 minutes.
Result The CLI's final result text is written to out/result.txt.
Observability Session, inference and tool hooks, trace.jsonl and OTel spans are all emitted normally. Token counts are reported as 0, which the subprocess protocol does not carry.
Compaction Does not run. context.max_tokens and inference.compaction parse but are inert under this transport; the CLI manages its own context.
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".

inference.api_key resolution

api_key accepts two forms:

  • Credential reference: api_key: ${ANTHROPIC_API_KEY} names a credential
  • 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 next inference 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 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: section.

The runtime keeps the key, and the driver never receives it. On each request the driver sends, the runtime attaches the header the driver's own inference_auth: block declares and sends the request to inference.endpoint itself. A driver without that block refuses to start with E-RUN-025. Without api_key, requests go out with no credential header.

The provider does not need to be in capabilities.network.allow. An entry naming it is accepted with W-SEC-025, and grants direct reach to that host without the key.

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 session workdir.
  • 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.

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: "1.0.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 workdir/logs/hook-<name>.log. 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 when the manifest sets it, otherwise 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.

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: "1.0.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 transport: http capsule's inference requests do not consult this list, so the provider need not appear in it; see inference.api_key resolution.

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 preopen 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 session workdir 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) — paths are not supported.
  • A capabilities.shell block present with an empty allow list is rejected at parse time.
  • A synthetic tool manifest is written to workdir/tools/<binary>/murmur.yaml 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 opens with the command line that ran, then the exit code and the two output streams:
$ ls -d .
Exit code: 0
Stdout:
.

Stderr:

The name on the $ line is the allowlist entry, so an interpreter's line shows the shell line on its own ($ echo hi).


Retention

Two stores grow as a capsule runs: the session directories under the workdir, and the conversation records under ~/.murmur/conversations/. trace.retain bounds the first, context.retain the second. Both blocks are enforced at launch, by the runtime, and every deletion is written to the running session's trace as a retention event.

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

Rule Effect
The block is omitted Nothing is deleted. Both stores grow without bound, as they always have.
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 or record 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 directory, whole
trace.retain.max_age The uuid-v7 timestamp inside the ses_ id One session directory, 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 directory is an independent unit — nothing references it and nothing spans two of them — so it is removed whole, taking its trace.jsonl and its blobs/ with it. 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 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: 10
  delegation_deadline_secs: 600

lifecycle.task_acceptance

Value Behaviour
none Capsule runs from task.md if present, then exits. All incoming messages return JSON-RPC error -32601.
single (default) Capsule accepts one A2A task, runs it, then exits. A second message while a task is active returns state: "rejected".
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.

lifecycle.after_task

Value Behaviour
exit (default) The capsule exits immediately after the task finishes, or after the idle timeout fires.
sleep The capsule loops back to wait for the next task. Only useful with task_acceptance: queue; with single it behaves like exit.

lifecycle.input_timeout_secs

Controls how long the capsule waits for a message/send reply after a WASM tool component calls murmur:task/task#request-input.

Value Behaviour
absent (default) Wait indefinitely — the task stays in input-required state until a reply arrives or the process is killed.
N (positive integer) If no message/send arrives within N seconds, the task transitions to failed with status message "input-timeout". SSE clients receive a final TaskStatusUpdateEvent with "final":true.

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 and the clock decides.

Value Behaviour
10 (default) A command exiting within 10 seconds returns its output to the turn. One that runs longer moves to the background.
N (positive integer) The same, with an N-second window.
0 The first check after the spawn moves the command to the background, unless it has already exited by then.

A backgrounded command hands the turn a handle of the form wrk_<id> and the fact that it is still running. Its full stdout and stderr are written to logs/<work_id>.log under the capsule workdir, and stay there — the turn is told the path, never the contents.

Nothing accepts a work id, so the handle cannot be used to ask after the command. The result arrives on its own, as a task with origin completion in the bg lane, carrying the exit code and the output path. A non-zero exit, a signal kill and a resource limit all arrive the same way, told apart by the status field on the command's shell_completed record.

That completion reaches only a capsule that outlives the task which started the command. Under the default after_task: exit the session ends when the task does, and nothing arrives; a capsule that runs shell commands and leaves its lifecycle block that way is warned at launch with W-SEC-022. A capsule that means to receive completions declares task_acceptance: queue and after_task: sleep:

lifecycle:
  task_acceptance: queue
  after_task: sleep
  shell_grace_secs: 2

When a session ends with a command still running

The session does not wait and does not kill: the command keeps running, detached, and the runtime stops owning its result. The discard is stated rather than left silent. One report goes to stderr and to logs/bootstrap.log under the capsule workdir, naming every discarded 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. Nothing was waited for and nothing was killed; what is known about each is below.

work_id: wrk_0199a3f1c2d47e8ab5c6d7e8f9a0b1c2
binary: /usr/bin/bash
command: sleep 45; echo done
state: still running after 45012 ms
result: none — no exit code, and no logs/wrk_0199a3f1c2d47e8ab5c6d7e8f9a0b1c2.log will be written, because the thread that writes it ended with this session.

A command still running keeps running, detached from this session, with nothing reading its output. 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 — too late for any task to carry its result, but with its exit code and logs/<work_id>.log already written — is reported with that better information instead:

state: finished during teardown after 2014 ms
status: ok
exit_code: 0
output: logs/wrk_0199a3f1c2d47e8ab5c6d7e8f9a0b1c2.log (91 bytes, in the capsule workdir)
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

A backgrounded command outlives the runtime that started it. If the runtime is killed — SIGKILL, an out-of-memory kill, a host that goes away — the command keeps running with nothing reading its output, and nothing of its result survives: no exit code, no logs/<work_id>.log, no record of whether it finished. 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 a delegation. The capsule's own runtime holds the clock; no daemon has to be reachable for the deadline to fire.

It covers the two waits a delegation has, 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 a terminated outcome is posted to the delegating capsule
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
Value Behaviour
600 (default) A sub-capsule has 10 minutes from the moment it reports itself ready
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

Absent is a ceiling, not an absence: a capsule that never declares this one still delegates under 600 seconds, because an unbounded delegation leaves a wedged sub-capsule running for as long as the capsule that started it. 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.

Reaching the deadline ends the sub-capsule. Under delegate-task the delegating capsule is told, in a task of its own: a completion-origin task in the bg lane carrying the delegation's dlg_ id, status: terminated and a detail naming the bound in seconds. That task is the same shape every delegation outcome takes — see The completion path.

An outcome reaches only a capsule that outlives the task which made the delegation. Under the default after_task: exit the session ends when the task does, and nothing arrives; a capsule that delegates and leaves its lifecycle block that way is warned at launch with W-SEC-020. A capsule that means to receive outcomes declares:

lifecycle:
  task_acceptance: queue
  queue_depth: 4
  after_task: sleep
  delegation_deadline_secs: 120

An outcome that arrives when the delegating capsule's task loop has already ended is recorded rather than dropped: the sub-capsule's own completion.json is written with delivered: false and the reason, and a line goes to stderr.

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 workdir/out/result_<taskId>.txt alongside the shared workdir/out/result.txt.

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, then runs the agent loop once with an empty task and exits.

The 30-second window is set by the MURMUR_A2A_TIMEOUT_SECS environment variable, which tests use to keep wait times short.

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.