Queue, steer, and reclaim turns
Your user sends a message while the agent is still working on the last one. Every chat-style UI hits this moment, and the protocol gives you three honest answers: start it later, fold it into the running work, or — the part this page is really about — let the user take it back before it runs.
The one idea to take away: the ack’s disposition is the routing
decision. Branch on that word. Never guess from history what happened to
the input you just sent.
Three dispositions, one field
Section titled “Three dispositions, one field”When the session is idle, turn/start begins a turn right away and the ack
says so:
{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"turn/start\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a32-1111-7000-8000-0000000000a1\",\"input\":[{\"type\":\"text\",\"text\":\"Review the current deployment state\"}]}}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"commandId\":\"018f6a32-1111-7000-8000-0000000000a1\",\"status\":\"accepted\",\"turnId\":\"018f6a32-1111-7000-8000-0000000000a1\",\"startedNewTurn\":true,\"disposition\":\"started\"}}"}"disposition": "started" — a fresh foreground turn began, and turnId is
the turn now running.
When a turn is already running, ifBusy says what you want done with the
input. "queue" — the default — parks it:
{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"turn/start\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a32-3333-7000-8000-0000000000c3\",\"input\":[{\"type\":\"text\",\"text\":\"Run the queued deployment checks\"}],\"ifBusy\":\"queue\"}}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{\"commandId\":\"018f6a32-3333-7000-8000-0000000000c3\",\"status\":\"accepted\",\"turnId\":\"018f6a32-3333-7000-8000-0000000000c3\",\"startedNewTurn\":false,\"disposition\":\"queued\"}}"}Two things in that ack deserve a careful read:
"disposition": "queued"and"startedNewTurn": false— nothing is running this input yet. It launches when the current turn reaches its terminal.- the ack still carries a
turnId. That is the pre-minted id of the turn that will carry this input. Keep it: it is how you name this queued submit from now on — to your user, and to the host.
The third disposition is "steered": the running turn absorbs the input
mid-flight instead of a new turn being created. You choose it per submit with
ifBusy: "steer" (“steer whatever is running, else start fresh”), or with
the exact-target turn/steer command, which names the turn it expects to
steer and fails if that turn is no longer active. Prefer turn/steer when
your UI is pointing at a specific running turn — the “whatever is running”
form can silently steer a turn that started after your user began typing.
What a steer absorbs depends on live model timing, so there is no canned
exchange to replay here; the two queue-side dispositions above are the ones
this recipe exercises end to end.
Un-send: reclaim the queued turn
Section titled “Un-send: reclaim the queued turn”A queued submit is durable — it survives the host restarting — and until it
launches it is listed in the session snapshot’s queuedTurns. If you render
that list, your user will expect to act on it. turn/unqueue, addressed by
the pre-minted turn id from the queueing ack, is the “un-send” gesture:
{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"turn/unqueue\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a32-5555-7000-8000-0000000000c5\",\"turnId\":\"018f6a32-3333-7000-8000-0000000000c3\"}}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":5,\"result\":{\"commandId\":\"018f6a32-5555-7000-8000-0000000000c5\",\"status\":\"accepted\",\"turnId\":\"018f6a32-3333-7000-8000-0000000000c3\"}}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"method\":\"turn/unqueued\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"viewCursor\":\"v:0198f0aa-1111-7000-8000-0000000000aa:6\",\"sourceRange\":{\"stream\":{\"kind\":\"session\",\"id\":\"0198f0aa-1111-7000-8000-0000000000aa\"},\"first\":{\"id\":\"018f9033-aaaa-7000-8000-000000000005\",\"sequence\":5},\"last\":{\"id\":\"018f9033-aaaa-7000-8000-000000000005\",\"sequence\":5}},\"turnId\":\"018f6a32-3333-7000-8000-0000000000c3\",\"commandId\":\"018f6a32-3333-7000-8000-0000000000c3\"},\"emittedAtMs\":1754591400400}"}Read the turn/unqueued notification closely: its commandId is the id of
the turn/start that queued the turn — the submission being handed back
— not the id of the unqueue command that reclaimed it. That is what lets a
client match the reclaim to the pending submit it still holds.
What the SDK does with the reclaim
Section titled “What the SDK does with the reclaim”@muse-code/sdk’s Session keeps a pending-command set: one entry per submit you
have made that the wire has not yet settled. Record what the user actually
typed when you submit, and every later settlement — including this one —
tells you exactly what to do with it:
// At submit time: mint ONE commandId, record the entry under it, and pass// the same id on the wire — turn/unqueued echoes it, and that echo is how// the reclaim finds this entry.const commandId = msp.connection.mintCommandId();session.pending.submitted({ commandId, input: draft });const ack = await msp.connection.command( "turn/start", { sessionId, input: [{ type: "text", text: draft }], ifBusy: "queue" }, { commandId },);const queuedTurnId = String(ack["turnId"]);session.pending.acked(commandId, { turnId: queuedTurnId, disposition: String(ack["disposition"]),});When the turn/unqueued notification folds through Session.apply, the
entry retires with the reason "reclaimed" — and the retirement carries the
original input back to you:
const { retirements } = session.apply(unqueuedNotification);for (const retirement of retirements) { if (retirement.kind === "reclaimed" && retirement.restoreToComposer) { // The exact text the user queued. Put it back in the composer: // "un-send" must never mean "delete my draft". composer.restore(retirement.input); }}The reclaimed turn also settles as a turn, with its own terminal kind:
const outcome = await session.turn(queuedTurnId).completed;// outcome.kind is "unqueued". No turn/completed will EVER carry this// turnId — a wait that only watches for turn/completed hangs forever.That last line is the bug this recipe exists to catch: a reclaimed turn never
ran, so it never completes. If your “wait for the turn to finish” only folds
turn/completed, the un-send path leaves it waiting on an event that will
never come.
The running turn never notices
Section titled “The running turn never notices”The reclaim is surgical. The turn that was active through all of this is untouched — it keeps streaming and reaches its own terminal:
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"method\":\"item/completed\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"viewCursor\":\"v:0198f0aa-1111-7000-8000-0000000000aa:7\",\"sourceRange\":{\"stream\":{\"kind\":\"run\",\"id\":\"018f6a32-1111-7000-8000-0000000000a1\"},\"first\":{\"id\":\"018f9033-1111-7000-8000-000000000003\",\"sequence\":3},\"last\":{\"id\":\"018f9033-1111-7000-8000-000000000003\",\"sequence\":3}},\"item\":{\"itemId\":\"0198f033-0001-7000-8000-000000000202\",\"kind\":\"agentMessage\",\"turnId\":\"018f6a32-1111-7000-8000-0000000000a1\",\"revision\":1,\"status\":\"completed\",\"recordedAt\":\"2026-08-07T18:50:21.010Z\",\"text\":\"The current deployment is ready for its queued checks.\"}},\"emittedAtMs\":1754591400500}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"method\":\"turn/completed\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"viewCursor\":\"v:0198f0aa-1111-7000-8000-0000000000aa:8\",\"sourceRange\":{\"stream\":{\"kind\":\"run\",\"id\":\"018f6a32-1111-7000-8000-0000000000a1\"},\"first\":{\"id\":\"018f9033-1111-7000-8000-000000000004\",\"sequence\":4},\"last\":{\"id\":\"018f9033-1111-7000-8000-000000000004\",\"sequence\":4}},\"turnId\":\"018f6a32-1111-7000-8000-0000000000a1\",\"terminal\":\"completed\",\"durationMs\":1300},\"emittedAtMs\":1754591400600}"}Nothing about queueing a second submit, or taking it back, changes the first turn’s outcome. Your UI can offer “un-send” on every queued entry without ever worrying about the work in flight.
Run it yourself
Section titled “Run it yourself”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:
git clone https://github.com/meta-models/muse-code-sdkcd muse-code-sdknpm ci && npm run build --workspace @muse-code/sdknpm run recipes --workspace @muse-code/sdk-cookbook -- --only queue-steer-reclaimThis 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.
Steering a genuinely running turn end-to-end needs a real model behind the host, so that arm is not part of the headless run.
Where the tested code lives
Section titled “Where the tested code lives”The runnable source for this page is published in full, comments and all, as the queue-steer-reclaim 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 exchanges above against the committed transcript, so this page cannot drift from what actually runs.
Since GA the SDK surfaces are on by default and MUSE_EXPERIMENTAL_SDK_ENABLED
survives only as an off-switch; this recipe’s canned fixture host takes no gate
variable at all. Build against muse serve as a supported command, with no
gate variable of your own.