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

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

When 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)?.status

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.

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.

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.