Skip to content
Developer Preview

List models and switch mid-session

Your user wants a different model — a cheaper one for boilerplate, a stronger one for the hard question — and they do not want to lose their session to get it. This page shows you how to offer the choice and make it stick: list the catalog, switch the session, and know the switch really happened.

The one idea to take away: the ack means “admitted”, session/modelChanged means “it happened”. Listing models is a question; switching is a command that lands a durable record, and that record — not your memory of sending the command — is what every attached client folds and every later resume replays.

Before you switch anything, know the state you are switching out of. The session object’s modelId and providerId are nullable, and a freshly started session can genuinely carry modelId: null: no model-selection record exists until one lands. Render that state honestly (say “default”, or nothing) rather than special-casing some placeholder string — the wire never sends one.

client.listModels asks the host’s model/list. It is a query, not a command: no commandId, nothing written, just a snapshot of what this host can offer right now.

{"dir":"client","raw":"{\"id\":3,\"jsonrpc\":\"2.0\",\"method\":\"model/list\",\"params\":{\"sessionId\":\"018f7294-0000-7000-8000-5f0e06800000\"}}"}
{"dir":"server","raw":"{\"id\":3,\"jsonrpc\":\"2.0\",\"result\":{\"models\":[{\"contextLimit\":null,\"cost\":null,\"description\":null,\"displayLabel\":\"gpt-5.5\",\"isActive\":false,\"isDefault\":false,\"modelId\":\"gpt-5.5\",\"outputLimit\":null,\"profileId\":null,\"providerId\":\"openai\",\"reasoningEffortVariants\":[],\"releaseDate\":null,\"variants\":[\"none\",\"minimal\",\"low\",\"medium\",\"high\",\"xhigh\"]},{\"contextLimit\":null,\"cost\":null,\"description\":null,\"displayLabel\":\"gpt-5.6-sol\",\"isActive\":true,\"isDefault\":true,\"modelId\":\"gpt-5.6-sol\",\"outputLimit\":null,\"profileId\":null,\"providerId\":\"openai\",\"reasoningEffortVariants\":[],\"releaseDate\":null,\"variants\":[\"none\",\"minimal\",\"low\",\"medium\",\"high\",\"xhigh\"]}],\"profileId\":null,\"providerId\":\"openai\",\"source\":\"fakeCatalog\"}}"}

Each row carries what a picker renders: displayLabel for the human, providerId and modelId for the wire, and limits and cost when the catalog source declared them (null when it did not). Two flags matter:

  • isActive — because the request named a sessionId, the row matching that session’s effective model is flagged. With no selection landed yet, no row is active. Never assume exactly one active row; render what you got.
  • isDefault — the catalog’s own default row. Also allowed to be false everywhere.

variants lists that row’s supported reasoning-effort choices in order. An empty array means no choices; "unknown" means the host cannot determine them. Older hosts may omit the member.

Two optional companions ride alongside variants: reasoningEffortVariants pairs advertised tiers with the catalog’s display string for each (tiers without one contribute no entry), and defaultReasoningEffort names the row’s catalog default. Both stay absent when variants is "unknown". Older hosts may omit either member.

The result’s source tells you where the catalog came from — a live provider catalog, the binary’s bundled rows, a test fake — so a diagnostic screen can label it honestly. And models may legitimately be empty: a build shipped with no bundled models is a supported configuration, so an empty picker is a state your UI owns, not an error.

You need a client and a session first. MuseClient.spawn starts the host and runs the handshake; startSession opens a session and hands back a Session that folds every frame the host sends for it, so you never wire up a notification listener yourself.

import { MuseClient } from "@muse-code/sdk";
// With no `museBin`, the SDK runs the first `muse` on your PATH.
const client = await MuseClient.spawn({
args: ["serve"],
clientInfo: { name: "my-app", version: "1.0.0" },
});
const session = await client.startSession({ workspaceRoot });
// A query: naming the session is what makes `isActive` mean something.
const { models } = await client.listModels({ sessionId: session.sessionId });
// Offer `models` to your user; they pick one.
const choice = models.find((row) => !row.isActive);
if (choice === undefined) throw new Error("nothing to switch to");
// The switch is a command: the SDK mints and remembers the commandId, and
// verifies the ack echoes it. Its "accepted" means ADMITTED, nothing more.
const ack = await session.setModel({
providerId: choice.providerId,
modelId: choice.modelId,
});
// Later, when you render: the durable selection record, as the session
// folded it. This is the same record every other attached client sees.
const current = session.fold.sessionState.get("session/modelChanged");
if (current?.source === "user" && current.modelId === choice.modelId) {
console.log(`model is now ${String(current.providerId)}/${current.modelId}`);
}

The result types name every member, but the SDK does not check what the host sent at run time. The tested recipe checks the members it reads, and a client that cannot trust its host should do the same.

The record lands a moment after the ack, so it may not be in the fold yet when setModel returns. The session has no change event yet, so re-read session.fold.sessionState on your UI’s next render to pick it up.

On the wire, the command, its ack, and the durable record look like this:

{"dir":"client","raw":"{\"id\":4,\"jsonrpc\":\"2.0\",\"method\":\"session/setModel\",\"params\":{\"commandId\":\"018f7294-0000-7000-8000-5f0e06800f11\",\"model\":{\"modelId\":\"gpt-5.5\",\"providerId\":\"openai\"},\"sessionId\":\"018f7294-0000-7000-8000-5f0e06800000\"}}"}
{"dir":"server","raw":"{\"id\":4,\"jsonrpc\":\"2.0\",\"result\":{\"commandId\":\"018f7294-0000-7000-8000-5f0e06800f11\",\"status\":\"accepted\"}}"}
{"dir":"server","raw":"{\"emittedAtMs\":1754592500000,\"jsonrpc\":\"2.0\",\"method\":\"session/modelChanged\",\"params\":{\"modelId\":\"gpt-5.5\",\"providerId\":\"openai\",\"sessionId\":\"018f7294-0000-7000-8000-5f0e06800000\",\"source\":\"user\",\"sourceRange\":{\"first\":{\"id\":\"018f7294-0000-7000-8000-5f0e06800105\",\"sequence\":4},\"last\":{\"id\":\"018f7294-0000-7000-8000-5f0e06800105\",\"sequence\":4},\"stream\":{\"id\":\"018f7294-0000-7000-8000-5f0e06800000\",\"kind\":\"session\"}},\"viewCursor\":\"v6:018f7294-0000-7000-8000-5f0e06800000:5\"}}"}

Three things worth reading off those frames:

  • The ack echoes your commandId and says accepted. If a turn is running right now, the selection is admitted immediately and applied at the next model-call boundary — the ack does not wait for that boundary, which is exactly why it is not the “it happened” signal.
  • session/modelChanged carries source: "user": your session/setModel folds as a user choice. A host is also free to land selections of its own ("default", "policy"), so a client that filters by method alone will eventually attribute someone else’s change to its own button.
  • The Session your client handed you folds this frame as it arrives: session.fold.sessionState.get("session/modelChanged") holds the latest selection — the same statement, read off the fold instead of the raw wire.

The switch is durable, so prove it the durable way: ask again.

{"dir":"client","raw":"{\"id\":5,\"jsonrpc\":\"2.0\",\"method\":\"model/list\",\"params\":{\"sessionId\":\"018f7294-0000-7000-8000-5f0e06800000\"}}"}
{"dir":"server","raw":"{\"id\":5,\"jsonrpc\":\"2.0\",\"result\":{\"models\":[{\"contextLimit\":null,\"cost\":null,\"description\":null,\"displayLabel\":\"gpt-5.5\",\"isActive\":true,\"isDefault\":false,\"modelId\":\"gpt-5.5\",\"outputLimit\":null,\"profileId\":null,\"providerId\":\"openai\",\"reasoningEffortVariants\":[],\"releaseDate\":null,\"variants\":[\"none\",\"minimal\",\"low\",\"medium\",\"high\",\"xhigh\"]},{\"contextLimit\":null,\"cost\":null,\"description\":null,\"displayLabel\":\"gpt-5.6-sol\",\"isActive\":false,\"isDefault\":true,\"modelId\":\"gpt-5.6-sol\",\"outputLimit\":null,\"profileId\":null,\"providerId\":\"openai\",\"reasoningEffortVariants\":[],\"releaseDate\":null,\"variants\":[\"none\",\"minimal\",\"low\",\"medium\",\"high\",\"xhigh\"]}],\"profileId\":null,\"providerId\":\"openai\",\"source\":\"fakeCatalog\"}}"}

The same catalog, re-asked with the same sessionId: the row you switched to is isActive now, and the old row is not. isDefault did not move — the default is the catalog’s fact, not the session’s. session/read tells the same story from the session’s side: its providerId and modelId are the switched values, folded from the durable record, which is what every later resume of this session will see. MuseClient has no verb for session/read yet, so send it through client.command("session/read", { sessionId }).

Do not update your model indicator from the ack:

// WRONG: "accepted" means the selection was admitted, not that it landed.
// Your indicator now claims a model the durable record may not hold yet —
// and other attached clients, folding the real record, disagree with you.
const ack = await session.setModel(model);
if (ack.status === "accepted") setIndicator(model);

Drive the indicator from the folded session/modelChanged record — the frame everyone sees — and it can never disagree with the session.

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 list-models-and-switch-mid-session

The recipe runs the whole arc against your installed host, logged out: start a session (and see modelId: null), list the bundled catalog, switch, wait for the durable session/modelChanged to reach the session’s fold, then verify with session/read and a re-list. One honest boundary: actually running a turn on the model you switched to needs a live provider and credentials, so that arm is not part of this recipe.

The runnable source for this page is published in full, comments and all, as the list-models-and-switch-mid-session 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 transcript, so this page cannot drift from what actually runs.

The harness spawns its hosts with MUSE_EXPERIMENTAL_SDK_ENABLED set explicitly. muse serve now runs by default; the variable survives as an off-switch, and the harness pins the run open so an operator’s off in the environment cannot silently change what this page proves. That is not advice for your application — build against muse serve as a supported command.