Classify every way the host can exit
One day the host process will end — cleanly when you close it, or abruptly
when something is wrong. The exit code is the contract you branch on when the
process dies before or instead of answering, and the SDK does the reading for
you: the moment the host is gone, child.exit resolves to an
ExitClassification you can switch on. This page shows you that switch,
and the two things that look like host exits but are not.
If you want the conceptual model first, read the exit classification guide. This recipe is that guide made executable.
Two habits before any code
Section titled “Two habits before any code”Capture stderr; never parse it. The host writes human-readable
diagnostics to stderr, and the format is deliberately not a contract. Hand
spawnMspConnection an onStderr tap and keep what arrives somewhere a
person can read — a log file, a devtools pane. Every classification except a
clean shutdown carries a bounded stderrTail for exactly that purpose: it is
evidence to surface, not data to match on.
Branch on kind, not on raw codes. The SDK maps the observed exit to a
named classification, and where the meaning is known it also tells you
whether retrying can help. New codes may be added over time; an unrecognized
one reads as a crash, which is exactly what the contract says a client
should do.
The code
Section titled “The code”import { spawnMspConnection } from "@muse-code/sdk";
const handshake = spawnMspConnection({ command: "muse", args: ["serve"], onStderr: (chunk) => diagnostics.push(chunk),});
handshake.child.exit.then( (classification) => { switch (classification.kind) { case "cleanShutdown": // Exit 0: the drain completed and the session closed durably. break; case "configError": // Exit 3: the user must fix configuration; retrying without a fix // cannot help. classification.retry === "fix-config", and the // stderr tail names what to fix. break; case "leaseUnavailable": // Exit 4: another live client owns the session. Retry after that // client exits — classification.retry === "after-lease-release". break; case "usageError": case "sdkSurfaceUnavailable": // Exits 2 and 5: retry === "never". A usage error is a client bug // or a version mismatch; surface-unavailable means NO invocation // of this binary will serve, so different arguments cannot help. break; case "unhandledError": // Exit 1: the host could not say what happened on the wire. // Surface the captured stderr; it is the only explanation there is. break; case "crash": // Any other code, or a signal (OOM kills land here too). The // session log is intact; the next load reconciles what is missing. break; } }, (error) => { // The binary never ran at all — a wrong path, a missing file. There is // no exit to classify, so `exit` REJECTS with the spawn error instead. // "The host never ran" and "the host ran and died" have different // remedies; keep them apart. console.error("could not spawn the host:", error); },);That is the whole pattern. child.exit settles exactly once, whenever the
process ends, so you can attach this handler right after spawning and forget
about it. If you close the host yourself, child.close() returns the same
classification after the orderly drain.
What is not a host exit
Section titled “What is not a host exit”Two failures routinely get mistaken for one, and both have their own recipe segment proving the difference.
A spawn failure. As above: if the process never launched, child.exit
rejects rather than resolving. Do not fold this into your crash handling —
a crash means a host ran and left evidence; a rejected spawn means your
command or path is wrong.
A failed turn. A turn can fail while the host is perfectly healthy — a host that is not logged in, for example, accepts your turn and then fails it with a message telling the user to log in. That failure arrives on the wire, as the turn’s own terminal, and the process that delivered it is still serving: the tested recipe reads the session back from the very same host one request later.
One turn failure deserves its own helper: the launch failure, where the turn
died before the agent ever started and the host emitted no turn-started at
all. It has a precise wire marker (terminal "failed" with a launch-error
kind), and isLaunchFailure reads exactly that marker:
import { isLaunchFailure } from "@muse-code/sdk";import type { TurnOutcome } from "@muse-code/sdk";
function settle(outcome: TurnOutcome): void { if (isLaunchFailure(outcome)) { // THIS turn failed to launch. The host did not exit — do not respawn // anything. Show the turn's error and let the user act on it. }}Branch on the marker, never on terminal === "failed" alone: the tested
recipe shows the logged-out turn is a model failure on a run that started —
isLaunchFailure says no for it — and proves the helper says yes for the
launch-boundary shape. Either way the conclusion is the same: a turn terminal
classifies one turn on a live wire; ExitClassification classifies the death
of the process itself.
Run it yourself
Section titled “Run it yourself”You need Node 20 or newer, an installed muse binary, and the SDK your own
application depends on — npm install @muse-code/sdk. 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/sdkMUSE_BIN=$(command -v muse) \ npm run recipes --workspace @muse-code/sdk-cookbook -- --only classify-serve-exitsPart of this recipe drives a conformance fixture host, which 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.
The recipe walks the taxonomy end to end: a build whose SDK surface is
switched off answers exit 5 before parsing anything; a path that cannot spawn
rejects instead of classifying; exit 3 classifies as configError with the
fix-config posture (driven through the canned conformance host, which exits
with exactly the code it is told to — test plumbing, not client guidance);
a logged-out host fails a turn while staying alive, and isLaunchFailure
distinguishes that model failure from the launch-boundary shape; and closing
stdin ends in exit 0, the one row where the session closed durably.
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 classify-serve-exits example. It is the source that runs, not a retelling of it: it is executed end to end on every change to this area, so this page cannot quietly disagree with what the SDK actually does.
Since GA the SDK surfaces are on by default and
MUSE_EXPERIMENTAL_SDK_ENABLED survives only as an off-switch. The harness
still sets it on its healthy hosts, and the switched-off segment sets it to
off deliberately — that is exactly how it produces exit 5. Neither is
advice for your application — build against muse serve as a supported
command, with no gate variable at all.