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:
-
The runtime puts the task in
TASK_STATE_INPUT_REQUIREDwith this prompt, one line per check (-for a check with nocode):safety check on tool call '<tool>' (<call id>): <code>: <message> reply yes to acknowledge and run the action; any other reply declines it -
The person replies with a
SendMessagenaming the task, as forrequest-input.yes,y,approve,approved,acknowledgeandacknowledged, ignoring case and surrounding spaces, acknowledge the checks. Any other reply declines them. - Every call that carried checks writes one
safety_checktrace 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
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-bytesfor a call with notool-call-started; - a second
tool-call-startedfor the same id, or one with an empty id; - a
bytesno 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_errorevent is written totrace.jsonl— see Session trace schema. The one exception ison-stage, which runs before the trace file exists. Anasynchook'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-tokensis the number the runtime compared againstinference.compaction.threshold: the provider's count of the call that crossed it when the driver reported usage, the runtime's estimate otherwise. The trace'scompactionline records which intokens_before_source.compaction-event.thresholdissession-tokensover the turn's input budget, the number the threshold is a fraction of.compaction-event.modelandcompaction-event.system-promptcarryinference.compaction.modelandinference.compaction.system_promptverbatim, so a compaction hook knows which model and which prompt to use for its own summarization call. Settinginference.compaction.system_prompt_fileinstead delivers that file's contents. Either field is absent when the manifest leaves it unset, and the hook resolves its own default.message.idandmessage.source-idare runtime bookkeeping carried alongside a message'sroleandcontent.idismsg_followed by a uuid — an identity, so two byte-identical messages still carry different ids.source-idis 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. Anida hook returns is kept, and the runtime mints one for a message returned without one; asource-ida hook sets is carried verbatim, and the field is absent when the hook set none.- An assistant message's
produced_byrecord key, written when the capsule declaresinference.alternates, is stripped before the driver payload likeidandsource-id, and is not served throughmurmur:conversation/read. message.inserted-bynames 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 likeidandsource-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.inputis the exact tool input JSON the tool will receive, untruncated, and is what a policy decides on.shell-event.outcomeandtool-event.outcomecarry 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-progressfor a call with notool-call-started; - a second
tool-call-startedfor the same id; - a
tool-call-startedafter the call'stool-callortool-result, or with an empty id; - an
input-bytesno 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_quotarecord; - sets the
quotafield ofsession-statusto the latest window per name; - reaches a client streaming the task as a
quotaframe.
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.