Streaming Protocol
The server-sent event streams a capsule serves on its A2A address: the three endpoints, the A2A v1.0 events they write, the murmur frames the stream extension adds, event ids, replay and the heartbeat.
mur watch is a client for one of the endpoints. What it implements is listed
under What mur watch implements.
What the door serves and refuses, and every extension, is listed under A2A v1.0 conformance.
Endpoints
Each endpoint is a JSON-RPC method sent as POST / to the capsule's address, with
Content-Type: application/json, A2A-Version: 1.0 and a non-empty body. A POST / without a
JSON content type or without a body is answered 404 Not Found. A request without
A2A-Version: 1.0 is answered -32009 as one JSON body, and no
stream opens.
| Method | Purpose | Params | Closes when |
|---|---|---|---|
SendStreamingMessage |
Send a message — a new task, or the reply to a task waiting for input — and stream that task | params.message, read as SendMessage reads it: see Agent Card: Messages |
After the task's terminal status, or after a live TASK_STATE_INPUT_REQUIRED status of the task. Also when the client disconnects or the capsule exits. Closing the connection does not cancel the task: use CancelTask |
SubscribeToTask |
Stream the rest of one live task the caller sees | params.id, the task's id — see Agent Card: SubscribeToTask |
After the task's terminal status. A TASK_STATE_INPUT_REQUIRED status does not close it. Also when the client disconnects or the capsule exits. Closing the connection does not cancel the task |
murmur.WatchSession |
Observe every task the capsule runs, without sending anything | {} — nothing is read |
The capsule's stream ends, or the client disconnects. A task's terminal status does not close it |
SendStreamingMessage and SubscribeToTask are the task streams: each carries one task.
On a capsule declaring network.authentication, every
method requires Authorization: Bearer <token> with a token holding the method's scope. A request
without one is refused with 401 or 403 and a JSON body before the stream opens. See
Agent Card: What the door answers.
Request a SendStreamingMessage, with the stream extension activated:
curl -N -X POST http://localhost:52222/ \
-H 'Content-Type: application/json' \
-H 'A2A-Version: 1.0' \
-H 'A2A-Extensions: https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1' \
-d '{"jsonrpc":"2.0","id":"r1","method":"SendStreamingMessage","params":{"message":{"messageId":"msg-1","role":"ROLE_USER","parts":[{"text":"Summarise README.md"}]}}}'
Subscribe to a task already running:
curl -N -X POST http://localhost:52222/ \
-H 'Content-Type: application/json' \
-H 'A2A-Version: 1.0' \
-d '{"jsonrpc":"2.0","id":"sub-1","method":"SubscribeToTask","params":{"id":"tsk_0199c4e2f1b7712a9d3e4f5061728394"}}'
Request a murmur.WatchSession:
curl -N -X POST http://localhost:52222/ \
-H 'Content-Type: application/json' \
-H 'A2A-Version: 1.0' \
-H 'Last-Event-ID: 0' \
-d '{"jsonrpc":"2.0","id":1,"method":"murmur.WatchSession","params":{}}'
Each answers with these response headers, then the event stream. There is no content-length: the
body ends when the connection closes. The A2A-Extensions line names what the answer used, as
Activation lists. On murmur.WatchSession it is always:
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache
connection: keep-alive
A2A-Extensions: https://docs.murmur.nexus/reference/agent-card/#murmur-door-v1, https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1
Everything that can refuse a SendStreamingMessage is answered as one JSON-RPC body,
content-type: application/json, before any stream opens:
| Refusal | Answer |
|---|---|
The capsule's lifecycle.task_acceptance is none, so its card says capabilities.streaming: false |
-32004 UnsupportedOperationError |
| The message is malformed, carries a file part, or names a task it cannot go to | The error SendMessage answers the same message with |
| The task could not be handed to the capsule's queue | -32603, and the task is not kept |
A new task the capsule has no room for, or refuses because its session is closing, is answered
as a stream of exactly one unnumbered event: the task in
TASK_STATE_REJECTED, whose status.message is task rejected: capsule is busy or
task rejected: the session is closing — the task SendMessage answers the same refusal with.
The stream then ends.
Everything that can refuse a SubscribeToTask — no id, a task the caller does not see, a task
that has ended, a door whose task_acceptance is none — is answered the same way, as one JSON-RPC
body before any stream opens. The table is under
Agent Card: SubscribeToTask.
murmur.WatchSession is served whatever task_acceptance says.
Event form
Every event either endpoint writes is an optional id: line, one data: line, then a blank line.
Lines end with \n. No event carries an event: line.
| Line | Form | On |
|---|---|---|
| Id | id: <unsigned 64-bit integer> |
Every event in the session's sequence — see Event ids |
| Data | data: <JSON-RPC response> |
Every event. Always exactly one line |
| Comment | :<text> |
The heartbeat. Stands alone between blank lines and is not an event |
The data: line is a JSON-RPC 2.0 response to the request: its id is the request's id,
verbatim, string or integer, and its result is one A2A v1.0 StreamResponse, in ProtoJSON
camelCase.
id: 3
data: {"jsonrpc":"2.0","id":"r1","result":{"statusUpdate":{"taskId":"tsk_0199c4e2f1b7712a9d3e4f5061728394","contextId":"ctx_0199c4e2f1b7712a9d3e4f50617283a1","status":{"state":"TASK_STATE_WORKING","message":{"messageId":"msg_0199c4e2f1b87a01b2c3d4e5f6071829","contextId":"ctx_0199c4e2f1b7712a9d3e4f50617283a1","taskId":"tsk_0199c4e2f1b7712a9d3e4f5061728394","role":"ROLE_AGENT","parts":[{"mediaType":"text/plain","text":"inference turn 2"}]},"timestamp":"2026-10-13T09:00:00.123Z"}}}}
Standard events
A StreamResponse sets exactly one member. A connection that did not activate the stream
extension receives only task, statusUpdate and artifactUpdate events of its own task, and no
metadata keyed by the extension's URI.
| Member | Written | Shape |
|---|---|---|
task |
First, on every task stream, unnumbered. On SendStreamingMessage, the task as GetTask would have answered when the message was taken: TASK_STATE_SUBMITTED for a new task, TASK_STATE_WORKING for one the message continued. On SubscribeToTask, the task exactly as GetTask answers when the stream opens, in any state but a terminal one |
An A2A Task |
statusUpdate |
Every change of a task's state, and every step the runtime reports while the task works | taskId, contextId, status, and metadata on an annotated event |
artifactUpdate |
Immediately before a TASK_STATE_COMPLETED status, when GetTask reports a response artifact for the task |
taskId, contextId, artifact — the response artifact exactly as GetTask reports it — and lastChunk: true |
message |
Only on murmur.WatchSession, carrying a murmur frame that belongs to no task |
An A2A Message from ROLE_AGENT |
A status holds state, timestamp and, when the runtime had something to say, message: an
A2A Message from ROLE_AGENT naming the task and its context, with one text part. A non-terminal
status's messageId is msg_ and a UUIDv7, unique within the session. A terminal status is exactly
the status GetTask reports for the task afterwards. Task.history is never written.
timestamp is when the status was recorded: the task's last change for the opening task and the
terminal status, and the moment it was written for every other status. See
Agent Card: Status timestamps.
status.state |
Murmur's word | status.message |
|---|---|---|
TASK_STATE_SUBMITTED |
submitted |
On the opening task of a new task only. No message |
TASK_STATE_WORKING |
working |
One of the working messages. No message on the opening task of a continued task |
TASK_STATE_INPUT_REQUIRED |
input-required |
The prompt a tool passed to request-input, or the prompt for a driver's safety checks on a tool call |
TASK_STATE_COMPLETED |
completed |
task completed |
TASK_STATE_FAILED |
failed |
task failed (<cause>): <reason> when the driver failed or rejected the credential, its response could not be acted on, or a compaction hook failed — <cause> is the task_failed cause and <reason> its reason; driver invocation failed: <error> when the driver could not be called; max_turns exceeded: … naming inference.max_turns; the spend refusal when a spend ceiling stopped the task; input-timeout when a request-input wait timed out; the refusal naming lifecycle.max_task_reopens or inference.max_turns when an on-task-end hook still wanted a reopen that limit did not allow; the error, as error[<code>]: <message> or its text, when the task ended in a runtime error |
TASK_STATE_CANCELED |
canceled |
task canceled for a running task; task canceled before it started for a queued one; task canceled; the harness was killed and its session may not resume cleanly when a transport: process harness had to be killed |
TASK_STATE_REJECTED |
rejected |
Refused at the door: task rejected: capsule is busy or task rejected: the session is closing, as the one unnumbered task event of its stream. Refused when the session ended: task rejected: the session ended before this task started, or task rejected: the session was stopped before this task started under mur stop, as a numbered statusUpdate |
Working status messages
A TASK_STATE_WORKING status says what the task is doing:
status.message |
Written when |
|---|---|
inference turn <n> |
At the start of each inference turn, counted from 1 across every attempt of the task |
resumed |
A TASK_STATE_INPUT_REQUIRED wait is answered |
reopened by hook <hook> |
An on-task-end hook reopens the task |
waiting on call-member: <n> call(s) outstanding |
call-member calls are unanswered |
waiting on delegate-task: <n> delegation(s) outstanding |
Delegations the task started have not reported back |
continuing with <n> member answer(s) |
The task continues with the member answers that arrived |
continuing with <n> delegation outcome(s) |
The task continues with the delegation outcomes that arrived |
keep working <n> of <limit> |
lifecycle.keep_working sends its message after a turn end |
done check failed, reopened <n> of <limit> |
lifecycle.done_check's command failed and the task reopens |
When both kinds are outstanding, or both kinds arrived, the two messages are joined with ; in
one status.
What SendStreamingMessage writes, in order
- The response headers.
- The
task, unnumbered. - When the request carried
Last-Event-ID: agapwhen one applies and the connection activated the extension, then every buffered event of this task with an id above the cursor — see Replay. A replayed terminal status ends the stream; a replayedTASK_STATE_INPUT_REQUIREDdoes not, since the task has left that state. - The task's live events,
laggedframes when activated, and heartbeats, until the task's terminal status or a liveTASK_STATE_INPUT_REQUIREDstatus.
Only events whose taskId is the connection's task are written.
To answer a stream that ended at TASK_STATE_INPUT_REQUIRED:
- Read the prompt from that status's
status.message. - Send a
SendStreamingMessagewhosemessage.taskIdis the task's id. Amessage.contextId, if sent, must be the task's, or the request is answered-32602. SetLast-Event-IDto the last id the first stream carried to receive anything of the task written since. - The new stream opens with the task in
TASK_STATE_WORKINGand continues as above.
A SendMessage naming the task delivers the same reply without a stream.
What SubscribeToTask writes, in order
- The response headers.
- The
task, unnumbered, asGetTaskanswers it when the stream opens. The connection starts receiving events before the task is read, so no later event is lost; an event written between the two is in thetaskand is also delivered. - When the request carried
Last-Event-ID: the replay, exactly as onSendStreamingMessage. - The task's live events,
laggedframes when activated, and heartbeats, until the task's terminal status.
The one difference from SendStreamingMessage is the end: a TASK_STATE_INPUT_REQUIRED status
does not end a subscription. The reply comes on a SendMessage or SendStreamingMessage naming the
task, and the subscription then carries the task's TASK_STATE_WORKING status with message
resumed.
The launch's own task — mur run --task, or a task.md in the workdir — is a live task from the
moment the door is announced, so a subscription to it, found with
ListTasks, carries its events to its terminal status like any other.
What murmur.WatchSession writes, in order
- The response headers.
- A
connection-ackframe. - The replay, from
Last-Event-ID, or from0when the header is absent: agapframe if one applies, then every buffered event. - Every task's live events, murmur frames included,
laggedframes and heartbeats, until the capsule's stream ends. A capsule process that exits closes the connection without acapsule-closedframe.
One final status
Every task the capsule accepts ends in exactly one terminal statusUpdate, whether it ran or not,
and it is the task's last event. It is written after GetTask answers
the same state, and its status is the one GetTask reports. For a task that ran, it is written
after every on-task-end hook has run.
| How the task ended | status.state |
Before it |
|---|---|---|
| An attempt completed and no hook reopened it | TASK_STATE_COMPLETED |
The response artifactUpdate, when the answer is not empty |
A hook still wanted a reopen that lifecycle.max_task_reopens or inference.max_turns did not allow |
TASK_STATE_FAILED |
— |
| The last attempt failed | TASK_STATE_FAILED |
— |
| The task was cancelled | TASK_STATE_CANCELED |
— |
| The session ended before the task started | TASK_STATE_REJECTED |
— |
A request-input wait that times out ends the attempt, not the task: the tool that asked fails,
and the task's terminal status — TASK_STATE_FAILED with message input-timeout — follows once
the hooks have run, unless a hook reopens the task.
Reopened tasks
An on-task-end hook that reopens a task
starts a new attempt of the same task id. Between the two attempts the stream carries one
TASK_STATE_WORKING status with message reopened by hook <hook>. An activated connection reads
the reopen's ordinal, counted from 1, as metadata[<URI>].reopen on that status — the only
annotation the extension makes on a standard event:
{"statusUpdate":{"taskId":"tsk_0199c4e2f1b7712a9d3e4f5061728394","contextId":"ctx_0199c4e2f1b7712a9d3e4f50617283a1","status":{"state":"TASK_STATE_WORKING","message":{"messageId":"msg_0199c4e2f2117c0aa1b2c3d4e5f60718","contextId":"ctx_0199c4e2f1b7712a9d3e4f50617283a1","taskId":"tsk_0199c4e2f1b7712a9d3e4f5061728394","role":"ROLE_AGENT","parts":[{"mediaType":"text/plain","text":"reopened by hook review-gate"}]},"timestamp":"2026-10-13T09:00:00.123Z"},"metadata":{"https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1":{"reopen":1}}}}
| What a client sees | Reading |
|---|---|
text, thinking and artifact frames of a rejected attempt |
Written live and never withdrawn |
| The boundary status | The rejected attempt is over; the next attempt starts with inference turn <n>, numbered on from the rejected attempt's last turn |
The response artifact |
The task's answer |
Never join text frames across a boundary: the joined text mixes a rejected answer with the
accepted one.
The stream extension
The murmur frames — streamed text and reasoning, tool calls being written, tool results, and the
connection-level frames — are an A2A extension with URI
https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1. It is listed in the
agent card's capabilities.extensions after the door and capsule extensions, with
required: false — see Agent Card: Extensions. On the public card of a
door that declares network.authentication it follows the door extension, and the extended card
carries it too.
{
"uri": "https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1",
"description": "The murmur frames this capsule writes in addition to the A2A v1.0 events: sent on SendStreamingMessage and SubscribeToTask when the request's A2A-Extensions header names this URI, and always on murmur.WatchSession. Each is carried in a StreamResponse under metadata keyed by this URI.",
"required": false,
"params": {"frames": ["text", "thinking", "tool-call-started", "tool-call-progress", "artifact", "gap", "lagged", "connection-ack", "capsule-closed"]}
}
| Key | Type | Notes |
|---|---|---|
params.frames |
array of strings | The frames a connection to this door can receive, in the order of the table below |
| Frame | A task stream, activated | murmur.WatchSession |
|---|---|---|
text |
yes | yes |
thinking |
yes | yes |
tool-call-started |
yes | yes |
tool-call-progress |
yes | yes |
artifact |
yes | yes |
quota |
yes, on a transport: process capsule |
yes, on a transport: process capsule |
suggested-prompt |
yes, on a transport: process capsule |
yes, on a transport: process capsule |
gap |
yes, when the request sent Last-Event-ID |
yes |
lagged |
yes | yes |
connection-ack |
no | yes, first event |
capsule-closed |
no | yes, as the last event when it is written |
Read the list from the capsule's card rather than from this table. The example above is a
transport: http capsule's; a transport: process capsule's lists quota and suggested-prompt
after artifact.
Activation
| Request | Connection |
|---|---|
SendStreamingMessage or SubscribeToTask whose A2A-Extensions headers name the URI |
Activated. The response head's A2A-Extensions names the URI |
SendStreamingMessage or SubscribeToTask without it |
Plain: the standard events of its task and nothing else. The response head does not name the URI |
murmur.WatchSession |
Always activated, whatever the request says. The response head's A2A-Extensions names the door extension's URI, then this one |
SendMessage, GetTask, ListTasks, CancelTask, murmur.StopSession, GetExtendedAgentCard |
Never activated. The response head does not name the URI |
The door reads every A2A-Extensions request line, header name case-insensitive, splits each
value on ,, trims each entry and drops empty ones. The connection is activated when one entry
equals the URI exactly. Any other URI in the header is ignored, the door's and capsule's own
extension URIs included. No method refuses a request for not activating the extension.
A response names the door extension's URI when the request used
that extension, whatever its A2A-Extensions said: on murmur.WatchSession, and on a
SendStreamingMessage whose message.metadata holds the door URI, which carries
forgetSession. The door URI comes first:
A2A-Extensions: <door URI>, <stream URI>.
Carriers
An activated connection receives each murmur frame inside an event that still parses as an A2A
v1.0 StreamResponse, under metadata keyed by the URI. The object there always has a frame
key naming the frame; the reopen annotation has none, which is how a client tells them apart.
| The frame belongs to | Carrier |
|---|---|
A task (text, thinking, tool-call-started, tool-call-progress, artifact, and gap and lagged on a task stream) |
A statusUpdate of the task repeating its state with no status.message, its status.timestamp the moment the carrier was written. A task's frames are written only while it works, so the state is TASK_STATE_WORKING. A gap repeats the opening task's state; a lagged repeats the state of the last task or status the connection wrote |
No task (connection-ack, capsule-closed, and gap and lagged on murmur.WatchSession) |
A message from ROLE_AGENT, messageId msg_ and a UUIDv7, no contextId, and one text part holding the frame's name |
{"statusUpdate":{"taskId":"tsk_0199c4e2f1b7712a9d3e4f5061728394","contextId":"ctx_0199c4e2f1b7712a9d3e4f50617283a1","status":{"state":"TASK_STATE_WORKING","timestamp":"2026-10-13T09:00:00.123Z"},"metadata":{"https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1":{"frame":"text","text":"README.md describes","final":false}}}}
{"message":{"messageId":"msg_0199c4e2f0a77e11a2b3c4d5e6f70819","role":"ROLE_AGENT","parts":[{"mediaType":"text/plain","text":"connection-ack"}],"metadata":{"https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1":{"frame":"connection-ack","role":"observer","conversationMode":"stateless"}}}}
The frame sections below show the object under metadata[<URI>].
Transports
A task writes the same events whatever the capsule's inference.transport:
the same kinds, the same keys and the same order for the same work, apart from the differences
listed below. On a transport: http capsule, the text, thinking, tool-call-started and
tool-call-progress frames come from the driver's
murmur:stream/events calls. On a transport: process
capsule they come from the events its process driver reads out of the
harness:
| The harness | Events |
|---|---|
| Starts text, reasoning or a tool call | A TASK_STATE_WORKING status, message inference turn <n> |
| Streams a fragment of text | text, "final":false |
| Reports the complete text of what it streamed | text, "final":true, empty — the client keeps the fragments it was sent |
| Streams a fragment of reasoning, or reports reasoning nothing streamed | thinking |
| Begins writing a tool call | tool-call-started |
| Reports how much of it is written | tool-call-progress |
| Answers a tool call | artifact |
| Ends the turn | One artifact frame for each artifact an on-inference hook returned for that turn, then the whole result as one "final":true text frame when the last turn streamed nothing, then the response artifact and the task's TASK_STATE_COMPLETED status |
| Fails the turn | The task's TASK_STATE_FAILED status |
| Reports its subscription windows | quota |
| Predicts the person's next prompt | suggested-prompt |
A process task ends in the same one final status as an http task, on every
path out of its last attempt, including the ones a runtime diagnostic ends: the status message is
that diagnostic, under the code mur reports it as.
On a process capsule the harness runs the turn, so its artifact frames differ in these keys:
| Key | On a process capsule | Why |
|---|---|---|
content |
The harness's report of the result, with each image the tool server returned replaced by [image: <media_type>, <n> bytes, sha256 <hex>] |
Image bytes never reach a frame |
fenceSource |
The source murmur's tool server fenced the result under, as on an http capsule, when the harness's report of the result still holds that fence's opening marker. null for a call the tool server refused or could not dispatch, an operator-pinned skill, a result whose tool name and arguments match no tool-server call, a report that dropped the fence, and the harness's own built-in tools. So content can be fenced while fenceSource is null |
The harness reports a result with no reference to the tool-server call, so the runtime matches the two by tool name and arguments |
exitCode, truncated |
Always null and false |
A process driver reports no exit status or truncation flag |
attachments |
Always [] |
Nothing is stored, so there is no address to fetch an image from |
A provider retry writes no event on either transport; the driver reports it to the trace as
harness_retry.
Event order within a task
One attempt of a task writes its events in this order:
- A
TASK_STATE_WORKINGstatus, messageinference turn <n>, at the start of each inference turn, before the driver is called. - While the turn runs,
thinkingandtext"final":falseframes, in the order they are produced. Atool-call-startedframe for each tool call the model begins writing, and that call'stool-call-progressframes, come after the turn'sTASK_STATE_WORKINGstatus and before the call'sartifact. On atransport: httpcapsule they come before the turn's"final":truetextframe of step 3. - When the turn streamed text, one
text"final":trueframe with empty text once the driver returns. - One
artifactper tool call, written when the call returns, in dispatch order. A tool's own streamedtextandthinkingframes, and theTASK_STATE_INPUT_REQUIREDandTASK_STATE_WORKINGresumedstatuses of itsrequest-inputwait, come before itsartifact. - The next turn repeats from step 1.
- When the attempt completes, one
artifactper hook artifact, then the whole reply as onetext"final":trueframe when the last turn streamed no text. - On a
transport: processcapsule,quotaandsuggested-promptframes wherever the harness reports them, including after the turn's end while the harness exits, and always before the steps below. - A reopen writes the reopen boundary, and the next attempt starts at step 1.
- The task's one final status is its last event, after its
responseartifact when it completed with one.
A plain connection receives steps 1, 4's statuses, 8 and 9 only.
text
A piece of the model's reply.
| Key | Type | Notes |
|---|---|---|
frame |
string | text |
text |
string | The piece of reply text |
final |
bool | See below |
final |
text |
Written when |
|---|---|---|
false |
A chunk | A streaming driver, or a tool, emits a chunk through murmur:stream/events |
true |
"" |
A streaming driver's inference call returned, or a transport: process harness reported the complete text of fragments it streamed. Marks the end of that turn's chunks |
true |
The whole reply | An attempt completed with a non-empty reply and no chunk was emitted during its last inference turn |
final on a text frame ends nothing: the task goes on to its statuses.
{"frame":"text","text":"README.md describes","final":false}
thinking
A piece of the model's reasoning, emitted by a streaming driver or a tool through
murmur:stream/events.
| Key | Type | Notes |
|---|---|---|
frame |
string | thinking |
text |
string | The piece of reasoning |
final |
bool | Always false |
{"frame":"thinking","text":"The file names two targets","final":false}
tool-call-started
A tool call the model has begun writing, before its input is complete and before the tool runs. Written when the capsule's driver reports calls as they are written:
| Transport | Source |
|---|---|
http |
The inference driver calls tool-call-started |
process |
The process driver sends the tool-call-started event |
| Key | Type | Notes |
|---|---|---|
frame |
string | tool-call-started |
toolCallId |
string | The call's id: the toolCallId of the artifact that answers it |
toolName |
string | The capsule's tool name, with no harness prefix: the toolName of the answering artifact |
| Property | Value |
|---|---|
| Once per call | At most one frame per toolCallId, before any tool-call-progress for the call and before its artifact |
| End of the call | The artifact with the same toolCallId ends the call. A call no artifact answers ends with the task's terminal status |
| Input | The frame carries none of the call's input |
| Turns | Opens no inference turn and does not count toward inference.max_turns |
| Optional | A call's artifact may arrive with no tool-call-started before it. A client treats the start as a hint, never as a prerequisite |
{"frame":"tool-call-started","toolCallId":"toolu_01","toolName":"write_file"}
tool-call-progress
How many bytes of a started call's input the model has written so far. Follows the call's
tool-call-started frame. On transport: http it comes from the
driver's tool-call-input-bytes calls; on transport: process,
from the process driver's tool-call-progress
events.
| Key | Type | Notes |
|---|---|---|
frame |
string | tool-call-progress |
toolCallId |
string | The call's id, as on its tool-call-started frame |
inputBytes |
u64 | Bytes of the call's input JSON written so far, cumulative for the call. Never smaller than an earlier frame's for the same toolCallId |
| Property | Value |
|---|---|
| Rate | At most one frame per call per 250 ms, plus one when the call's input is complete if the largest size reported has not been written yet |
| Count | Any number, including none. A client works with tool-call-started alone |
| Input | The frame carries a size and none of the call's input |
| Turns | Opens no inference turn and does not count toward inference.max_turns |
{"frame":"tool-call-progress","toolCallId":"toolu_01","inputBytes":3172}
artifact
One tool call's result, or one hook artifact, as the model received it. It is a murmur frame, never
an A2A artifact: GetTask reports none of them. The runtime writes one frame for each tool call it
dispatches, in dispatch order.
A hook artifact is what an on-inference hook returned as
artifact for the attempt's last inference
turn. The runtime writes one frame for each, in hook-registration order, only when the attempt
completes: after that turn's streamed text, before its whole-result text frame,
and ahead of the task's terminal status or the reopen boundary. A turn that
called a tool forwards none, and neither does an attempt that is canceled, fails or stops at a
spend ceiling.
What a refused call writes depends on what refused it and on the transport:
| Refused by | transport: http |
transport: process |
|---|---|---|
A policy hook or capabilities.filesystem.read_only |
No frame | One frame with isError true: the harness reports the refusal it was handed as its own failed tool call |
| A missing required field | One frame with isError true and the refusal text as content, the same frame as for a call that never reached a tool |
One frame with isError true, from the harness's report |
Every key is present on every frame, in this order. A key that does not apply to the frame is
null, never absent.
| Key | Type | Notes |
|---|---|---|
frame |
string | artifact |
toolName |
string | The tool, skill or hook the frame reports |
content |
string | Exactly the bytes the model received, never truncated. Fenced when fenceSource is a string. On an http capsule it is unfenced when fenceSource is null; on a process capsule it can be fenced either way, see Transports. The fence grammar is in Untrusted Fence |
fenceSource |
string | null | tool:<name> for a tool result, skill:<name> for a skill whose murmur.lock origin is runtime. null for an operator-pinned skill, a call that never reached a tool, and a hook artifact. A process capsule names it in fewer cases, listed under Transports |
toolCallId |
string | null | The provider's id for the call. null on a hook artifact, and on a call the provider gave no id or an empty one |
isError |
bool | true when the tool returned a status other than passed, or the call never reached a tool. Matches "status":"error" on the call's tool_call or skill_call trace event. false on a hook artifact |
durationMs |
u64 | null | Time the dispatch took, equal to the duration_ms on the call's trace event. null on a hook artifact |
exitCode |
i32 | null | The exit status of the subprocess the call ran to completion. null for a WASM tool, a skill, a command moved to the background, a call that never reached a tool, and a hook artifact |
truncated |
bool | The tool result's own truncated flag, unchanged. false on a call that never reached a tool and on a hook artifact |
attachments |
array | The images the call returned that the runtime accepted, in the order the call named them, each {"mediaType", "sizeBytes", "sha256", "url"}. url is /attachments/<task-id>/<sha256>, where the door serves the bytes: see Attachment bytes. A refused attachment is not listed. [] on every other frame |
isError and exitCode are separate facts. A shell command that runs to completion is a
successful call whatever its exit status, so bash reports "isError":false beside
"exitCode":3. Branch on exitCode to act on the command's own outcome.
| Frame | Example |
|---|---|
| A successful tool call | {"frame":"artifact","toolName":"bash","content":"<untrusted-content source=tool:bash>\nExit code: 0\nStdout:\nhello\n\n</untrusted-content>","fenceSource":"tool:bash","toolCallId":"toolu_01","isError":false,"durationMs":12,"exitCode":0,"truncated":false,"attachments":[]} |
| A call that never reached a tool | {"frame":"artifact","toolName":"no-such-tool","content":"tool 'no-such-tool' is not declared in manifest allowlist","fenceSource":null,"toolCallId":"toolu_03","isError":true,"durationMs":0,"exitCode":null,"truncated":false,"attachments":[]} |
| A tool call that returned an image | {"frame":"artifact","toolName":"screenshot","content":"<untrusted-content source=tool:screenshot>\nsaved shot.png\n</untrusted-content>","fenceSource":"tool:screenshot","toolCallId":"toolu_02","isError":false,"durationMs":40,"exitCode":null,"truncated":false,"attachments":[{"mediaType":"image/png","sizeBytes":48213,"sha256":"9f2c…","url":"/attachments/tsk_0199c4e2…/9f2c…"}]} |
| A hook artifact | {"frame":"artifact","toolName":"my-hook","content":"{\"reviewed\":true}","fenceSource":null,"toolCallId":null,"isError":false,"durationMs":null,"exitCode":null,"truncated":false,"attachments":[]} |
Attachment bytes
GET /attachments/<task-id>/<sha256> answers with the bytes of an image a tool call of that task
returned, the url its artifact frame lists. On a capsule declaring
network.authentication it takes a token holding the
GetTask scope, and reaches only the tasks that token sees; a public
door serves it as it serves GetTask.
| Request | Status | Body |
|---|---|---|
| A task the caller sees, and an image one of its tool calls returned | 200, content-type the image's media type, and content-length |
The image's bytes |
| No token, or one that is not valid | 401 |
The door's refusal |
A token without the GetTask scope |
403 |
The door's refusal |
| A task the registry does not hold or the caller does not see, or a hash none of its calls returned | 404 |
Empty |
Any method but GET |
405, allow: GET |
Empty |
quota
One report of the subscription windows a transport: process capsule's harness says are used, as
its process driver sent it. It is a snapshot of the windows it
names: a later frame replaces a window of the same name and leaves the others standing. The
merged view is the quota field of session-status.
| Key | Type | Notes |
|---|---|---|
frame |
string | quota |
windows |
array | At least one window |
windows[].name |
string | The window's name, such as five-hour |
windows[].used |
f64 | Fraction of the window used, 0 to 1. Absent when the harness did not report it |
windows[].resetsAtMs |
u64 | When the window resets, unix milliseconds. Absent when not reported |
windows[].windowMinutes |
u64 | The window's length in minutes. Absent when not reported |
windows[].state |
string | ok, warning or exhausted. Absent when not reported |
| Property | Value |
|---|---|
| Carrier | A TASK_STATE_WORKING status; the task keeps working |
| Turns | Opens no inference turn |
| Effect | None: the capsule reports a window and acts on none, however exhausted |
| Canceled task | Still written |
{"frame":"quota","windows":[{"name":"five-hour","used":0.42,"resetsAtMs":1793491200000,"windowMinutes":300,"state":"ok"},{"name":"seven-day","used":0.1}]}
suggested-prompt
What a transport: process capsule's harness predicts the person will ask next, as its
process driver sent it.
| Key | Type | Notes |
|---|---|---|
frame |
string | suggested-prompt |
text |
string | The predicted prompt, never empty |
| Property | Value |
|---|---|
| Carrier | A TASK_STATE_WORKING status; the task keeps working |
| When | Often after the turn's end, read while the harness exits; always before the task's final status |
| Turns | Opens no inference turn |
| Canceled task | Not written, as a canceled task delivers no answer |
{"frame":"suggested-prompt","text":"Run the test suite"}
gap
Written before a replay when events written after the client's Last-Event-ID are no
longer in the replay buffer, or when that id was never issued by this capsule session. Unnumbered.
On a task stream it is written only to an activated connection.
| Key | Type | Notes |
|---|---|---|
frame |
string | gap |
firstAvailableId |
u64 | The id of the oldest event in the replay buffer |
{"frame":"gap","firstAvailableId":37}
lagged
Written to a connection that fell more than 128 events behind and lost live events, before the next event it receives. Unnumbered, and only that connection receives it, once per loss. The count is not cumulative: a client that wants a total adds them up. On a task stream it is written only to an activated connection.
| Key | Type | Notes |
|---|---|---|
frame |
string | lagged |
missed |
u64 | The events this connection lost since the last event it received. At least 1 |
{"frame":"lagged","missed":40}
connection-ack
The first event on every murmur.WatchSession connection. Unnumbered.
| Key | Type | Notes |
|---|---|---|
frame |
string | connection-ack |
role |
string | Always observer |
conversationMode |
string | stateless or threaded — the capsule's lifecycle.conversation |
{"frame":"connection-ack","role":"observer","conversationMode":"stateless"}
capsule-closed
The last event on a murmur.WatchSession connection whose capsule closed its event stream while still
serving the connection. Unnumbered.
| Key | Type | Notes |
|---|---|---|
frame |
string | capsule-closed. No other key |
{"frame":"capsule-closed"}
A capsule process that exits closes the connection without writing this frame, whether it was
stopped with mur stop, ended after its task, or was killed. A client reads end
of stream without capsule-closed as a lost connection: the capsule may have exited or may still
be running, and only a new connection tells the two apart.
Events a connection misses
Each connection reads live events from a queue of 128. A connection that falls more than 128
events behind loses the oldest of them; an activated connection is written a
lagged frame naming how many before the next event it receives. The connection
stays open. The capsule also writes the count to its own stderr, as
SSE broadcast lagged by <n> events.
On a task stream, a lost event may be the status that ends the stream. From the first loss on, the connection checks its task's state at each heartbeat, and writes what it finds, unnumbered, then ends:
| The task | Written |
|---|---|
| Has ended | Its ending as GetTask reports it: the response artifact when it has one, then the terminal status |
Waits for input, on SendStreamingMessage |
A TASK_STATE_INPUT_REQUIRED status carrying the prompt. A SubscribeToTask connection writes nothing and stays open |
A murmur.WatchSession client that needs every event reconnects with the id of the last event it
received and replays the rest, which recovers them while they are still in the replay
buffer.
Heartbeat
A comment written to every open connection on both endpoints, so an idle capsule can be told apart from a closed connection by reading bytes.
| Property | Value |
|---|---|
| Wire form | :heartbeat\n\n — the comment line :heartbeat, then a blank line |
| Interval | 15 seconds, measured from when the connection was opened. The first one arrives about 15 seconds after connecting |
| Reset by events | No. A heartbeat comes due every 15 seconds whether or not events were written in between |
| Event id | None |
| Replay buffer | Never entered, so no replay contains one |
The heartbeat is written apart from the capsule's turns, so it keeps its cadence while a turn is running — through an inference call, a tool call, or a shell command the capsule is waiting on. Events wait on the call: a turn waiting on one call writes no event until the call returns, so heartbeats may be the only bytes on the connection for a while.
A pause in the heartbeat longer than 15 seconds means the capsule's process is alive but not being given time to write it:
- the process is suspended, for example by
SIGSTOPor a debugger - the host is too loaded to schedule the process
- the capsule is too busy serving other connections
A pause does not mean the capsule is gone. The connection closing is what ends the stream; what
that tells a client is under capsule-closed.
Event ids
| Event | Carries id: |
|---|---|
Every statusUpdate and artifactUpdate of a task |
yes |
Every murmur frame of a task: text, thinking, tool-call-started, tool-call-progress, artifact |
yes |
The opening task of a task stream, and a TASK_STATE_REJECTED task refused at the door |
no |
gap, lagged, connection-ack, capsule-closed |
no |
The ending or TASK_STATE_INPUT_REQUIRED status a lagged task stream writes in place of a lost one |
no |
| Heartbeat | no |
A capsule session numbers its events from one sequence:
| Property | Value |
|---|---|
| First id | 1 |
| Next id | Exactly one higher than the previous numbered event's, across every task, every reopened attempt and every event kind, in the order events are written |
| Uniqueness | Unique within a capsule session. A new session, including one started with mur run --resume, starts again at 1 |
| On one connection | Strictly ascending, never repeated, and not contiguous: a task stream skips other tasks' ids, and a plain one skips its own task's murmur frames |
An event without an id never takes a number, so it leaves no hole in the sequence. A
lagged frame is what reports live events a connection lost.
Replay
Each session keeps its most recent 512 events with ids in one replay buffer shared by both
endpoints, in the order they were written. When the buffer is full, a new event evicts the oldest.
The heartbeat and every unnumbered event — the opening task, a TASK_STATE_REJECTED task,
connection-ack, gap, lagged and capsule-closed — never enter it.
| Endpoint | Last-Event-ID sent |
Last-Event-ID absent |
Last-Event-ID not a number |
|---|---|---|---|
SendStreamingMessage, SubscribeToTask |
Replays this task's events after that id, following the opening task |
No replay | No replay |
murmur.WatchSession |
Replays every event after that id, following connection-ack |
Replays from 0 |
Replays from 0 |
The header name is case-insensitive. A replay from id N:
| Buffer | Written |
|---|---|
| Empty | Nothing |
N was never issued by this session: it is the id the next event will take, or higher |
A gap naming the oldest id, then every event in the buffer |
The oldest event's id is greater than N + 1: events after N were evicted |
A gap naming the oldest id, then every event in the buffer |
| Otherwise | Every event in the buffer whose id is greater than N, in order |
A task stream writes the replay's events of its own task only, and its gap only when activated.
A replay from 0 of a buffer that has evicted nothing is every event the session has written,
starting at id 1. Live events follow the replay on the same connection, starting after the
highest id the replay held.
To reconnect a murmur.WatchSession, send the id of the last event received as Last-Event-ID. Without
a gap, the replay is exactly the events written after that id. With one, the events between that
id and firstAvailableId are gone, or the id came from an earlier capsule session, whose ids this
session numbers again from 1.
A SendStreamingMessage connection lost while its task works is not reopened by sending the message
again: a message naming a working task is refused -32004. Send a SubscribeToTask naming the task,
with the last id received as Last-Event-ID.
Unknown frames and keys
| On receiving | A client |
|---|---|
A StreamResponse member it does not know |
Ignores the event |
A frame it does not know |
Ignores the event |
| A JSON key it does not know, at any depth | Ignores the key |
A line beginning with : |
Skips the line |
These rules are what let the capsule add frames, keys and comments without breaking a client that follows them.
What mur watch implements
mur watch is a murmur.WatchSession client.
| Protocol feature | mur watch |
|---|---|
A2A-Version |
Sends A2A-Version: 1.0 |
Last-Event-ID |
Sends Last-Event-ID: 0, so it replays the buffer on attach |
id: lines |
Read, only to report where a lost connection stopped |
| Reconnecting | Does not reconnect |
statusUpdate |
Printed to stdout as [<state>] <message>, the state in murmur's word; a task's terminal status as [<state>] alone |
artifactUpdate response |
Held, and printed wrapped under its task's [completed] line |
task |
Ignored |
text, artifact |
Printed to stdout; text is printed as it arrives, and the next status or artifact line after text that did not end its line starts on a new line |
thinking |
Not shown |
tool-call-started, tool-call-progress |
Not shown |
quota, suggested-prompt |
Not shown |
connection-ack |
Read for conversationMode, which sets how status lines are labelled |
| Heartbeat | Read and discarded |
gap |
A warning on stderr naming firstAvailableId |
lagged |
A warning on stderr naming missed |
| Unknown members, frames and keys | Ignored |
capsule-closed |
Prints [murmur] capsule closed to stderr and exits 0 |
| Connection lost | Exits 1 with E-IO-003: connection to <session id or address> lost after event id <n>, or lost before any event, followed by the capsule may still be running; run mur watch again to reattach |