Skip to content
Developer Preview

Retry without double-submitting

Your user hit send, the reply never came back, and now they are staring at a spinner. Do you send it again? This page shows you how to retry safely — and how to tell, when the answer finally arrives, whether your command ran, launched later, or was durably rejected.

The one idea to take away: the commandId is the retry. Submitting the same commandId with the same payload joins the command you already sent; it never runs it twice. Minting a fresh id for the “retry” is the double-submit this page exists to prevent.

The concept behind this page is covered in commands and idempotency; this is the executable version.

Connection.command mints one commandId per logical command and reuses it across its own retries — pass maxAttempts and transient nothing-admitted errors (overloaded, backpressured) are retried for you under the same id. To retry across a longer window than one call — a reconnect, your own retry button — mint the id first and pin it, so every attempt is the same command:

// Mint once, BEFORE the first attempt, so every retry reuses it.
const commandId = msp.connection.mintCommandId();
const ack = await msp.connection.command(
"turn/start",
{ sessionId, input: [{ type: "text", text: prompt }] },
{ commandId, maxAttempts: 3 },
);

On the wire, a command and its ack look like this:

{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"goal/set\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a22-4010-7000-8000-00000000d010\",\"objective\":\"Replay me\"}}"}
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"commandId\":\"018f6a22-4010-7000-8000-00000000d010\",\"status\":\"accepted\",\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\"}}"}

And here is the same command submitted again — a new JSON-RPC request id, because it is a new request, but the same commandId, because it is the same command. The ack is value-identical, down to the same turnId, and nothing runs a second time:

{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"goal/set\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a22-4010-7000-8000-00000000d010\",\"objective\":\"Replay me\"}}"}
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{\"commandId\":\"018f6a22-4010-7000-8000-00000000d010\",\"status\":\"accepted\",\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\"}}"}

The tested recipe asserts both halves: the replay ack carries the same values as the original, and exactly one goal change ever folds into the session view. The server does not even distinguish a first ack from a replay ack — a duplicated line, a reconnect replay, and a nervous retry all land in the same place.

Reusing a commandId with different content is a client bug, and the SDK refuses it locally before anything reaches the wire:

await msp.connection.command("goal/set", { sessionId, objective: "Ship it" }, { commandId });
// Throws "commandId ... was reused with a different payload" — locally,
// before any frame is sent. A retry repeats the SAME intent.
await msp.connection.command("goal/set", { sessionId, objective: "Revert it" }, { commandId });

A server sees the same mismatch as a rejection with the command_id_conflict reason. Either way the answer is the same: new intent, new id.

Between “the user hit send” and “the command folded into the view” your UI wants to render something. PendingCommandSet is the SDK’s fold for exactly that window — record the submit before you send, record the ack when it arrives:

import { PendingCommandSet } from "@muse-code/sdk";
const pending = new PendingCommandSet<string>();
const commandId = msp.connection.mintCommandId();
pending.submitted({ commandId, input: prompt });
const ack = await msp.connection.command(
"turn/start",
{ sessionId, input: [{ type: "text", text: prompt }], ifBusy: "queue" },
{ commandId },
);
pending.acked(commandId, {
turnId: ack["turnId"] as string,
disposition: ack["disposition"] as string,
});

Read the entries back with pending.list() — each is a PendingCommandEntry row: the input, the ack so far, and where it renders. (When you use the Session facade, it owns one of these sets and exposes it as session.pending, a PendingCommandView; here we drive the set directly.)

The ack’s disposition tells you what admission decided. A "queued" submit is the interesting one: the host was busy, so the ack pre-mints the turn that will run your input and nothing has run yet:

{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"turn/start\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a32-2222-7000-8000-0000000000b2\",\"input\":[{\"type\":\"text\",\"text\":\"Run the queued release checks\"}],\"ifBusy\":\"queue\"}}"}
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{\"commandId\":\"018f6a32-2222-7000-8000-0000000000b2\",\"status\":\"accepted\",\"turnId\":\"018f6a32-2222-7000-8000-0000000000b2\",\"startedNewTurn\":false,\"disposition\":\"queued\"}}"}

How an entry usually retires: it materializes

Section titled “How an entry usually retires: it materializes”

The common case is happy: your command becomes real work, and the view says so. A userMessage item carrying your commandId folds in, and the entry retires in favour of the real item:

msp.connection.onNotification((notification) => {
if (notification.method !== "item/completed") return;
const item = notification.params?.["item"] as Record<string, unknown> | undefined;
if (typeof item?.["commandId"] !== "string" || typeof item["itemId"] !== "string") return;
const retirement = pending.observedUserMessage(item["commandId"], item["itemId"]);
if (retirement?.kind === "materialized") {
// Stop rendering the optimistic entry; the real item replaces it.
}
});

A userMessage whose commandId matches no local entry is another client’s submission on the same session — render it plainly; the same join that retires your entries de-duplicates everyone else’s echoes.

For a queued entry there is one more beat. A queued turn’s fate is decided at its launch boundary, so when another turn starts or completes — queue movement — the set asks you to re-verify what is still waiting:

// On turn/started or turn/completed for a turn that is not the entry's own:
for (const commandId of pending.observedQueueMovement(turnId)) {
// Replay it: same method, same payload, same commandId. The answer is
// either the ack you already have, or the settlement below.
}

In the tested recipe’s launch arm, the launch itself wins that race: the queued turn starts and folds its commandId-bearing userMessage before any replay is needed, and the entry retires materialized exactly like the simple case.

The reject arm plays the unhappy path: the queued turn’s launch fails. The pre-minted turn folds a failed turn/completed — with a launch error, and no turn/started ever — and replaying the commandId now answers the settlement, a durable commandRejected:

{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":5,\"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\":5,\"error\":{\"code\":-32030,\"message\":\"queued turn launch failed: provider unavailable\",\"data\":{\"kind\":\"commandRejected\",\"commandId\":\"018f6a32-3333-7000-8000-0000000000c3\",\"reason\":\"deferred_start_failed\"}}}"}

Feed that answer back into the set, and the retirement tells you what to do with the user’s words:

import { MspError } from "@muse-code/sdk";
try {
await msp.connection.command(
"turn/start",
{ sessionId, input: [{ type: "text", text: prompt }], ifBusy: "queue" },
{ commandId, maxAttempts: 1 },
);
} catch (error) {
if (!(error instanceof MspError)) throw error;
const retirement = pending.replayAnswered(commandId, {
kind: "error",
error: { code: error.code, kind: error.kind, reason: String(error.data["reason"] ?? "") },
});
if (retirement !== "held" && retirement.kind === "rejected") {
composer.restore(retirement.input); // give the user their words back
}
}

Two details worth noticing:

  • Only a durable rejection settles. replayAnswered retires the entry only for commandRejected; every other error admitted nothing, the entry holds, and you keep retrying under the same id.
  • restoreToComposer: true is an instruction. The input goes back where the user can see and resend it. Silently dropping it loses their words; silently resending under a fresh id runs work they only asked for once.

Rejection is not the only composer-restore path. A queued entry also retires reclaimed when the user pulls the queued turn back before it launches — the host folds turn/unqueued, you call observedReclaim, and the same restoreToComposer: true contract hands the input back. These transcripts cannot produce a reclaim, so this recipe does not play one — but the contract is the same: nothing is lost, nothing runs twice.

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 retry-without-double-submitting

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.

One detail in that source is test plumbing rather than guidance: the recipe passes each transcript’s recorded commandId explicitly, because the canned host replays its recorded acks verbatim and Connection.command verifies the echo. Against a real host the SDK mints a fresh UUIDv7 commandId for every logical command — your only job is to reuse it when you retry, exactly as shown above.

The runnable source for this page is published in full, comments and all, as the retry-without-double-submitting 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 frames above against the committed transcripts, so this page cannot drift from what actually runs.