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”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 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.
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.
The code
Section titled “The code”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.
from muse_code import MuseClient, MuseClientSpawnOptions, StartSessionOptions
client = await MuseClient.spawn( MuseClientSpawnOptions( muse_bin=None, # None runs the first `muse` on your PATH args=("serve",), client_info={"name": "my-app", "version": "1.0.0"}, ))session = await client.start_session(StartSessionOptions(workspace_root=workspace_root))
# A query: naming the session is what makes `isActive` mean something.listing = await client.list_models({"sessionId": session.session_id})models = listing["models"]
# Offer `models` to your user; they pick one.choice = next((row for row in models if not row["isActive"]), None)if choice is None: raise RuntimeError("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.ack = await session.set_model( {"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.current = session.fold.session_state.get("session/modelChanged")if ( isinstance(current, dict) and current.get("source") == "user" and current.get("modelId") == choice["modelId"]): print(f"model is now {current.get('providerId')}/{current.get('modelId')}")The results are typed dictionaries; the member names are the wire contract, and the tested twin reads exactly these.
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.
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\":\"v6: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
Sessionyour 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.
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\",\"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 }).
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 session.setModel(model);if (ack.status === "accepted") setIndicator(model);# 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.ack = await session.set_model(model)if ack["status"] == "accepted": set_indicator(model)Drive the indicator from the folded session/modelChanged record — 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-sessionNot yet runnable from the public clone: the Python twins are not
mirrored to the public SDK repository yet, so this command needs a source
tree that carries clients/sdk-cookbook-py/. The twins run in CI on every
change to this area either way.
You need Python 3.10 or newer with the two SDK packages importable and an
installed muse binary:
PYTHONPATH=clients/sdk-cookbook-py/src \MUSE_BIN=$(command -v muse) \ python3 -m cookbook_recipes --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, 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.
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.