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.
A fresh session may have no model yet
Section titled “A fresh session may have no model yet”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.
List the catalog
Section titled “List the catalog”model/list 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\",\"releaseDate\":null},{\"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\",\"releaseDate\":null}],\"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 asessionId, 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.
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.
The code
Section titled “The code”A result’s members are Record<string, unknown> — the wire is not typed for
you — so read them by name and narrow:
const list = await msp.connection.request("model/list", { sessionId });const models = list["models"] as Array<{ displayLabel: string; providerId: string; modelId: string; isActive: boolean;}>;
// 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");
// Subscribe BEFORE you send: the host writes the ack and the record// back-to-back, and a listener attached after the await can miss a frame// that arrived in the same read as the ack.msp.connection.onNotification((notification) => { if (notification.method !== "session/modelChanged") return; const params = notification.params ?? {}; if (params["sessionId"] !== sessionId) return; // A durable selection record landed — the same frame every other // attached client sees. Drive your indicator from records like this one, // never from the ack. This filter is method + session only, so it reports // every selection record; attributing one to YOUR switch specifically // takes source === "user" plus a match on the model you sent. console.log(`model is now ${String(params["providerId"])}/${String(params["modelId"])}`);});
// The switch is a command: the SDK mints and remembers the commandId,// and verifies the ack echoes it. Its "accepted" means ADMITTED — the// subscription above is what reports the record landing.const ack = await msp.connection.command("session/setModel", { sessionId, model: { providerId: choice.providerId, modelId: choice.modelId },});A real client would validate rather than cast, but the member names are the part that has to be right — those are the wire contract, and the tested recipe reads exactly these.
Watch the switch land
Section titled “Watch the switch land”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\":\"v:018f7294-0000-7000-8000-5f0e06800000:5\"}}"}Three things worth reading off those frames:
- The ack echoes your
commandIdand saysaccepted. 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/modelChangedcarriessource: "user": yoursession/setModelfolds 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 SDK’s
Sessionfolds this frame for you: aftersession.apply(notification),session.fold.sessionState.get("session/modelChanged")holds the latest selection — the same statement, read off the fold instead of the raw wire.
Verify by reading, not remembering
Section titled “Verify by reading, not remembering”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\",\"releaseDate\":null},{\"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\",\"releaseDate\":null}],\"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.
What not to do
Section titled “What not to do”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 msp.connection.command("session/setModel", { sessionId, model });if (ack["status"] === "accepted") setIndicator(model);Drive the indicator from session/modelChanged — the frame everyone sees —
and it can never disagree with the session.
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 list-models-and-switch-mid-sessionThe recipe runs the whole arc against your installed host, logged out: start a session
(and see modelId: null), list the bundled catalog, switch, observe the
durable session/modelChanged, fold it through the SDK’s Session, 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.
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 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.