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\":\"v6: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\":\"v6: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, a turn you started is a handle, and the handle has
both halves of this page on it: turn.cancel() sends the request, and
turn.completed settles with the outcome. The client folds every event the
host sends into your session as it arrives, so the turn’s terminal is never
missed — even when it lands before you start waiting for it.
Start the turn from a client you got from MuseClient.spawn, and keep the
handle. While it runs, turn.items() hands you each item as it changes —
here, the shell tool call your user is watching:
const session = await client.startSession({ workspaceRoot });const turn = await session.sendUserTurn({ input: [{ type: "text", text: "Run the flaky integration suite" }],});
// items() replays what the session already holds, then follows the turn,// so a fast host can never slip an item past you.for await (const item of turn.items()) { if (item.kind === "toolCall" && item.status === "inProgress") break;}session = await client.start_session(StartSessionOptions(workspace_root=workspace_root))turn = await session.send_user_turn( SendUserTurnOptions(input=[{"type": "text", "text": "Run the flaky integration suite"}]))
# items() replays what the session already holds, then follows the turn,# so a fast host can never slip an item past you.async for item in turn.items(): if item.get("kind") == "toolCall" and item.get("status") == "inProgress": breakWhen your user clicks Stop, ask for the cancel, then wait for the turn’s own terminal:
await turn.cancel(); // resolves with the ack: { status: "accepted", ... }
// The ack said "accepted"; the OUTCOME is the turn's terminal.const outcome = await turn.completed;// outcome.kind is "completed" and outcome.params.terminal is "cancelled".// The tool call that was running now reads status "cancelled":// session.fold.items.get(toolCallItemId)?.statusawait turn.cancel() # returns the ack: {"status": "accepted", ...}
# The ack said "accepted"; the OUTCOME is the turn's terminal.outcome = await turn.completed# outcome.kind == "completed" and outcome.params["terminal"] == "cancelled".# The tool call that was running now reads status "cancelled":# session.fold.items.get(tool_call_item_id)["status"]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: turn.cancel() rejects with an MspError whose kind is
commandRejected and whose reason is 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, and the client has already folded it into your
session. So turn.completed has settled, or is about to, and this branch is
just a read:
import { MspError } from "@muse-code/sdk";
try { await turn.cancel();} 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 // turn.completed already holds its outcome. } else { // Any other rejection is a real error: the host did not accept the // cancel, so nothing promises this turn is stopping. throw error; }}const outcome = await turn.completed;// outcome.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.from muse_code.connection import MspError
try: await turn.cancel()except MspError as error: if error.kind == "commandRejected" and error.data.get("reason") == "already_terminal": # The turn reached its own terminal first. Nothing is running, and # turn.completed already holds its outcome. pass else: # Any other rejection is a real error: the host did not accept the # cancel, so nothing promises this turn is stopping. raiseoutcome = await turn.completed# outcome.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, you tell your user the turn stopped when the host never agreed to
stop it.
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-turnNot 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 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.