Skip to content

WIT Interfaces

WASM artifacts — capsules, tools, drivers, and hooks — talk to the runtime through WIT interfaces. This page lists each interface, who uses it, and what its behaviour means for you. The type definitions themselves live in the repository under crates/capsule-runtime/wit/; each row below links to its source, and Package versioning lists the version each one carries.

Interfaces

Interface Used by Purpose
murmur:tool/run Exported by tool and driver components The entrypoint the runtime calls to run a tool or an inference driver
murmur:tool-registry/invoke Imported by capsule components Call an allowlisted tool by name
murmur:capsule/run Exported by capsule components The capsule entrypoint
murmur:artifact-manager/manage Provided by the runtime to capsule components List, describe, search, pull and remove artifacts during a session
murmur:shell/execute Implemented natively by the runtime Run an allowlisted shell binary
murmur:message/send Provided by the runtime to capsule components Send an A2A task to a peer capsule
murmur:task/task Imported by tool components Pause the agent loop and wait for external input
murmur:stream/events Imported by tool and driver components Tell the session's stream what a turn is doing while it is still in flight: reply and thinking chunks, and a tool call being written
murmur:hook/lifecycle Exported by hook artifacts The lifecycle handlers the runtime calls
murmur:runtime/inference Provided by the runtime to hook components Run one LLM completion through the capsule's configured driver
murmur:runtime/tokens Provided by the runtime to hook components Count the tokens in a string the way the runtime counts them
murmur:task-io/read Provided by the runtime to hook components Read the task's input text and the agent's result text
murmur:conversation/read Provided by the runtime to hook components Read the capsule's durable conversation record
murmur:driver/process Exported by process drivers Describe the CLI a transport: process capsule drives, plan each turn and read the CLI's output

Worlds

A world is what your component's source compiles against with wit_bindgen::generate!.

World Imports Exports Defined in
capsule tool-registry/invoke capsule/run guest/worlds.wit
tool task/task, stream/events tool/run guest/worlds.wit
driver stream/events tool/run guest/worlds.wit
hook runtime/inference, runtime/tokens, task-io/read, conversation/read hook/lifecycle hook/worlds.wit
runtime-host artifact-manager/manage, shell/execute, tool-registry/invoke, murmur:message/send — host/host.wit
process-driver — driver/process process-driver/process.wit

Agent capsules compile against no world — the agent loop runs inside the runtime, and the capsule is defined by its manifest alone.


murmur:tool/run

A tool-result carries a metadata list — a free-form key/value channel back to the runtime. These keys are reserved:

Key Value Effect
continuation_id Opaque id string Tells the runtime the driver is holding conversation state provider-side. See Stateful driver continuation.
state_effect "read" or "mutate" How this call affected the resource it addressed. Recorded on the call's tool_call trace event.
resource_id Opaque, tool-defined string Which resource this call addressed. Recorded verbatim on the tool_call trace event.
transient "true" or "false" Read on an inference driver's failed call. "true" marks the failure transient, so the runtime makes the call again; "false" vetoes a retry of a status the runtime would otherwise retry. Any other value counts as absent. See Retrying transient failures.

state_effect and resource_id together let mur trace spot redundant calls without knowing anything about your tool. Omit either and the call counts as "unknown": it is never credited as a redundant read.

A driver sets transient: "true" on a failure the provider's status cannot show: a stream that ended without a stop reason or with a provider error event after the 200. It sets transient: "false" on a status that is retryable in general but permanent here, such as a 429 the provider reports as an exhausted quota. With neither, the runtime decides from what its credential gateway saw: the status, or a connection that broke before a response arrived. If the list carries more than one transient tuple, the first in list order counts.

For resource_id, scope the value by addressing scheme (for example "sym:Foo::bar") so it cannot collide with an unrelated resource of the same name. Two calls address the same resource when their resource_id values are byte-equal. Without one, the redundancy detector guesses from path-like input fields (path, file, file_path, filepath, filename) — so declare the key if your resource is a symbol, URI, or query.

Returning a large result

A tool with more output than it wants to carry in data writes the output to a file and returns the filename in data-path. Name that file relative to the work folder, the directory the tool's own . resolves to. When the runtime reads the file on the tool's behalf, it resolves the name beneath that directory and refuses anything that leaves it:

data-path names Outcome
A regular file under the work folder Its contents become the result
An absolute path, or one climbing out with .. The call fails
A path passing through a symlink, wherever the symlink points The call fails
A file that does not exist The call fails
A file over 10 MiB The call fails
A path holding % escapes Decoded before it is resolved, so %2e%2e fails like ..

A result carrying data uses it verbatim and never opens data-path.

Returning an image

A tool returns an image by writing the file into the work folder and naming it in the result's attachments list, one attachment { media-type, path } per image, in the order the model should see them. An inference driver returns an empty list.

The runtime checks each attachment before the model sees it:

Check Refusal code
media-type is image/png, image/jpeg, image/gif or image/webp media_type_not_allowed
path and media-type are both non-empty malformed
path is a regular file under the work folder, reached through no symlink not_found, outside_root, symlink_refused, not_a_regular_file, io_error
The file is at most context.images.max_bytes too_large
Its first bytes are those of media-type content_mismatch
This model turn's tool calls have returned fewer than context.images.max_per_turn images turn_limit
Its bytes could be stored store_failed

An accepted image is stored once, by its sha256, in the conversation's blobs/ directory and recorded as a reference: see The conversation record. A refused one adds one line to the result the model reads, after the tool's own output:

attachment 'note.png' was not attached: declared image/png but the bytes are not an image of any allowed type (content_mismatch)

A refusal never changes the call's status.

Under transport: process the runtime runs the same checks, with max_per_turn counted per tool call, and stores nothing. The tool server's answer is the result's text, with one line per attachment after the tool's own output, then one MCP image item per accepted image, in order:

Attachment Line in the text Item after the text
Accepted [image attached: <path>, <media type>, <n> bytes] {"type": "image", "data": <base64>, "mimeType": <media type>}
Refused The refusal line above None

Whether the model sees the image is up to the harness.

Native tool result

A native tool reads one JSON object on stdin and writes one on stdout. The stdout object carries the same fields as a tool-result:

Field Type Notes
status "passed", "failed", other Any other value, or none, is error
summary string Optional. The result text when data is absent
data string or any JSON value Optional. A non-string value is passed on serialized
attachments array of {"media_type": <string>, "path": <string>} Optional. The images the call returns, checked as Returning an image describes. A single object in place of the array is read as a one-entry list. An entry that is not an object of two strings, and any other value that is not an array, is refused malformed
{"status": "passed", "summary": "took a screenshot", "data": "saved shot.png",
 "attachments": [{"media_type": "image/png", "path": "shot.png"}]}

Stdout that is not JSON becomes the result text as it is, and an empty stdout reports the exit status.

Stateful driver continuation

By default the runtime resends the full conversation history on every turn. A driver holding conversation state provider-side can opt out by returning a non-empty continuation_id.

Situation What the runtime does
Driver returns a non-empty continuation_id Holds the id for the rest of the session loop
Next turn, same context_id Sends only the messages appended since the driver last acknowledged state, plus the held id
Next turn, different context_id Full resend — a continuation is never reused across unrelated tasks
Next turn under a different driver choice Full resend with no continuation_id — the id is held for the choice it was returned under, and presented again only under that choice
Driver omits the key or returns an empty string Drops the held id; the next turn is a full resend
A hook commits replace-context (for example compaction) Drops the held id; the next turn resends the post-compaction history

Token accounting for the compaction threshold is always computed from the full conversation, not the smaller payload actually sent, so continuation never changes when compaction fires.

Prompt cache routing hint

Every request the runtime hands a driver carries a top-level prompt_cache_key string. Its value is fixed for every turn of a task, and it does not change when history is compacted or when a held continuation_id is dropped:

Task prompt_cache_key
Has a context id, and the value fits in 64 characters <capsule-name>:<version>:<context-id>
Has no context id, and the value fits in 64 characters <capsule-name>:<version>
The value would run past 64 characters The leading characters of the same value, then : and a 16-character hex suffix

A provider that routes on the value keeps a task's turns on one machine, so each turn reaches the cache entry the previous turn warmed. Two runs of the same capsule get different values, because a context id is minted per task — including when the value was shortened.

Shortening keeps the value inside the 64 characters the OpenAI Chat and Responses APIs accept; a capsule name of ordinary length already runs past that once a context id is appended. A shortened value still opens with the capsule's own name, so a key seen in a payload dump names the capsule that produced it.

A driver reads the field only if it declares it, so a driver that ignores it runs unchanged. Forward it only where the provider defines a field of its own for it: the OpenAI Chat and Responses APIs accept prompt_cache_key in the request body, and the Anthropic Messages API rejects a body carrying any field it does not define.

The [Capsule] block the runtime prepends to every system prompt is a pure function of the manifest: the capsule's name and version, for a formation member its roster name, and the facts the manifest fixes — read-only paths, network allowlist, limits and the manifest file name. It names no host-specific path, session id or formation id, so it is byte-identical across every launch of the same manifest, and of the same roster member. Prefix caching depends on that stability: a provider matches its cache against the bytes it has seen before, and the first block of every prompt would otherwise never repeat.

Reported token usage

A driver response may carry a top-level usage object holding the provider's own token counts for that call:

{
  "stop_reason": "end_turn",
  "content": [{"type": "text", "text": "..."}],
  "usage": {
    "input_tokens": 12043,
    "output_tokens": 218,
    "cached_tokens": 11780,
    "cache_write_tokens": 0,
    "thinking_tokens": 96
  }
}
Member Value
input_tokens Request tokens neither read from nor written to the provider's prompt cache
output_tokens Tokens the provider billed for the completion
cached_tokens Request tokens served from the provider's prompt cache
cache_write_tokens Request tokens written into the provider's prompt cache
thinking_tokens The part of the completion the provider reports as reasoning — a subset of output_tokens, never an addition to it

Every member is optional and every member is a non-negative integer. A driver reports whichever members its provider returned; a provider with no prompt cache reports no cache members. Omit a member the provider did not report rather than sending 0 — the runtime keeps the two apart, and a 0 reads as a genuine cache miss on the trace.

Where each default-artifacts HTTP driver reads each number, by the provider surface it calls:

Member Anthropic Messages (driver-anthropic) OpenAI Chat Completions (driver-openai for o-series models and models before gpt-5, driver-moonshotai, driver-zai) OpenAI Responses (driver-openai, gpt-5 and later) DeepSeek (driver-deepseek)
input_tokens usage.input_tokens usage.prompt_tokens minus prompt_tokens_details.cached_tokens and prompt_tokens_details.cache_write_tokens; driver-zai subtracts cached_tokens alone usage.input_tokens minus input_tokens_details.cached_tokens and input_tokens_details.cache_write_tokens usage.prompt_cache_miss_tokens
output_tokens usage.output_tokens usage.completion_tokens usage.output_tokens usage.completion_tokens
cached_tokens usage.cache_read_input_tokens usage.prompt_tokens_details.cached_tokens usage.input_tokens_details.cached_tokens usage.prompt_cache_hit_tokens
cache_write_tokens usage.cache_creation_input_tokens usage.prompt_tokens_details.cache_write_tokens when sent; never from driver-zai usage.input_tokens_details.cache_write_tokens when sent Not reported
thinking_tokens Not reported usage.completion_tokens_details.reasoning_tokens from driver-zai only Not reported Not reported

A subtraction that would go below zero leaves input_tokens out.

The runtime records the reported numbers on the call's inference trace event and its capsule.inference span. A report that carries both input_tokens and output_tokens is also what the runtime acts on, as two numbers:

Number Sum
Request input_tokens + cached_tokens + cache_write_tokens, an absent cache member counted as 0
Completion output_tokens. thinking_tokens is part of it and is never added
Decision With a report Without one
Compaction threshold, checked after each agent-loop response Request plus completion The runtime's estimate of the request plus its count of the raw response
Spend charged for the call, agent loop and a hook's run-inference alike Request plus completion The runtime's own counts of the request and the response
Spend reserved before the call is sent The runtime's estimate The runtime's estimate

A usage missing input_tokens or output_tokens is recorded but steers nothing. A driver that folds its cache counts into input_tokens has them counted twice. See Observability schemas for the field names.

A malformed report degrades to no report rather than failing the call:

What the driver returns What the runtime records
No usage Nothing
A usage that is not an object Nothing
A member that is not a non-negative integer Nothing for that member; its well-formed siblings are still recorded
A member the runtime does not define Nothing for that member

An omitted number is absent from the trace event, never written as 0.

Provider tools

A tool artifact whose own murmur.yaml declares a provider_tool: block is one the model's provider defines itself. Its entry in the request's tools array carries the block as declared, beside the entry's usual name, description and parameters:

{
  "name": "computer",
  "description": "Operates the session's display.",
  "parameters": {"type": "object", "properties": {}},
  "provider_tool": {"kind": "computer", "display_width": 1280, "display_height": 800}
}
provider_tool.kind The tool What the driver does
computer Operates a display, keyboard and pointer, and returns screenshots as images Sends its provider's own computer tool, sized display_width × display_height, in place of a function definition. The calls it returns are tool_call blocks named after the entry, with the provider's action as input

An entry without the block is an ordinary tool, serialized as it is for any capsule. A driver that does not read provider_tool offers the entry as an ordinary function.

Safety checks

A driver may attach safety_checks to a tool_call content block it returns, for checks its provider asks a person to acknowledge before the action runs:

{"type": "tool_call", "id": "call_1", "name": "computer", "input": {"action": "click", "x": 640, "y": 400},
 "safety_checks": [{"id": "sc_1", "code": "irrelevant_domain", "message": "The page is not the one the task named."}]}
Member Type Required
safety_checks[].id string no
safety_checks[].code string no
safety_checks[].message string, not empty yes

[] counts as no checks. Any other shape, null included, is malformed. Checks are accepted on any tool call. Once the capsule's capability checks and any on-tool-call policy hook let the call through, and before it runs:

  1. The runtime puts the task in TASK_STATE_INPUT_REQUIRED with this prompt, one line per check (- for a check with no code):

    safety check on tool call '<tool>' (<call id>):
    <code>: <message>
    reply yes to acknowledge and run the action; any other reply declines it
    
  2. The person replies with a SendMessage naming the task, as for request-input. yes, y, approve, approved, acknowledge and acknowledged, ignoring case and surrounding spaces, acknowledge the checks. Any other reply declines them.

  3. Every call that carried checks writes one safety_check trace event.
Outcome The call The tool message the driver reads next
Acknowledged Runs The call's result, with acknowledged_safety_checks holding the checks exactly as the driver sent them
Declined Does not run is_error: true: the person did not acknowledge the safety check on this call, so it was not run: <code>: <message>; their reply: <reply>
No task to ask a person in Does not run is_error: true: this call carries a safety check and this task has no way to ask a person, so it was not run: <code>: <message>
Malformed checks Does not run, nobody is asked is_error: true: the driver returned a malformed safety check with this call, so it was not run
No reply within lifecycle.input_timeout_secs Does not run is_error: true; the attempt ends, and the task ends TASK_STATE_FAILED with input-timeout unless an on-task-end hook reopens it
The task is canceled while it waits Does not run The task is canceled

Several checks on one call are one prompt and one answer; the texts join them with ;. A driver that sends its provider an acknowledgement reads acknowledged_safety_checks from the tool message on its next request.


murmur:artifact-manager/manage

Lets a capsule component inspect and install artifacts mid-session: list and describe report what is installed, diagnostics returns the session id alongside that list, search lists what the capsule may pull, pull installs a new artifact, and remove uninstalls one the capsule pulled. An agent capsule makes the same pulls and removals through the pull-artifact and remove-artifact tools; both paths run one implementation and make the same checks.

Every function takes and returns artifacts by full name, <scope>/<name> — see Artifact names. The signatures are unchanged: a name is a string. A name that is not a full name is refused with an error string beginning not-granted:, which for a bare name carries the hint naming its scoped forms:

not-granted: 'tool-x' is not a full artifact name: an artifact is named <scope>/<name>; name it with its scope: 'murmur/tool-x' for a Murmur default artifact, '<owner>/tool-x' for one published from github.com/<owner>, or 'local/tool-x' for one installed from a .mur.zip file

pull(name, version) takes a required version. It resolves the artifact from the project store, then the global store; under capabilities.install.from: [stores, sources] a version neither store holds is fetched from the scope's GitHub source, with the checks mur install makes, and written to no store. It verifies the bytes against the reported hash, checks that the payload fits the WIT level this mur serves, checks the scope's owner id against murmur.lock, then installs it under tools/<name>/ in the session's runtime directory, <name> being the bare part of its full name, and updates murmur.lock. A new entry is pinned as origin: runtime with the id of the pulling session, which the runtime supplies itself, and with the scope's owner_id when the source or the stored payload records one; an entry that already exists keeps its origin. A pulled artifact is immediately visible to list() and describe(), and a pulled tool joins the dispatch allowlist, so invoke() reaches it.

pull requires the capsule's capabilities.install grant. A pull the grant does not admit returns an error string beginning not-granted:, which names the artifact and the capabilities.install key it failed, and lists the capsule's entries where the refusal is about a name. The checks run in this order:

Order Refused when Registry read
1 The capsule declares no capabilities.install No
2 The name is not a full artifact name, <scope>/<name> No
3 The version is not a single path segment: empty, ., .., or containing / No
4 No entry in either install list matches the name No
5 Another artifact of this session already has its tool name, the bare part: pulling ana/tool-y while murmur/tool-y is installed returns not-granted: 'ana/tool-y' has the tool name 'tool-y', which 'murmur/tool-y' already holds in this session; … No
6 The artifact is not a skill or a tool — a driver, a hook, or a role that disagrees with its payload Yes
7 No entry in the list for the artifact's own kind matches the name Yes

Past the grant, a refusal opens with one of three further tokens:

Token Refused when Example
not-found: No allowed place holds the version not-found: 'murmur/tool-edit@1.0.0' is in neither the project store nor the global store, and capabilities.install.from is [stores], so this capsule fetches from no source
does-not-fit: The payload's interfaces do not fit the served level does-not-fit: [E-WIT-LEVEL-001] artifact 'murmur/tool-edit@2.0.0' does not fit WIT level 3, the level this mur serves: …; murmur/tool-edit@1.0.0 is the newest version in the global store that fits level 3
not-fetched: The scope's source could not be read or failed a check, or the scope's owner id differs from the one murmur.lock records not-fetched: [E-REG-010] could not resolve ana/tool-edit@0.3.0: the owner of scope 'ana' has changed: …

A version or hash that conflicts with an existing murmur.lock pin is refused with murmur.lock conflict for '<name>': …. A refused pull writes nothing: no file under tools/, no murmur.lock change, no store change and no compiled form. diagnostics states the grant in runtime-state.capabilities, as install skill: <entries>; install tool: <entries>; install from: <stores | stores, sources>, or as pull, remove and search: not granted when the capsule declares none.

A script capsule's pull reaches the tool list an http agent's model is offered at the boundary inference.tool_refresh names: the next inference call under immediate, or the first call after a compaction under the default compaction, and at the start of the next task under either. An agent's own pull-artifact reaches it at its next call. It is offered marked untrusted, there and on every later launch whose murmur.yaml declares it, until mur install adopts its pin — see Artifact origin.

A successful pull writes one artifact_pulled line to the session's trace.jsonl, naming the artifact, its origin, its trust class, where it was fetched from and, when known, the scope's owner id; a refused or failed pull writes none.

search(query) takes an exact full name or an install pattern — *, <scope>/*, <scope>/<prefix>* — and returns one artifact-summary per artifact it reaches that this capsule may pull, at most 50, sorted by name. Its version is the version a pull without one would choose, or its newest held version when none fits. It lists artifacts whose name the query matches and an entry of their own kind's install list also matches, held in an allowed place: the stores, and under from: [stores, sources] the indexes of the scopes the query and the grant reach. Hooks and drivers are never listed. A query that is neither a name nor a pattern, or that reaches no granted name, is refused with not-granted:. It installs nothing.

A pulled WASM artifact is compiled once per version and kept in ~/.murmur/compiled, shared with mur run, so a later pull or launch of that version, in any session, skips compiling it.

remove

remove(name) uninstalls an artifact the capsule pulled. It needs the same capabilities.install grant as pull, and it removes only an artifact that murmur.lock pins as origin: runtime and murmur.yaml does not declare. The artifact must be installed in this session; its murmur.lock entry may name an earlier session that pulled the same artifact.

Result Meaning
ok(true) The artifact was removed
ok(false) The session has no installed artifact of that name, including a shell.allow binary that list() shows and a name already removed. Nothing changed
err(...) A refusal or a failure, in the table below

A removal deletes the artifact from every place pull put it:

Place After remove
tools/<name>/ in the runtime directory, <name> being the bare part Deleted: the manifest, skill.md and the native binary
list() and describe() The name is gone. describe() returns artifact '<name>' is not installed
murmur.lock The entry is deleted. Every other entry is rewritten unchanged
invoke() The name leaves the dispatch allowlist. A call to it returns tool '<name>' was removed from this session; it can no longer be called
~/.murmur/compiled Kept, so pulling the same version again skips compiling it. A compiled form not read in 30 days is deleted, as ~/.murmur/compiled describes

The checks run in this order, and a refused removal changes nothing:

Order Returns When
1 not-granted: this capsule declares no capabilities.install, so removing '<name>' is refused The capsule declares no capabilities.install
2 not-granted: '<name>' is not a full artifact name: … or not-granted: '<name>' is not a valid artifact name: <reason> The name is not a full artifact name, <scope>/<name>
3 ok(false) The session has no installed artifact of that name
4 not-removable: '<name>' is a <hook\|driver>; hooks and drivers are fixed at launch and cannot be removed while the session runs The artifact is a hook or a driver, whatever its origin
5 not-removable: '<name>' is declared in murmur.yaml; an artifact the operator declared stays for the life of the session murmur.yaml declares the artifact, whatever its origin. The next mur run needs its murmur.lock entry
6 not-removable: murmur.lock pins '<name>' with origin: operator; only an artifact this capsule pulled (origin: runtime) can be removed The pull matched an existing operator pin
7 failed to read murmur.lock: <error> murmur.lock exists and cannot be read
8 not-removable: murmur.lock has no entry for '<name>', so nothing records that this capsule pulled it murmur.lock, read at the moment of removal, has no entry for the name, or does not exist
9 The order 6 string murmur.lock, read at the moment of removal, pins the name as origin: operator, for example after mur install adopted it

Two errors can follow the checks:

Returns State after the error
failed to write murmur.lock: <error> Nothing changed
'<name>' is removed from this session and from murmur.lock, but <path> could not be deleted: <error> The artifact is out of the session and murmur.lock, and a call to it returns the removal refusal. The directory is left behind

Removing an artifact and pulling it again at another version replaces a runtime pin. An operator pin cannot be replaced this way: remove refuses it, and pull refuses to override it.


murmur:shell/execute

The contract for running an allowlisted shell binary. The runtime implements it natively.

Behaviour Detail
Non-zero exit code Data, not an error — the call only errs on spawn/IO failure
truncated: true stdout or stderr exceeded 16 KiB and was cut; full-output-path is the absolute path of the untruncated log, logs/shell-<timestamp>.log in the session's records directory
Allowlist binary must appear in capabilities.shell.allow; anything else is rejected before the process is spawned

murmur:task/task

request-input pauses the agent loop at a decision boundary and waits for a reply delivered by a SendMessage naming the task in its taskId. While it waits, the door reports the task as TASK_STATE_INPUT_REQUIRED. From the component's perspective the call is synchronous: pass a prompt string, receive the reply string.

Call it when the agent has reached a decision it cannot make on its own — ambiguous requirements, or a consequential action that needs approval — and a human or supervisor capsule is expected to reply.

It is available in the tool world only, and only while the capsule is running an A2A task. Called anywhere else, it returns an error immediately.

A driver's safety checks on a tool call put the task in TASK_STATE_INPUT_REQUIRED the same way, with the same reply and the same timeout.

Timeout: lifecycle.input_timeout_secs in the manifest (see lifecycle.input_timeout_secs). When it fires, the component is aborted and the attempt ends; the task ends TASK_STATE_FAILED with message input-timeout unless an on-task-end hook reopens it.


murmur:message/send

Sends a task to a peer capsule. send takes the peer URL and a message — a message-id, an optional context-id, and the text — and returns the peer's task-id, context-id, and state. The runtime sends it as a JSON-RPC SendMessage with A2A-Version: 1.0 and configuration.returnImmediately: true, so the peer answers as soon as it accepts the task and the capsule never sees the wire format.

Behaviour Detail
Allowlist The peer URL must appear in the sender's capabilities.network.allow. Otherwise the call returns Err("network policy: '...' not in capabilities.network.allow") and no connection is made.
Formation members A virtual address from MURMUR_FORMATION_PEERS, http://<member>.formation.invalid, is resolved by the runtime to that member's door, checked against capabilities.network.allow there, and sent with the member's formation token. A member the sender may not call, or no member at all, gets the allowlist error above. The trace records the virtual address.
Tracing With OTel configured, the runtime injects a W3C traceparent header so the peer's session span nests under the sender's.
Origin The runtime stamps x-murmur-task-origin: peer and the sending task's own x-murmur-task-trust on every request, so the receiving capsule inherits the sender's trust class. The message record has no field for either, so a capsule cannot set them. See Task origin and trust class.
Refusal A peer that answers with a non-2xx status fails the call with Err("peer at <url> refused the message (<status> <error>): <message>"), carrying the peer's own error code and message when its body has them. A peer that does not declare exports.peer_tasks.accept: true answers 403 peer_not_accepted.
Result state task-result.state is the state the peer answered the send with, in murmur's word — see the mapping below. Poll the peer's GetTask endpoint for the final state.
Answers that fail A peer answering TASK_STATE_UNSPECIFIED, a state that is not an A2A v1.0 task state, or a message rather than a task fails the call with Err, naming what the peer sent.
task-result.state State on the wire
submitted TASK_STATE_SUBMITTED
working TASK_STATE_WORKING
input-required TASK_STATE_INPUT_REQUIRED
completed TASK_STATE_COMPLETED
failed TASK_STATE_FAILED
rejected TASK_STATE_REJECTED
canceled TASK_STATE_CANCELED
auth-required TASK_STATE_AUTH_REQUIRED, which a peer that is not a murmur capsule may answer

The guest sees murmur's words, not the TASK_STATE_* names: the WIT is murmur's vocabulary, and the runtime maps the peer's state when it reads the answer. state is a string that components match on, so changing its values would break every built component with no change to the WIT version.


murmur:stream/events

The signals a tool or driver sends the session's stream while a turn is still in flight. Each function returns at once and never fails.

Function Writes Call it with
emit-chunk: func(chunk: string) A text frame The next piece of reply text
emit-thinking-chunk: func(chunk: string) A thinking frame The next piece of reasoning
tool-call-started: func(id: string, name: string) A tool-call-started frame The id and name of a tool call the model has begun writing
tool-call-input-bytes: func(id: string, bytes: u64) A tool-call-progress frame, coalesced The id of a started call, and how many bytes of its input JSON the model has written so far
Caller emit-chunk, emit-thinking-chunk tool-call-started, tool-call-input-bytes
The capsule's inference driver, on an agent-loop turn Write Write
A tool Write Do nothing
A driver called by a hook's run-inference Do nothing Do nothing
Any component outside an A2A task Do nothing Do nothing

A driver reporting a tool call follows these rules:

Rule What it means
Same id and name id and name are the id and name of the tool_call content block the driver returns for the call. The artifact frame takes both from that block, so a start under another id is never matched to it. A block returned under another name keeps that name, and the runtime writes an error line naming the driver, the id and both names
Cumulative, never delta bytes is the UTF-8 length of the call's input JSON written so far, not the increment since the last report
A count, never input No function carries any of the input's content; the input reaches the runtime only in the returned tool_call block
No id, no start A driver that learns the provider's call id only once the call is complete calls neither tool-call function

The runtime writes nothing for:

  • a tool-call-input-bytes for a call with no tool-call-started;
  • a second tool-call-started for the same id, or one with an empty id;
  • a bytes no larger than one already reported for the call.

Neither tool-call function opens an inference turn, counts toward inference.max_turns or is written to the trace.

In WIT source the package is written murmur:%stream, because stream is a WIT keyword. The instance a component imports is murmur:stream/events@0.1.0, and Rust bindings name it murmur::stream::events.


murmur:hook/lifecycle

Hook artifacts (runtime: hook) export this interface. The runtime calls each handler synchronously. Returning an error logs it to logs/hook-<name>.log in the session's records directory and the session continues — except at on-compaction, where the error ends the session, because the runtime has no other way back under the token budget:

on-compaction error Session exit_status
Follows a run-inference call a spend ceiling refused spend_ceiling_reached
Any other failed
Handler Fires
on-stage Once at capsule staging, before any session starts
on-session-start Once per capsule launch, before the first task's work begins
on-task-start Once per task, before that task's first inference turn
on-inference On inference.transport: http, after each inference driver response is parsed. On inference.transport: process, before each tool call the runtime runs for the harness, and again when each turn closes. Its turn is the number the turn's inference trace line carries: zero-based, counted across every attempt of the task. See What on-inference carries on each transport
on-tool-call Twice per model-requested tool invocation: once before it is dispatched, once after it returns or errors
on-shell Twice per allowed shell command: once before it is dispatched, once after it returns
on-compaction When the session token threshold is reached, before any history is replaced
on-task-end Once per task, immediately after that task's agent loop returns
on-session-end Once per capsule launch, after the task loop exits

With task_acceptance: none or single, on-task-start coincides with on-session-start; with queue it fires once per queued task. session-id is the session identifier — ses_ followed by 32 hex characters — and is the same value in every hook event of a run.

on-task-start and on-task-end are optional exports: a component that omits them loads and is simply never dispatched for them. The six session handlers are required — a component missing one fails to load with an error naming it. A missing on-stage is logged rather than fatal, and staging continues.

The two dispatches of a gated event

on-tool-call and on-shell are each dispatched twice, and outcome is what tells the two apart:

outcome Dispatch Meaning
none Decision point, before the call The call has not run. A returned deny refuses it.
some(...) Observation, after the call The call has run. Nothing returned can change that.

The decision point runs only for a hook whose own murmur.yaml declares commit_policy: deny on binding: on-shell or binding: on-tool-call. Every other hook sees the observation dispatch alone.

What each handler can commit

A handler may return any hook-output arm, but the runtime commits only one arm per event:

Handler Committed arm
on-stage write-manifests
on-inference artifact
on-compaction replace-context
on-task-end reopen-task
on-task-start seed-context
on-shell, on-tool-call deny, at the decision point only
All others none — only none is silent

This table is also enforced ahead of time. A hook artifact declares in its own murmur.yaml which handler it binds to (binding:) and what it expects committed (commit_policy:), and the runtime checks the pair against this table when the capsule is staged. A commit_policy the binding's handler cannot commit (say commit_policy: reopen-task on binding: on-stage) fails staging with an error naming the binding, the declared policy, and the one this binding honors, rather than becoming a mid-session dispatch fault. A hook with no binding: receives every event, so any commit_policy is valid for it. See Hook contract fields.

Returning none is always silent, and is the normal case for an observational hook. Returning any other non-none arm is a loud but non-fatal fault:

  • A line naming the hook, the handler, and the discarded arm is appended to logs/hook-<name>.log.
  • A hook_dispatch_error event is written to trace.jsonl — see Session trace schema. The one exception is on-stage, which runs before the trace file exists. An async hook's fault reaches the trace by the same path; the trace and the log are the only places it appears, since the agent loop never sees an async hook's return value.

reopen-task is a control arm: returning it from on-task-end re-runs the task's agent loop with the arm's string injected as feedback, subject to lifecycle.max_task_reopens and inference.max_turns. See Task reopening.

seed-context is honored at on-task-start alone: the first bound blocking hook to return it wins, and its messages are placed at the head of the task's context, ahead of any loaded history and ahead of the task message. The list is chronological, oldest first. The host measures it against task-start-event.budget-tokens — the input budget × context.seed_budget — and commits it whole, drops its oldest messages, summarizes the overflowing front through the compaction hook, or refuses it; every outcome is recorded as a context_seed event, and a refusal never fails the task. Returning it from any other handler is the same loud-but-non-fatal fault as any other unhonored arm. Only binding: on-task-start accepts it as a commit_policy.

deny is a control arm honored at the decision-point dispatch of on-shell and on-tool-call alone: the call is not made, the agent is handed a result naming the hook and the reason, and a call_denied event is written. It only narrows — there is no arm that permits a call, and the manifest's own capability check still runs first, so a hook can never make a capsule able to do something its manifest did not allow. Returning it from any other handler, or from the observation dispatch of these two, is the same loud-but-non-fatal fault as any other unhonored arm. See Policy hooks for the fail-closed rule that governs a policy hook's failures.

What on-inference carries on each transport

On inference.transport: process the harness, not the runtime, talks to the model, so on-inference is dispatched at two points: once per tool call, at the call's decision point, and once per turn, when the turn closes. Each tool call's announcement is handed to the hook before the call runs, and a call that is then refused — by a missing required field, by capabilities.filesystem.read_only or by a policy hook — was still announced.

inference-event field transport: http transport: process, call announcement transport: process, turn close
When it fires After each driver response is parsed, before any of the turn's tool calls is checked or run Before each tool call the runtime runs for the harness, once per call When the turn closes: at its tool call's result, or when the harness ends or fails the turn
turn The turn's number The number of the turn the call belongs to The turn's number
input-tokens, output-tokens The turn's counts 0 The harness's counts for the turn, 0 where it reported none
decision tool_call, end_turn, capped or text tool_call tool_call, end_turn or error
tool-name The turn's first tool call The call's tool name The turn's first tool call
output The response's content blocks, as a JSON array A JSON array holding one tool_call block: type, name and input, the call's argument object. It has no id The turn's last text, or absent
prompt The request's messages, as JSON Absent: the harness builds the request Absent
tools The request's tool list, as JSON Absent Absent
Counts toward on-session-end's total-turns Yes No Yes
Returned artifact Forwarded to the task's stream from a turn that calls no tool; dropped for a tool-calling turn Dropped Forwarded from the turn the harness ends; dropped for a tool-calling turn

An announcement's output for a call to tool-write:

[{"input":{"content":"hello","path":"notes.txt"},"name":"tool-write","type":"tool_call"}]

Event field notes

  • compaction-event.session-tokens is the number the runtime compared against inference.compaction.threshold: the provider's count of the call that crossed it when the driver reported usage, the runtime's estimate otherwise. The trace's compaction line records which in tokens_before_source. compaction-event.threshold is session-tokens over the turn's input budget, the number the threshold is a fraction of.
  • compaction-event.model and compaction-event.system-prompt carry inference.compaction.model and inference.compaction.system_prompt verbatim, so a compaction hook knows which model and which prompt to use for its own summarization call. Setting inference.compaction.system_prompt_file instead delivers that file's contents. Either field is absent when the manifest leaves it unset, and the hook resolves its own default.
  • message.id and message.source-id are runtime bookkeeping carried alongside a message's role and content. id is msg_ followed by a uuid — an identity, so two byte-identical messages still carry different ids. source-id is opaque: whatever produced the content sets it, and the runtime records it verbatim without parsing it. Both are stripped before the driver payload is built, so neither reaches the provider — a uuid at the head of a cached prefix would break prompt-prefix caching on every request. An id a hook returns is kept, and the runtime mints one for a message returned without one; a source-id a hook sets is carried verbatim, and the field is absent when the hook set none.
  • An assistant message's produced_by record key, written when the capsule declares inference.alternates, is stripped before the driver payload like id and source-id, and is not served through murmur:conversation/read.
  • message.inserted-by names the hook output that put a message into the conversation. Only the runtime sets it, when it commits that output, and it is stripped before the driver payload like id and source-id. A value a hook sets on a message it returns is ignored.
Value Meaning
replace-context Committed from a compaction hook's replace-context, including the summary of a seed's overflowing front
seed-context Committed from an on-task-start hook's seed-context
none No hook output inserted it: a task message, a model turn, a tool result or a runtime marker. role tells those apart

The mark is independent of role: a summary returned as assistant or user is marked the same. A message a hook returns counts as carried, and keeps the mark it already had, when its id names a message in the context the hook was handed and its role and content are unchanged. Any other returned message is marked with the output it came in through, so reusing a held id on new content cannot pass a summary off as a person's turn. - task-start-event.context-window is the primary driver choice's context window: context.max_tokens, else the context_window its driver's models: table declares for the model, or 0 when neither gives one. It is precomputed so a hook sizing its work against the window never has to know which model the session runs. - task-start-event.budget-tokens is the ceiling a seed-context returned from this event is enforced against: the primary choice's input budget at launch × context.seed_budget, rounded down. The input budget is the input ceiling, or the window less the output cap, whichever is smaller, so budget-tokens is a fraction of a number below context-window, which still carries the window. task-start-event.prior-tokens is the token count of the conversation history the task will load under lifecycle.conversation: threaded, and 0 under every other conversation mode. Read 0 as "the runtime has not computed this" and decline, rather than as an unbounded budget. - shell-event.binary is the program the shell tool invoked — a canonicalized absolute path (for example /usr/bin/pytest) when the runtime resolved the name against PATH, and the bare invoked name when nothing resolved. - shell-event.command carries the argument list alone (for a shell interpreter, the script text passed via -c), truncated to 200 characters. It is a display string. shell-event.argv is the exact argument list the runtime passes to the executable and shell-event.script is the -c body (absent for every other form), both untruncated — a policy must decide on those two and on binary, never on command. - shell-event.recipe is the body of the recipe a build-tool call names, read by the runtime out of the capsule's workdir. A hook holds no grant on that directory and reads nothing itself.

| Invocation | Body |
|---|---|
| `make <target>` | the target's recipe lines, from `GNUmakefile`, `makefile` or `Makefile` |
| `just <recipe>` | the recipe's indented body, from `justfile`, `.justfile`, `Justfile` or `JUSTFILE` |
| `npm run <script>` | the string value of `scripts.<script>` in `package.json` |

The body is the file's own text: variables, interpolations, conditionals and recipe arguments
are carried as written. It is absent when the call names no recipe, and when the runtime could
not resolve one:

- no recipe file in the workdir, or one that is unreadable, oversized or not UTF-8
- a target, recipe or script that is not defined, or one reached through a `make` pattern rule
  or a `just` alias
- a form that could resolve to more than one body: two `make` rules for one target, a duplicate
  `just` recipe, a `make` recipe written on the rule line as `target: ; cmd`, or an `npm` script
  with a `pre`/`post` sibling
- an invocation carrying flags beyond the recipe file and working-directory selectors, or a
  `make` variable override such as `make build VERSION=2`

Read an absent body as "the runtime has no body to show", which is a statement about the
resolution and not about the call.
  • tool-event.input is the exact tool input JSON the tool will receive, untruncated, and is what a policy decides on.
  • shell-event.outcome and tool-event.outcome carry what the call produced, and are absent at the decision point — see The two dispatches of a gated event.

murmur:runtime/inference

A runtime-provided import available to any hook component that declares it. Nothing needs to be exported and nothing in the manifest changes.

run-inference runs exactly one LLM completion through the capsule's transport: http driver (inference.driver.artifact).

Case Behaviour
No model given Uses the manifest's inference.model
model given Sends that model. If the driver or provider rejects it the call returns an error; the runtime never silently falls back. To retry on the primary model, call again without a model.
No system-prompt given No system prompt is sent
Called while the hook serves on-compaction Sends inference.compaction.max_tokens as the output cap and runs the driver under inference.compaction.driver_config, each when set. Any other event's call gets the cap in Output cap sent and inference.driver.config
No driver configured Returns an error naming inference.driver.artifact. The import still links, so the hook itself runs.
Capsule runs under transport: process Returns an error saying run-inference is not available under that transport. Nothing is sent, nothing counts toward a spend ceiling, and nothing is written to the trace. The import still links, so the hook itself runs.
A spend ceiling refuses the call Returns an error starting spend ceiling reached: that names inference.max_session_tokens or spend.machine_tokens_per_day. Nothing is sent, and retrying will not succeed. The refusal is recorded as a spend_ceiling_reached line carrying the hook's origin.

model-used is the model string the runtime actually sent. input-tokens and output-tokens are the runtime's own cl100k_base counts of the request payload and the raw driver response.

Every call that reaches the driver, success or failure, writes one inference record to trace.jsonl and one capsule.inference OTel span carrying origin: "hook:<hook name>" and model. A call from an on-inference hook is recorded on that hook's turn, ahead of the turn's own inference line.

A call that fails transiently is made again under the capsule's inference.retry, as an agent turn is, before run-inference returns. The hook sees only the last attempt's result. Each retry writes an inference_retry line carrying the hook's origin, and the call's one inference record carries attempts when it took more than one. The decision to wait does not consult the hook's own limits.deadline_seconds: a wait that outlasts it ends the hook as a slow provider call would.


murmur:runtime/tokens

A runtime-provided import available to any hook component that declares it. It needs no capability grant and no manifest entry: counting text reaches no resource.

count returns the runtime's own cl100k_base count of the string — the same count behind every estimate the runtime makes: a request's size before it is sent, a seed's budget, and the compaction threshold when the driver reports no usage. Once a driver reports usage, the compaction threshold and the spend ceilings use the provider's count instead, which count does not reproduce.


murmur:task-io/read

A runtime-provided import available to any hook component that declares it. It hands a hook the text of the task its capsule was given and the result text the agent produced, so an output gate or an archiver needs no filesystem grant to see either.

Reading is granted per hook with capabilities.task_io.read: true on that hook's entry in the capsule manifest. A hook without the key still links and still runs — every function returns not-granted.

Function Returns
input-len(form) Byte length of the task input
read-input(form, offset, max-bytes) A window of the task input
output-len() Byte length of the agent's result text
read-output(offset, max-bytes) A window of the result text

Ask for a length first, then read the window you can afford: the runtime imposes no truncation cap of its own. A read returns the longest prefix of the value from offset that fits in max-bytes and ends on a character boundary, so a multi-byte character is never split. Advance offset by the byte length of what you got back. An empty return with offset still below the length means max-bytes was too small for the next character.

form picks which rendering of the input to read:

Form Text
as-given Exactly what this attempt's agent loop was handed, including any feedback a previous reopen appended. What an output gate judges against.
original The task before any reopen feedback was appended. What an archiver or cost-attribution hook wants.

On a task that has never been reopened the two are identical.

When a task is readable

A task is in scope from the moment one of its attempts enters the agent loop until the runtime finishes with that task, reopens included.

Lifecycle event input-* output-*
on-stage no-task no-task
on-session-start no-task no-task
on-task-start no-task no-task
on-inference, on-tool-call, on-shell, on-compaction This attempt's input no-output until the loop finishes
on-task-end This attempt's input This attempt's result text, or no-output if it ended without one
on-session-end no-task no-task

on-task-start fires before the task enters the loop and on-session-end after the last task has left it, so an archiver binds to on-task-end. On the second attempt of a reopened task the output is cleared at attempt start: a hook judging attempt 2 never sees attempt 1's result.

An execution_mode: async hook reads whatever is in scope when its worker reaches the call, which may be after the task has left scope. Bind a hook that reads the task as blocking.

Error Meaning
not-granted The hook's entry does not declare capabilities.task_io.read: true
no-task No task is in scope at this lifecycle event
no-output A task is in scope but the loop produced no result text
out-of-range offset is past the end of the value, or inside a multi-byte character

murmur:conversation/read

A runtime-provided import available to any hook component that declares it. It hands a hook the capsule's durable conversation record — every message the runtime put in front of the model, newest first, paged — without a filesystem grant. No artifact gets a filesystem path into ~/.murmur/conversations/; this interface is the only way in. The runtime is the only writer, and there is no write function.

Reading is granted per hook with capabilities.conversation.read: true on that hook's entry in the capsule manifest. A hook without the key still links and still runs — read-messages returns not-granted.

Function Returns
read-messages(cursor, limit) One message-page: messages, next-cursor, total

messages[0] is the most recently appended message, and each carries the id and the inserted-by its record line holds. A line with no inserted_by key, or one this runtime does not recognise, is served with inserted-by: none.

Field Meaning
cursor An opaque token. none starts at the newest message; pass back the next-cursor of the page you just read, unmodified.
next-cursor none once a page has reached the oldest message in the record, which is what ends a paging loop.
limit Clamped to 1..=100, so 0 reads one message and a loop always makes progress.
total Parseable messages in the whole record at the moment the page was served, not in this page.

A cursor stays valid while the runtime appends to the record underneath a paging hook: it names a position counted from the oldest message.

total is a snapshot, not a loop invariant. The runtime appends while a hook pages, so it grows from one page to the next and the page lengths of a completed walk can sum to less than the final total. Every page is a consistent snapshot anchored at the oldest message, so nothing repeats and nothing is skipped. Never size a paging loop from total; stop on next-cursor: none.

A message whose role is "tool" reaches a reader with its content replaced by a JSON object, because a tool result carries more than the one string content has room for.

Key Value
__murmur_tool_msg__ Always true, and what marks the envelope
tool_call_id The call this result answers, or null
is_error Whether that call failed, or null
body The tool result as the runtime holds it

This is the same encoding a compaction hook receives on murmur:hook/lifecycle's message — one encoding, in both places.

Error Meaning
not-granted The hook's entry does not declare capabilities.conversation.read: true
invalid-cursor A cursor naming a position outside the record
unavailable: <reason> The record exists and could not be read

A record that does not exist is not an error: the read succeeds with no messages and total: 0. That is what a hook sees before the first task of a launch, under context.record: off, and in a capsule running inference.transport: process — whose CLI owns its own conversation, so the runtime puts no message list in front of a model and writes no record.

The record in scope is the one belonging to the task the hook is being dispatched for, which is the context-id on task-start-event. A hook bound to on-task-start therefore reads the conversation its task is about to continue.


murmur:driver/process

Exported by the process driver a transport: process manifest names under inference.driver. The process-driver world imports nothing of Murmur's: the runtime loads the driver with an empty WASI context and grants it nothing.

Function Called Returns
describe Once, when the driver is loaded The harness's name and binary, how to read its version, how to interrupt a turn, the variables it requires, and whether it reports token usage
launch Once per run The arguments, environment, files and stdin to run the harness with, or an error
parse For each batch of complete stdout lines, including those printed after the terminal event The events those lines describe
classify-exit When the harness's output ends without a terminal event turn-end or turn-failed

One driver instance serves a whole run, so parse may answer from what launch was given. A call that refuses, crashes or runs out of time ends the run with E-RUN-034. mur run refuses a capsule whose driver fails a load-time check — see Process driver.

Every record, field and event is documented in process-driver.wit.

What the harness spent

A driver reports its harness's token counts through the usage event, whose payload is the usage record:

Member Type Value
input option<u64> Prompt tokens, excluding anything cache-read or cache-creation covers
output option<u64> Generated tokens, including the thinking subset below
cache-read option<u64> Prompt tokens served from the provider's cache
cache-creation option<u64> Prompt tokens written to the provider's cache
thinking option<u64> The part of output the harness reports as reasoning

Three rules govern what a driver sends:

Rule What it means
Cumulative, never delta Every value is the total for the harness run so far, not the increment since the last report. A harness that streams a running count and then a final total is relayed verbatim, both times; the runtime attributes only each member's growth to the turn closing when it arrives, so reporting the same total twice adds nothing
Absent is not zero none means this harness does not report this count. 0 means it reported spending none. The runtime keeps the two apart all the way into the trace
Cache sits beside input A harness that folds its cache counts into its input count subtracts them before reporting, so input is always the uncached prompt alone

No cost figure and no context-window size is carried. A price a harness computed against its own table is a different claim from a token count, and there is no equivalent on transport: http to make it mean the same thing on both.

describe().reports-usage says whether the driver emits the event at all. A driver that does not is fully supported, and its turns carry no token counts — but a manifest that sets a spend ceiling against one is refused at launch with E-RUN-038, because a ceiling nothing could ever reach is not a ceiling.

Where the reported numbers land in the trace, and how they differ from the http path's, is in the inference record.

A tool call being written

A driver whose harness reports a tool call while the model is still writing it sends two events ahead of the complete tool-call:

Event Record Members
tool-call-started tool-call-start id, name: the same id and bare name the complete tool-call will carry, with any prefix the driver added in launch stripped
tool-call-progress tool-call-progress id of the started call; input-bytes: u64, the bytes of the call's input JSON written so far
Rule What it means
Cumulative, never delta input-bytes is the total for the call so far, not the increment since the last report
Optional A driver whose harness reports a call only once it is complete sends neither event, and describe declares nothing about them
A count, never input Neither event carries any of the input's content; the input reaches the runtime only in the complete tool-call

The runtime turns them into the tool-call-started and tool-call-progress stream frames, which carry the name and a byte count and nothing of the input. Neither event opens an inference turn or is written to the trace; the call's turn opens, and counts toward inference.max_turns, when its complete tool-call arrives.

The runtime writes nothing for:

  • a tool-call-progress for a call with no tool-call-started;
  • a second tool-call-started for the same id;
  • a tool-call-started after the call's tool-call or tool-result, or with an empty id;
  • an input-bytes no larger than one already reported for the call.

A complete tool-call whose name differs from its start's is recorded under the tool-call's name, and the difference is written to the trace as a harness_note.

Subscription windows

A driver whose harness reports how much of the person's subscription is used sends the quota event. Its payload is a list of quota-window records:

Member Type Value
name string A stable lowercase label the driver documents, such as five-hour or seven-day
used option<f64> How much of the window is used, as a fraction from 0 to 1
resets-at option<u64> When the window resets, in milliseconds since the Unix epoch
window-minutes option<u64> How long the window is, in minutes
state option<quota-state> ok, warning or exhausted, as the harness reports it
Rule What it means
The driver translates A harness's own spelling — a percentage, seconds, numbered primary/secondary windows — becomes these names and units in the driver
A snapshot of the windows it names A later report replaces a window of the same name whole and leaves the others standing. A driver whose harness sends sparse updates merges them into its own last report before sending one
Absent is not zero none means the harness did not report the member. Every surface leaves it out
Optional A driver whose harness reports no windows never sends the event, and describe declares nothing about it

The runtime refuses a window whose name is not 1 to 64 characters of a-z, 0-9 and - starting with a letter or digit, or whose used is not a number from 0 to 1. It names each refused window and why in one harness_note and keeps the rest. Of two windows with one name in one report, the last stands.

Each report with at least one window left:

  • is written to the trace as a harness_quota record;
  • sets the quota field of session-status to the latest window per name;
  • reaches a client streaming the task as a quota frame.

The runtime acts on no report: no window, however exhausted, warns, stops, retries, slows the run or changes a prompt. The event opens no inference turn, and a run that only reported a quota commits no harness session.

The suggested prompt

A driver whose harness predicts what the person will ask next sends it as suggested-prompt(string).

Rule What it means
Optional A driver whose harness makes no such prediction never sends it
May arrive late A harness may print it after its terminal event; it is read while the harness exits
Empty says nothing An empty suggestion is dropped

Each suggestion is written to the trace as a harness_suggestion record and reaches a client streaming the task as a suggested-prompt frame. A task the person cancelled forwards no suggestion, as it forwards no answer; the record is still written. The event opens no inference turn.

Events after the terminal event

Once the driver has returned turn-end or turn-failed, the runtime closes the harness's stdin and gives it 3 seconds to exit. Until the harness has exited and closed its stdout, or those 3 seconds have passed, the runtime keeps reading its output and handing it to parse. The same rules cover the rest of the batch the terminal event arrived in:

Event What the runtime does
quota Records and forwards it, as before the terminal event
suggested-prompt Records and forwards it, as before the terminal event
note Writes it to the trace as a harness_note
Any other Writes one harness_note naming the event, <event> after the turn's terminal event, ignored, and ignores it

Nothing read after the terminal event changes the turn's outcome, its result, its token counts or its spend. No parse call starts after the 3 seconds; a harness still running then is killed, and its harness_exit record says killed: true. A parse call that fails in that time is written as a harness_note and ends the reading. A run that was stopped — by a cancel, inference.max_turns, a spend ceiling or inactivity — reads nothing after it ends.


Package versioning

Every murmur:* package declares an explicit @x.y.z version, so the contract a compiled .wasm artifact was built against is readable from the component binary itself. The whole set is numbered as a WIT level: this table is the level mur version reports, and WIT Levels lists every level and the mur releases that serve it.

Package Version
murmur:tool 0.2.0
murmur:tool-registry 0.2.0
murmur:capsule 0.1.0
murmur:artifact-manager 0.1.0
murmur:shell 0.1.0
murmur:message 0.1.0
murmur:task 0.1.0
murmur:task-io 0.1.0
murmur:conversation 0.2.0
murmur:stream 0.1.0
murmur:hook 0.9.0
murmur:runtime 0.4.0
murmur:host 0.1.0
murmur:runtime-guest 0.1.0
murmur:driver 0.4.0
Tier When
Patch Doc or comment edits, no ABI change
Minor A wholly new function or interface the runtime treats as optional to export
Breaking Any signature change, any change to an existing record or variant, or removing or renaming a function or interface

Every murmur:* package is pre-1.0, so the minor field is the breaking axis: a breaking change to 0.4.0 ships as 0.5.0, not 1.0.0. Only a patch moves the patch field.

Adding a field to an existing record or a case to an existing variant is always breaking, never minor: every field and case is positional in the binary encoding, so an addition changes the shape of every call that carries the type.

A wholly new interface goes in a new package at 0.1.0 when the package that would otherwise host it already has published consumers. The package version is part of every instance name in that package, so a minor bump renames interfaces the new one has nothing to do with, and every artifact importing one of them stops loading until rebuilt. The exception is a change set that already forces that rebuild for another reason, in which case the new interface joins the existing package instead: murmur:runtime/tokens sits in murmur:runtime because the same bump that introduced it took murmur:hook to 0.6.0 and rebuilt every hook regardless.

A package that only uses a type from another package moves when that type changes shape. murmur:conversation and murmur:runtime both carry murmur:hook's message record — read-messages returns it and inference-request holds it — so a change to message bumps all three packages together, and a hook importing either one is rebuilt with the murmur:hook bump.

One accepted version per interface. The runtime resolves each interface by its versioned name and nothing else — there is no compatibility fallback for an earlier version or for an unversioned name. An artifact built against a different version fails to load, with an error naming the version the runtime expects and a rebuild hint (mur install for a default artifact, or a source rebuild otherwise). When a package is bumped, every artifact exporting it must be rebuilt and republished.

An installed artifact carries the versioned interface names its own component declares in the wit_contracts key of its <name>-<version>.meta.json; see Local artifact cache. mur list --contract <PREFIX> lists the installed artifacts touching a package.

Full policy in wit/VERSIONING.md.