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.
One id per intent
Section titled “One id per intent”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.
A different payload is not a retry
Section titled “A different payload is not a retry”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.
Track what is in flight
Section titled “Track what is in flight”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.
When the answer is no
Section titled “When the answer is no”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.
replayAnsweredretires the entry only forcommandRejected; every other error admitted nothing, the entry holds, and you keep retrying under the same id. restoreToComposer: trueis 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.
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 retry-without-double-submittingThis 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.
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 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.