Skip to content
Developer Preview

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.

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.

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.

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.

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:

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 classify-serve-exits

Part 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.

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.