Skip to content
Developer Preview

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.

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.

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.

@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 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.

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 queue-steer-reclaim

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.

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.

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.