Skip to content
Developer Preview

Answer the agent's question

Mid-work, the agent can stop and ask your user a structured question — which database, which approach, which of two safe defaults — through the request_user_input tool. This page shows you how to take that question off the wire, put it in front of your user, and send the answer back.

The one idea to take away: an unanswered question outlives your client. It waits in the session, holds the turn open, and is re-asked to whoever connects next. Answering it is what lets the turn finish.

The host asks twice, and you owe it two different things

Section titled “The host asks twice, and you owe it two different things”

The ask has the same two-audience shape as a permission request:

  • userInput/requested — an event in the session’s view. This is the one for your UI: it carries the questions, each with the options the agent offered.
  • userInput/request — a JSON-RPC request. This one needs a reply, and the reply just means “a client is showing this to the user”, not the answer.

If you never register a handler, the SDK answers that request with method not found on your behalf, and the host is entitled to believe nobody is home. One line prevents it:

msp.connection.onServerRequest(async (request) => {
if (request.method !== "userInput/request") {
// A throw becomes a JSON-RPC error reply, so the host learns this client
// cannot handle it.
throw new Error(`unhandled server request: ${request.method}`);
}
const params = request.params ?? {};
showQuestions(params); // your UI; the actual answer travels separately
return {}; // "I am showing this to the user." Not the answer.
});

Check the method rather than acking everything — there is one handler for the whole connection, and the same handler also receives approval/request when the agent reaches for a gated tool. The approvals recipe walks through that sibling in full.

The tested recipe behind this page starts where real trouble starts: a client connects to a session it did not create — a restart, a second window, a crash-and-reopen — while a question is already pending. Two things tell you about it.

First, the resume result names it. session/resume carries a pendingRequests list, and a UI that skips this list resumes into a turn that looks stuck for no reason:

const resumed = await msp.connection.command("session/resume", {
sessionId,
excludeItems: true,
});
const pending = (resumed["pendingRequests"] ?? []) as Array<Record<string, unknown>>;
for (const entry of pending) {
// entry["kind"] — "userInput": a question is waiting for an answer
// entry["userInputId"] — which question; match it to the re-issued request
}

Second, the host re-issues the userInput/request to you, the late joiner, carrying the same userInputId the pending list named. On the wire, the re-issued ask and your receipt:

{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":18,\"method\":\"userInput/request\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000bb\",\"userInputId\":\"0198f0ae-1111-7000-8000-0000000000f1\",\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f20\",\"itemId\":\"0198f0ae-2222-7000-8000-0000000000f2\",\"toolCallId\":\"call_ab31\",\"toolName\":\"request_user_input\",\"viewCursor\":\"v:0198f0aa-1111-7000-8000-0000000000bb:502\",\"questions\":[{\"id\":\"db_choice\",\"header\":\"Database\",\"question\":\"Which database should the new service use?\",\"selection\":{\"mode\":\"single\"},\"options\":[{\"label\":\"Postgres\",\"description\":\"Matches the other services.\"},{\"label\":\"SQLite\",\"description\":\"Zero-ops, single node only.\"}]}],\"autoResolutionMs\":120000}}"}
{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":18,\"result\":{}}"}

The question also sits IN the session’s view, as a userInput/requested event right after the tool call that asked it — so when your resuming UI backfills with view/page, the question renders in place, exactly where a user scrolling the conversation expects to find it.

Show the question, offer only what was offered

Section titled “Show the question, offer only what was offered”

Each entry in questions is one thing to put in front of the user:

const questions = (params["questions"] ?? []) as Array<Record<string, unknown>>;
for (const question of questions) {
// question["id"] — what you quote back as questionId
// question["header"] — a short label ("Database")
// question["question"] — the question itself, for the user to read
// question["selection"] — { mode: "single" }: the user picks exactly one
for (const option of (question["options"] ?? []) as Array<Record<string, unknown>>) {
// option["label"] — what you send back as selectedLabel
// option["description"] — why a user would pick it
renderOption(option);
}
}

Send back a label the host offered. Do not invent one, and do not hardcode a choice: the options come from the agent’s own question, so they are different every time. The tested recipe asserts its answer is in the offered list before sending it.

One more field deserves your attention: autoResolutionMs. When it is present, the host does not wait forever: once the window passes without an answer, the host settles the question itself — the settlement arrives with outcome timedOut — and the turn moves on. Treat it as a UI deadline, not a suggestion. When it is absent, the question waits indefinitely.

Answer with userInput/answer, quoting back the userInputId and each question’s id:

const ack = await msp.connection.command("userInput/answer", {
sessionId,
userInputId,
answers: [{ questionId: "db_choice", selectedLabel: "Postgres" }],
});
// ack.status === "accepted" — the host took your answer
// ack.userInputId — echoes the question you answered

On the wire that is your answer, the host’s ack, and the settlement that follows:

{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"userInput/answer\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000bb\",\"commandId\":\"018f6a2b-3333-7abc-8def-00000000d002\",\"userInputId\":\"0198f0ae-1111-7000-8000-0000000000f1\",\"answers\":[{\"questionId\":\"db_choice\",\"selectedLabel\":\"Postgres\"}]}}"}
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{\"commandId\":\"018f6a2b-3333-7abc-8def-00000000d002\",\"status\":\"accepted\",\"userInputId\":\"0198f0ae-1111-7000-8000-0000000000f1\"}}"}
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"method\":\"userInput/settled\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000bb\",\"viewCursor\":\"v:0198f0aa-1111-7000-8000-0000000000bb:503\",\"sourceRange\":{\"stream\":{\"kind\":\"run\",\"id\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f20\"},\"first\":{\"id\":\"018f9008-71aa-7000-8000-0000000000f3\",\"sequence\":142},\"last\":{\"id\":\"018f9008-71aa-7000-8000-0000000000f3\",\"sequence\":142}},\"userInputId\":\"0198f0ae-1111-7000-8000-0000000000f1\",\"outcome\":\"answered\",\"answers\":[{\"questionId\":\"db_choice\",\"selectedLabel\":\"Postgres\"}],\"clarification\":null,\"reason\":null,\"decidedByCommandId\":\"018f6a2b-3333-7abc-8def-00000000d002\"},\"emittedAtMs\":1754591000100}"}

accepted means the host received your answer. How the question actually ended arrives as userInput/settled:

// outcome — "answered" here; also how you learn a question
// settled some other way (the window expired, or
// another client answered first)
// answers — the answers as the host recorded them
// decidedByCommandId — which command settled it; yours, if you won

That last field matters in exactly the situation this recipe plays: more than one client can be attached to a session, and each of them was asked. The settlement is broadcast to everyone, so a client whose command id does not match knows the question was answered elsewhere and simply takes the prompt down.

Then the loop closes the way every tool call does: the request_user_input call completes with the selected label as its visible output, and the turn — which the pending question was holding open — runs on to its own turn/completed. Answering is not a side channel; it is what unblocks the agent.

You need Node 20 or newer and the SDK your own application depends on — npm install @muse-code/sdk. This recipe never spawns a real host, so an installed muse is not part of it. 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
npm run recipes --workspace @muse-code/sdk-cookbook -- --only answer-user-input

This recipe replays a committed transcript through a canned host, so it needs no credentials and no model — but the canned host is a conformance fixture that 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 runnable source for this page is published in full, comments and all, as the answer-user-input 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.