Skip to content
Developer Preview

Quickstart: your first session

This is the shortest complete path from nothing to a working SDK program: start the host, run a turn, answer its permission request, cancel a turn, reload the session in a new process, and shut down. Follow it top to bottom. You do not need to read the SDK source. Every code step carries a TypeScript, a Python and a Rust tab; pick your language once and the tabs follow you down the page.

The TypeScript and Python journeys ship as runnable packages — the @muse-code/sdk-quickstart package and its Python twin — and a Rust port of the same journey runs beside them. Each runs against a release-built host: the TypeScript journey on every change to the TypeScript SDK and these docs, the Python and Rust journeys on every change to their SDKs. It asserts what the protocol is specified to do, so every step below is one a release-built host satisfies today. If a step ever regresses, the journey marks it blocked and fails loudly, and this page gains a caveat saying which step and on what open issue.

  • Node 20 or newer.

  • The @muse-code/sdk package, published on npm:

    Terminal window
    npm install @muse-code/sdk

    It has zero runtime dependencies — the only thing it needs at run time is a muse binary to talk to. TypeScript users also want @types/node, because the SDK’s types name Node’s own (ChildProcess, Readable) on its spawn and stream surfaces.

  • A muse binary. Install Muse Code with curl https://dev.meta.ai/install.sh | bash, which puts a checksum-verified muse launcher in ~/.local/bin and keeps it up to date, or follow the user manual. Its MSP surface is what your program talks to; the MSP concepts guide explains the model behind every call you are about to make.
  • A configured provider: run muse once and log in, or point your program’s HOME at a profile that already has credentials. A host with no credentials fails every turn.

Spawning starts your own muse serve child. In TypeScript and Python one call also completes the handshake and hands back a client that is ready to use; you cannot send anything before the handshake completes.

import { MuseClient } from "@muse-code/sdk";
const client = await MuseClient.spawn({
args: ["serve"],
clientInfo: { name: "my-app", version: "1.0.0" },
});

Leave out museBin and the SDK runs the first muse on your PATH; pass museBin to pick a binary.

args: ["serve"] starts the protocol host; a bare muse is the interactive app. Pass env if you want a specific HOME; the quickstart packages do, so they never touch your real sessions. If the host exits immediately instead of answering, the exit code tells you why (in TypeScript and Python, spawn rejects with a MuseHostStartError that carries it) — see exit classification. In particular, code 5 means that build’s experimental SDK tier is switched off and no invocation of that binary will serve.

import { EXPECTED_SCHEMA_FINGERPRINT } from "@muse-code/sdk";
const { serverInfo, sessionDurability, schema } = client.initializeResult;
if (schema.fingerprint !== EXPECTED_SCHEMA_FINGERPRINT) {
// built from a different schema: a warning, not an error
}

client.initializeResult tells you who answered: serverInfo, museHome, sessionDurability, and the protocol schema.fingerprint. A fingerprint that differs from the one the SDK was built against is a warning, never an error — an older SDK keeps working against a newer host.

The client owns the connection from here on: it routes every event the host pushes to the session it belongs to, so there is no handler to install.

const session = await client.startSession({ workspaceRoot });
const sessionId = session.sessionId;

Starting a session changes state, so it is sent as a command with a commandId. The TypeScript and Python clients mint it for you and remember it, so a retry after a dropped reply joins the original command instead of starting a second one — the commands and idempotency guide explains why that matters. The host’s reply, including the viewCursor that marks your position in the session’s event stream, is on session.opening.result.

const result = await session.run("Reply with the single word: hello");
// result.outcome.kind === "completed"; result.finalText === "hello"

run sends the prompt and resolves once the turn has ended, with the outcome, the final answer (finalText), the turn’s items, and its token usage. A turn that failed or was cancelled still resolves: read result.outcome to tell.

To show the answer while it is still being written, send the turn yourself and read its items as they arrive:

const turn = await session.sendUserTurn({
input: [{ type: "text", text: "Reply with the single word: hello" }],
});
for await (const item of turn.items()) {
// render each item: the prompt, tool calls, the agent's messages
}
const outcome = await turn.completed;

Underneath, the answer arrives as notifications. The TypeScript and Python sessions fold them into the items you read; in Rust you read them yourself:

notification what it means
turn/started the turn is running
item/started a new item (e.g. the agent’s message) has begun
item/delta more text for that item
item/completed that item is final
turn/completed the turn is over; terminal says how it ended

Accumulate item/delta for live output, and treat the item/completed text as authoritative. The turn’s terminal (on turn/completed, and on a completed outcome as outcome.params.terminal) says how the turn ended: "completed", "cancelled" or "failed" today, but it is an open set — treat any unknown value as terminal. On "failed" it carries error (kind, message, retryable) and usually a display-only reason; a turn the host died during carries reason and no error (sessions and turns). An error.kind of authRequired means the host has no usable credential — see What you need.

When the agent wants to do something that needs your consent, the host pushes approval/requested. Answer it:

session.onApproval(() => ({ choiceId: "allow_once" }));

Register the handler before you send the turn. The choices are offered by the host on the request (request.availableChoices); a choice the request did not offer is refused and reported to session.onApprovalError instead of being sent.

approval/resolved follows, naming the same approvalId. The turn then continues. The approvals guide covers the full requirement and choice model.

await turn.cancel();
const outcome = await turn.completed; // outcome.params.terminal === "cancelled"

To stop a run, pass it an AbortSignal: session.run(prompt, { signal: AbortSignal.timeout(60_000) }).

Cancellation is not instant and it is not an error. Wait for the turn to end; its terminal will be "cancelled".

await client.close();
const exit = await client.exit; // { kind: "cleanShutdown" }

Closing sends stdin EOF, and the host drains and exits. The session’s writer lease is released with it — which is what lets a later process load the same session.

Spawn exactly as before, then:

const session = await client.resumeSession({ sessionId, excludeItems: false });
const resumed = session.opening.result;

You get back:

  • resumed.session — the same sessionId, its workspaceRoot, status, turnCount, providerId and modelId.
  • resumed.viewCursor — where the session now is.
  • resumed.history — the conversation so far. history.mode says what was actually served: for a session this small it is { mode: "inline", items: [...] }; long or compacted sessions get a snapshot with items: null instead — check mode before reading items (see the resume guide).
  • resumed.pendingRequests — approvals and questions still waiting for you. In TypeScript and Python, pass your approval handler to the resume call itself (onApproval / on_approval) so a waiting approval reaches it.

Pass cursor instead to resume from a specific point. The resume guide explains the cursor and history contract.

Same close. Same clean exit.