Cancel a running turn
Your user clicked Stop. This page shows you how to cancel the turn they are watching — and, just as important, how to know it actually stopped.
The one idea to take away: cancelling is a request, not an outcome. The
host’s answer to turn/cancel only tells you it will try. The turn is over
when the turn itself says so, with a turn/completed event whose terminal is
"cancelled". Write your code to wait for that event, not for the cancel’s
reply.
The whole exchange on the wire
Section titled “The whole exchange on the wire”You started a turn and the host accepted it:
{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"turn/start\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\",\"input\":[{\"type\":\"text\",\"text\":\"Run the flaky integration suite\"}]}}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"commandId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\",\"status\":\"accepted\",\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\",\"startedNewTurn\":true,\"disposition\":\"started\"}}"}The agent is mid-work — a shell tool call is streaming output — when your
user clicks Stop. You send turn/cancel with the turn id you got back from
turn/start, and the host acknowledges the request:
{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"turn/cancel\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a21-0f0f-7aaa-bbbb-0123456789ab\",\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\"}}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{\"commandId\":\"018f6a21-0f0f-7aaa-bbbb-0123456789ab\",\"status\":\"accepted\",\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\"}}"}That "status": "accepted" is not the end of the story. The end of the story
is the turn reaching its own terminal. Notice what arrives with it: the tool
call that was running is completed with status "cancelled" — the history
stays balanced, nothing is silently dropped — and then the turn closes out:
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"method\":\"item/completed\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"viewCursor\":\"v:0198f0aa-1111-7000-8000-0000000000aa:8\",\"sourceRange\":{\"stream\":{\"kind\":\"session\",\"id\":\"0198f0aa-1111-7000-8000-0000000000aa\"},\"first\":{\"id\":\"018f9008-71aa-7000-8000-0000000000d9\",\"sequence\":221},\"last\":{\"id\":\"018f9008-71aa-7000-8000-0000000000d9\",\"sequence\":221}},\"item\":{\"itemId\":\"0198f0ad-0001-7000-8000-000000000010\",\"kind\":\"toolCall\",\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\",\"revision\":2,\"status\":\"cancelled\",\"recordedAt\":\"2026-08-07T18:42:45.000Z\",\"tool\":\"shell\",\"callId\":\"call_9f2c\",\"args\":\"{\\\"command\\\":\\\"cargo test -p tbh-agent\\\"}\",\"failureReason\":\"turn cancelled\",\"visibleOutput\":\" Compiling tbh-agent v0.9.4\\n\",\"truncated\":false}},\"emittedAtMs\":1754590945000}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"method\":\"turn/completed\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"viewCursor\":\"v:0198f0aa-1111-7000-8000-0000000000aa:9\",\"sourceRange\":{\"stream\":{\"kind\":\"run\",\"id\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\"},\"first\":{\"id\":\"018f9008-71aa-7000-8000-0000000000f1\",\"sequence\":140},\"last\":{\"id\":\"018f9008-71aa-7000-8000-0000000000f1\",\"sequence\":140}},\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\",\"terminal\":\"cancelled\",\"reason\":\"cancelled during tool execution\",\"durationMs\":5100},\"emittedAtMs\":1754590945100}"}The code
Section titled “The code”With @muse-code/sdk, the pattern has one rule that does all the work: install
your notification handler when the turn STARTS, not when you cancel. The
connection keeps a single, replace-only handler
(msp.connection.onNotification) and does not buffer — a turn/completed
that arrives while no handler is listening for it is gone. Record terminals
as they stream in, and every later question (“did my cancel land?”) becomes a
lookup. The tested recipe wraps exactly this in a small recorder with bounded
waits.
// ONE handler for the connection, installed before the turn starts. It// records every terminal, so a turn/completed can never slip past you.const completedTurns = new Map<string, Record<string, unknown>>();const waiters = new Map<string, Array<(params: Record<string, unknown>) => void>>();msp.connection.onNotification((n) => { if (n.method === "turn/completed") { const params = (n.params ?? {}) as Record<string, unknown>; const turnId = String(params["turnId"]); completedTurns.set(turnId, params); for (const resolve of waiters.get(turnId) ?? []) resolve(params); waiters.delete(turnId); }});
const ack = await msp.connection.command("turn/start", { sessionId, input });const turnId = String(ack["turnId"]);
// Check the record FIRST, then wait: the terminal may already be in.function terminalOf(turnId: string): Promise<Record<string, unknown>> { const already = completedTurns.get(turnId); if (already !== undefined) return Promise.resolve(already); // A LIST of resolvers per turn id: a single shared slot — or a // replace-only set per key — would let a second waiter silently evict // the first (a Stop handler plus a status indicator both awaiting one // turn), the same replace-only drop hazard this page warns about. return new Promise((resolve) => { const list = waiters.get(turnId); if (list === undefined) waiters.set(turnId, [resolve]); else list.push(resolve); });}When your user clicks Stop, send the cancel and read the turn’s own terminal:
await msp.connection.command("turn/cancel", { sessionId, turnId });
// The ack said "accepted"; the OUTCOME is the turn's terminal.const params = await terminalOf(turnId);// params["terminal"] is "cancelled"The race you must handle
Section titled “The race you must handle”There is a window where your user clicks Stop just as the turn finishes on
its own. Your cancel arrives after the turn already reached a terminal, and
the host refuses it: the command comes back rejected with the reason
already_terminal.
That is not a failure. It is the host telling you the thing you wanted — the
turn to stop running — is already true. And because the rejection travels the
same ordered stream as the turn’s events, the turn/completed you care about
was delivered BEFORE it — which is exactly why the recorder above exists. Had
you subscribed only when the cancel failed, the terminal would already be
dropped and a bare “keep waiting” would hang forever. With the recorder, this
branch is a read:
import { MspError } from "@muse-code/sdk";
try { await msp.connection.command("turn/cancel", { sessionId, turnId });} catch (error) { if ( error instanceof MspError && error.kind === "commandRejected" && error.data["reason"] === "already_terminal" ) { // The turn reached its own terminal first. Nothing is running, and the // recorder already holds its turn/completed. } else { // Any other rejection — a stale or mistyped turnId, a missing run — is // a real error: no turn/completed for this turn is coming, so // swallowing it here would wait forever. throw error; }}const params = await terminalOf(turnId);// params["terminal"] is whatever the turn reached first — "cancelled" if// your cancel won the race, or its own terminal if it finished on its own.Narrow the branch exactly like this: already_terminal is the only rejection
that means “already done”. If you skip this branch, your Stop button will
intermittently report an error for the most harmless timing in the system — a
turn that finished right as the user reached for it. If you widen it to every
rejection, a genuine mistake waits forever on an event that will never come.
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 cancel-mid-turnThis 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.
Cancelling 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 cancel-mid-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 exchanges 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.