Skip to content
Developer Preview

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.

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}"}

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"

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.

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 cancel-mid-turn

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.

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.

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.