Skip to content
Developer Preview

Resume a session and verify what came back

Your app restarted, or the machine hosting the agent went away, and all you kept was a session id. This page shows you how to pick that conversation back up — and, more importantly, how to check that the state you need actually came back with it.

The one idea to take away: “resume worked” means “the state survived”, not “the call returned”. A resume that answers you is not the same as a session you can safely take a turn on. The answer carries everything you need to decide that, so read it.

session/resume answers with four things, and each one is there for a different job:

  • session — who this conversation is. Its identity, the workspace it runs in, and the provider and model it will actually use for the next turn.
  • history — what was already said. Ask for it inline and you get the full item array; ask to leave it out and you get session metadata only, and page the history separately when your UI needs it.
  • pendingRequests — what is still waiting on your user. A tool approval or a question the agent asked before you disconnected does not disappear while you are away; you rejoin owing an answer.
  • viewCursor — where your event subscription now begins. Everything the session emits after this cursor arrives on this connection.

Spawn a host, resume the session, and read the answer:

import { MuseClient } from "@muse-code/sdk";
const client = await MuseClient.spawn({
museBin,
args: ["serve"],
clientInfo: { name: "my_app", version: "1.0.0" },
});
const session = await client.resumeSession({
sessionId,
// Say what you want explicitly. `false` asks for the history inline;
// `true` gets you metadata only and leaves paging to you.
excludeItems: false,
});
// The typed wire answer, discriminated by the verb that opened the session.
const opening = session.opening;
if (opening?.verb !== "session/resume") throw new Error("not a resume");
const resumed = opening.result;

These are the checks worth writing. They are cheap, and each one is a bug your user would otherwise discover for you:

// It is the session you asked for, in the workspace you expect. A resume that
// hands you a different workspace root means your file paths are wrong.
if (resumed.session.sessionId !== sessionId) throw new Error("wrong session");
if (resumed.session.workspaceRoot !== expectedWorkspaceRoot) {
throw new Error("the resumed session names a different workspace");
}
// It still knows what it runs on. This is the one to check by hand: a resumed
// session that forgot its provider and model would take your user's next turn
// somewhere they did not choose. Both are declared nullable, so a null here is
// a real answer — "this session has no effective model" — and not a type
// nuisance to cast away.
if (resumed.session.providerId === null || resumed.session.modelId === null) {
throw new Error("the resumed session lost its effective model");
}
// And check it is the SAME one. Non-null is not enough: a session resumed onto
// some other model reads as success everywhere except the answer and the bill.
if (resumed.session.modelId !== modelYourUserChose) {
throw new Error("the resumed session came back on a different model");
}
// The history you asked for is actually here, inline. `mode` reports what was
// SERVED, never what you asked for, so read it rather than assuming.
if (resumed.history.mode !== "inline") {
// The host downgraded your request — page the history with `view/page`
// instead of rendering an empty transcript.
}
// Anything still waiting on your user. Empty is the common case; non-empty
// means your UI owes somebody a prompt the moment it repaints.
for (const pending of resumed.pendingRequests) {
// pending.kind is "approval" or "userInput" today, and the vocabulary is
// OPEN — treat anything else as a future kind you do not handle yet rather
// than writing an exhaustive switch that breaks on a newer host.
// pending.viewCursor is where it opened.
}
// Where your subscription starts. Hold on to it: it is the cursor you hand
// back on your NEXT resume to ask for only what you missed.
//
// Store it, do not read it. A cursor is opaque: it is not ordered text you can
// compare, and empty-vs-non-empty carries no meaning — a session with nothing
// in its view yet legitimately hands you an empty string. Treating that as a
// failure invents a rule the protocol does not have.
const cursor = resumed.viewCursor;

If you kept the cursor from last time, send it. The host then returns the suffix — only what happened after that point — instead of replaying a conversation your UI already has:

{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"session/resume\",\"params\":{\"commandId\":\"0198f0ab-8888-7000-8000-0000000000c2\",\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"cursor\":\"v:0198f0aa-1111-7000-8000-0000000000aa:405\"}}"}

When the cursor is still inside the history the host retains, it serves no history at all — the answer’s history.mode is "none" — because you already have everything up to that point. What follows on the wire is the live tail, picking up exactly where you left off.

Read history.mode; do not assume you got a suffix

Section titled “Read history.mode; do not assume you got a suffix”

A cursor can be perfectly real and still be older than the history the host still keeps folded. That case succeeds, and it does not give you a suffix: the host falls back to serving a snapshot and tells you so, with a history.mode that is something other than "none".

This is the branch most worth writing, because getting it wrong is silent. A client that assumes a cursor resume always means “suffix” will take a served snapshot and append it to the transcript it already has, duplicating the conversation on screen with no error anywhere.

And when the answer is "none", that alone does not mean “you are current” — it only means no history was served, which has several causes. noneReason says which, and exactly one of its values means your transcript is still good:

const { history } = opening.result;
if (history.mode === "none") {
if (history.noneReason === "cursorSuffix") {
// The ONLY "you are current" answer. Your transcript is good up to the
// cursor and the events that follow on the wire extend it.
} else {
// No history was served for some other reason, so your transcript is NOT
// current. Do not render what you already had as if it were.
//
// Most reasons recover by paging with `view/page` — it was excluded, or it
// did not fit the budget. The exception is `projectionUnavailable`, where
// paging cannot help either: show the transcript as unavailable instead of
// paging a session that can never serve it.
}
} else {
// A snapshot or inline items, not a suffix. REPLACE your view from what came
// back rather than appending to it.
}

Treat an absent or unrecognised noneReason as “not current” too — a host older or newer than your SDK is exactly when guessing costs you. The full vocabulary is on the generated SessionHistory reference.

Read the members, always. mode reports what was actually served, never what you asked for.

Send a cursor you were given, never one you built

Section titled “Send a cursor you were given, never one you built”

A cursor is opaque. It is not a number to increment, a timestamp, a value to construct from a session id, or a string whose emptiness tells you anything — and a host that is handed one it never issued refuses the resume rather than guessing what you meant — the wire excerpt below shows that refusal.

One value escapes that refusal, and it is the one you are most likely to produce by accident. The empty string is a defined cursor meaning “before anything happened”, so it is never refused as unknown — you never get the notFound refusal shown below for it. Send it and you get the "none"/cursorSuffix answer above — no history — plus a subscription that starts at genesis, so the session’s whole view arrives as streamed events. That looks like success and behaves like a flood.

Once the host has pruned the front of its retained view, the same "" stops being servable from genesis and gets the stale-cursor treatment instead — the snapshot fallback from the section above, or the viewTruncated refusal shown below. So it is not even reliably a flood; it is whatever the retention floor makes it.

Build a cursor and get an error; build an empty one and get something that depends on how much history still exists. Send back what you were given, and if you have nothing to send, omit the parameter rather than passing "".

{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"session/resume\",\"params\":{\"commandId\":\"0198f0ab-8888-7000-8000-0000000000c4\",\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"cursor\":\"v:0198f0aa-2222-7000-8000-0000000000bb:9\"}}"}
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":9,\"error\":{\"code\":-32011,\"message\":\"unknown cursor anchor\",\"data\":{\"kind\":\"notFound\",\"reason\":\"missingAnchor\",\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\"}}}"}

notFound means the cursor never existed in this session’s view — including a cursor you kept from a different session. It is a client bug, not a state to recover from in the ordinary course, though recovering beats dropping the user.

There is one other typed refusal, and it is rare. When your cursor is genuinely old and even the fully-elided snapshot the host would have fallen back to still exceeds what it will materialize in one answer, it stops rather than skipping events silently, and tells you the earliest cursor it can still serve:

{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":9,\"error\":{\"code\":-32040,\"message\":\"requested range predates the retained view\",\"data\":{\"kind\":\"viewTruncated\",\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"earliestCursor\":\"v:0198f0aa-1111-7000-8000-0000000000aa:200\"}}}"}

Both arrive as an MspError, and both recover with the same call — ask again without a cursor — but they do not recover to the same place, so do not promise your UI otherwise:

  • After notFound, the retry is an ordinary resume and normally serves the history — the same budget ladder still applies, so read what came back.
  • After viewTruncated, it does not. The same budget that could not fit the elided snapshot still applies with no cursor, so the retry comes back history.mode: "none" with noneReason: "historyBudget". You get the session, its pending requests, the live tail and a fresh viewCursor — and no inline history. Fetch the transcript in pieces with view/page.
import { MspError } from "@muse-code/sdk";
try {
await client.resumeSession({ sessionId, cursor });
} catch (error) {
if (
error instanceof MspError &&
(error.kind === "notFound" || error.kind === "viewTruncated")
) {
// Either the cursor was never real, or it is too old to serve from.
// Resume WITHOUT one to get the session back, rather than dropping the
// user. Handle both: catching only `notFound` leaves your user stranded
// on the one case that is nobody's mistake.
const retry = await client.resumeSession({ sessionId, excludeItems: false });
// Then read `history.mode` and `noneReason` on what comes back. This
// retry is NOT guaranteed to carry history: after `viewTruncated` it
// answers "none" / "historyBudget", and the transcript is yours to page.
} else {
throw error;
}
}

You need:

  • Node 20 or newer.
  • An installed muse binary on your PATH, 1.0.2 or newer. That release added history.noneReason, which the suffix check below reads; against 1.0.1 or older, nine of this recipe’s ten steps pass and the suffix step fails with expected "cursorSuffix", got undefined. Everything else on this page works on any release.

Then run this one recipe from 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
MUSE_BIN=$(command -v muse) \
npm run recipes --workspace @muse-code/sdk-cookbook -- --only resume-and-verify

No credentials and no model are needed: the recipe never takes a turn, and the model it names at the start comes from the host’s own catalog.

The recipe does the whole round trip against a real host: it starts a session naming a model the host offers, shuts that host down cleanly, spawns a second one over the same state, resumes, and checks every field above on what came back — resumes again with the cursor it was served to prove the none/cursorSuffix answer, then proves the host refuses a cursor it never issued.

Two things it does at the start are harness setup rather than advice, and both exist to stop a check passing for the wrong reason:

  • It names a model. A host with no credentials configured resolves no default model, so a session started without one carries modelId: null from birth — and a check that the model survived would then pass on a session that never had one to lose.
  • It sets an approval mode (to the most restrictive one, so nothing here reads as a recommendation). That folds one real event into the session’s view. A session that has emitted nothing has the before-genesis cursor "", and the host answers "none"/cursorSuffix for that too — so the suffix check would pass while only exercising the degenerate case, never testing that a real minted cursor round-trips. The recipe fails loudly if that fold ever stops producing one.

The runnable source for this page is published in full, comments and all, as the resume-and-verify 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 transcripts, so this page cannot drift from what actually runs.

The harness spawns its hosts with MUSE_EXPERIMENTAL_SDK_ENABLED set because the serve surface is behind a default-off flag during the Developer Preview. That is not advice for your application — build against muse serve as a supported command.