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(...) on the running turn’s handle (the
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 — in
the SDK, unqueue() on the queued turn’s handle:
{"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\":\"v6: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, session.pending:
one entry per submit you have made that the wire has not yet settled. Pass
what the user actually typed as composerInput when you submit, and the SDK
records the entry before it writes anything to the wire — so your UI can draw
the “queued” bubble at once. sendUserTurn hands back the turn the ack named;
for a queued submit, that is the pre-minted turn:
const queued = await session.sendUserTurn({ input: [{ type: "text", text: draft }], ifBusy: "queue", composerInput: draft,});
// The entry your UI renders as "queued, not yet running".const entry = session.pending.list().find((held) => held.ack?.turnId === queued.turnId);// entry.ack.disposition is "queued"; entry.input is the draft.queued = await session.send_user_turn( SendUserTurnOptions( input=[{"type": "text", "text": draft}], if_busy="queue", composer_input=draft ))
# The entry your UI renders as "queued, not yet running".entry = next( held for held in session.pending.list() if held.ack is not None and held.ack["turnId"] == queued.turn_id)# entry.ack["disposition"] == "queued"; entry.input is the draft.When your user takes it back, call unqueue() on that handle. An accepted
reclaim retires the entry with the reason "reclaimed", and the retirement
carries the original input back to you:
const { ack, retirement } = await queued.unqueue();if (ack.status === "accepted" && retirement?.kind === "reclaimed") { // The exact text the user queued. Put it back in the composer: // "un-send" must never mean "delete my draft". composer.restore(retirement.input);}unqueued = await queued.unqueue()retirement = unqueued.retirementif unqueued.ack.get("status") == "accepted" and retirement is not None and retirement.kind == "reclaimed": # The exact text the user queued. Put it back in the composer: # "un-send" must never mean "delete my draft". composer.restore(retirement.input)For a turn your own session queued, an accepted unqueue() always returns
that retirement, whichever of the ack and the host’s turn/unqueued your
client reads first. It is empty only when your session holds no queued entry
for the turn, such as a turn another client queued.
The reclaimed turn also settles as a turn, with its own terminal kind:
const outcome = await queued.completed;// outcome.kind is "unqueued". No turn/completed will EVER carry this// turnId — a wait that only watches for turn/completed hangs forever.outcome = await queued.completed# outcome.kind == "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\":\"v6: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.\",\"messagePhase\":\"finalAnswer\"}},\"emittedAtMs\":1754591400500}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"method\":\"turn/completed\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"viewCursor\":\"v6: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-reclaimNot yet runnable from the public clone: the Python twins are not
mirrored to the public SDK repository yet, so this command needs a source
tree that carries clients/sdk-cookbook-py/. The twins run in CI on every
change to this area either way.
You need Python 3.10 or newer with the two SDK packages importable. The Python twin replays the same committed transcript through the same canned host:
PYTHONPATH=clients/sdk-cookbook-py/src \MUSE_CONFORMANCE_BIN=$PWD/target/release/muse-conformance \ python3 -m cookbook_recipes --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.