Skip to content
Developer Preview

Commands and idempotency

Every state-changing method in MSP — session lifecycle and command plane alike — carries a client-generated commandId, a UUIDv7 the SDK mints once per logical command. The server never mints one, and a missing commandId is an invalidParams error: it is the only retry handle that survives reconnects.

The command plane is thin by design. A response tells you only what the runtime decided at admission — accepted, duplicate, or rejected — and everything that actually happened arrives on the session view as cursor-bearing events; a response payload is never the only place a fact lives. Successful acks share a common envelope, commandAcceptedResult: the echoed commandId, an accepted status, and per-method fields such as the turn id. Admission failures are typed JSON-RPC errors, with durable runtime rejections carried as commandRejected plus a stable snake_case reason string; unknown reasons must be treated as terminal rejections.

Commands require the target session to be loaded on this host: a command against an unloaded session fails sessionNotLoaded, and a session whose lease a foreign process holds fails sessionInUse.

Here is a stop command and its admission ack from a recorded conformance scenario — note the echoed commandId and that the ack asserts admission only, not that the turn has stopped:

{"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\"}}"}
  • Exactly-once effect. Submitting the same commandId twice — a retry after a dropped connection, a reconnect replay, a duplicated line — applies the command once, and the duplicate receives a value-identical ack: the same members carrying the same values, recursively. The server does not distinguish a first ack from a replay ack.
  • Durable dedupe. The idempotency ledger is not connection state or process memory: each accepted command writes a durable intake record before its ack is sent, so a commandId replayed after a host crash and restart still deduplicates.
  • Payload identity is checked. Reusing a commandId with a different payload is a client bug, not a retry: the server compares against the originally captured payload signature and rejects the mismatch with the command_id_conflict reason.

Rejections are idempotent too: replaying a commandId whose command was durably rejected returns the same rejection.

Cross-restart dedupe and the durable intake record

Section titled “Cross-restart dedupe and the durable intake record”

Before the server acks an admitted command, the runtime appends a command intake pair to the session log: a received record at admission and a settled record at the terminal outcome. Three consequences an SDK can rely on:

  • Ack-then-crash is safe. If you received an ack, the intake is durable; if you did not, replaying the same commandId is always safe for target-anchored commands.
  • Intake without settlement is pending, not lost. After a host restart, recovery settles it — usually as abandoned — and a replay of an abandoned intake answers a durable rejection with the abandoned reason, never a stale pending ack. Never invent a terminal client-side; observe the settlement through a commandId replay or the resume snapshot.
  • Late joiners see pending commands. The pending-command set is a log read, exactly like pending approvals — not connection state.

There are ruled exceptions to the durable ledger. The interrupt/cancel row and the background-task stop rows are deliberately carrier-less at v1, so their dedupe is host-lifetime-only — acceptable because the running work each targets cannot survive the restart. The practical guidance: target-anchored replays (an explicit turn id or task id) answer with no new effect, while target-less forms — a stop-all sweep, or an id-less interrupt or cancel — are re-resolved fresh at a restarted host and may act on work the original gesture never aimed at. Pass the explicit id whenever you have one.

On an ephemeral host the intake pair is still written and every idempotency property holds for the host’s lifetime, but nothing survives host death: a client must not replay a commandId at a new host after an ephemeral host dies. See durability profiles.

For a fresh turn, the run stream is derived from the submitting command’s id: the commandId becomes the turnId, so the ack, the turn/started event, and every item event of that turn carry the same string and correlate without a join table. A steered submit does not mint a new turn — its ack names the running turn that absorbed the input. A queued submit mints its follow-up turn’s id at admission, so the ack always names the turn that will run your input, whether it started immediately or was queued.

Treat turnId as opaque: the derivation is a correlation guarantee, not a format you may parse. And remember the two no-run exits when awaiting a pre-minted turn: reclaim folds turn/unqueued with no completion event ever, and a failed launch folds a failed turn/completed with no started event.