Skip to content
Developer Preview

Stream a turn's answer into your UI

You send a prompt and you want the answer to appear the way a person types it, not all at once several seconds later. This page shows you how to draw the agent’s message as it arrives — and how to be sure what you drew is what the host actually said.

The one idea to take away: deltas are for drawing, item/completed is the truth. Append deltas as they land so your UI feels alive, then let the completed frame have the last word.

A turn’s agent message arrives in three acts:

  • item/started — the message exists now, and it is empty. This is where you create the bubble, keyed by the itemId on the frame.
  • item/delta — a piece of text to append. Each one names the itemId it extends and the field it extends (text), so you always know where it goes.
  • item/completed — the message is final, and the frame carries the whole text. Replace what you accumulated with it.

The deltas themselves are small and boring, which is the point:

{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"method\":\"item/delta\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"viewCursor\":\"v:0198f0aa-1111-7000-8000-0000000000aa:7\",\"itemId\":\"0198f0ac-4242-7000-8000-000000000042\",\"field\":\"text\",\"delta\":\"All 214 tests pass\"},\"emittedAtMs\":1754590941100}"}
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"method\":\"item/delta\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"viewCursor\":\"v:0198f0aa-1111-7000-8000-0000000000aa:8\",\"itemId\":\"0198f0ac-4242-7000-8000-000000000042\",\"field\":\"text\",\"delta\":\" except two in tbh-agent...\"},\"emittedAtMs\":1754590941200}"}

Two frames, and together they spell All 214 tests pass except two in tbh-agent... — exactly the text the completed frame carries.

Subscribe once, before you start the turn, and route by method.

A notification’s params is Record<string, unknown> — the wire is not typed for you — so read each member by name and narrow it:

const drawn = new Map<string, string>();
msp.connection.onNotification((notification) => {
const params = notification.params ?? {};
switch (notification.method) {
case "item/started": {
// The message exists and is empty. Make room for it.
const item = params["item"] as { itemId: string };
drawn.set(item.itemId, "");
break;
}
case "item/delta": {
// Append — but only text deltas, and only to the item they name.
if (params["field"] !== "text") break;
const itemId = params["itemId"] as string;
const delta = params["delta"] as string;
drawn.set(itemId, (drawn.get(itemId) ?? "") + delta);
break;
}
case "item/completed": {
// The authoritative text. Take it over your own accumulation.
const item = params["item"] as { itemId: string; text: string };
drawn.set(item.itemId, item.text);
break;
}
case "turn/completed": {
// The turn is over. `params["terminal"]` says how it ended, and
// `params["usage"]` is what it cost.
break;
}
}
});

A real client would validate rather than cast, but the member names are the part that has to be right — those are the wire contract, and the tested recipe reads exactly these.

Subscribing before turn/start matters. The host may push the first frames as soon as it accepts the command, and a listener attached afterwards can miss the opening of the very message it exists to draw.

Why take item/completed over your own text

Section titled “Why take item/completed over your own text”

Your accumulation is a guess that no delta was dropped, reordered, or applied to the wrong item. The completed frame is the host telling you what the message is. Taking it is one line, and it makes those failure modes invisible to your user instead of permanent.

The tested recipe asserts the two agree — it accumulates the deltas exactly as the code above does and compares the result to item.text on the completed frame. If they ever diverge, the cookbook-journeys job goes red.

The deltas stopping does not mean the turn stopped. Wait for turn/completed:

// WRONG: the agent may still be working — running a tool, starting another
// message — long after the last delta of the first one.
if (noDeltasFor(500)) markTurnFinished();

turn/completed is the one frame that says the turn is over, and it carries the terminal (how it ended) and the usage (what it cost) that a UI shows when the answer settles.

You need Node 20 or newer and the SDK your own application depends on — npm install @muse-code/sdk. This recipe never spawns a real host, so an installed muse is not part of it. The recipe programs live in the SDK repository:

Terminal window
git clone https://github.com/meta-models/muse-code-sdk
cd muse-code-sdk
npm ci && npm run build --workspace @muse-code/sdk
npm run recipes --workspace @muse-code/sdk-cookbook -- --only stream-a-turn

This recipe replays a committed transcript through a canned host, so it needs no credentials and no model — but the canned host is a conformance fixture that is not published yet, so the command above reports the recipe as needing a host it cannot find. Until that fixture ships, the verified program on this page is the complete source of truth. The recipes that need only a real muse host — the fingerprint check, surviving a host crash, switching models mid-session — run end to end today.

The runnable source for this page is published in full, comments and all, as the stream-a-turn example. It is the source that runs, not a retelling of it: it is executed end to end on every change to this area, and every docs build checks the wire frames above against the committed transcript, so this page cannot drift from what actually runs.

The harness spawns its hosts with MUSE_EXPERIMENTAL_SDK_ENABLED set explicitly. muse serve now runs by default; the variable survives as an off-switch, and the harness pins the run open so an operator’s off in the environment cannot silently change what this page proves. That is not advice for your application — build against muse serve as a supported command.