Approve or deny a tool call
The agent wants to write to a file, and your user has to say yes or no. This page shows you both answers — including the one the quickstart never gets to, where the user says no.
The one idea to take away: denying is an answer, not an error. The turn keeps running, the agent tells the user what it did not do, and your UI has nothing to apologise for.
The host asks twice, and you owe it two different things
Section titled “The host asks twice, and you owe it two different things”When the agent reaches for a tool that needs permission, two frames arrive:
approval/requested— a notification. This is the one for your UI: it carries the tool, the subject it wants to act on, and the choices the user may pick from.approval/request— a JSON-RPC request. This one needs a reply, and the reply just means “a client is handling this”, not “approved”.
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 !== "approval/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}`); } return {}; // "I am handling this." The actual answer travels separately.});Check the method rather than acking everything. There is one handler for the
whole connection, so a bare async () => ({}) would also tell the host you
are handling requests you have built no UI for.
The two error replies are not identical, which matters if the host branches on
the code: the no-handler default above is method not found (-32601), while
a plain throw from your handler is reported as an internal error
(-32603). If you want the default’s exact meaning, throw an MspError
carrying -32601 instead.
Show the choices the host offered
Section titled “Show the choices the host offered”approval/requested carries availableChoices, and that list depends on what
is being asked. Render it; do not hardcode it:
const request = /* the approval/requested params */;
for (const choice of request.availableChoices) { // choice.choiceId — what you send back // choice.label — what the user reads ("Allow once", "Reject") // choice.decision — what it means ("approved", "abort", ...) // choice.scope — how long it lasts ("once", "session") // choice.acceptsFeedback — whether the user may type a reason render(choice);}request.subject is what you put next to those buttons — for a file write it
carries the path and the access being asked for.
Send the answer
Section titled “Send the answer”Answer with approval/decide, quoting back the approvalId and the
currentRequirementId you were given:
const ack = await msp.connection.command("approval/decide", { sessionId, approvalId: request.approvalId, requirementId: request.currentRequirementId, choiceId: "allow_session",});
// ack.status === "accepted" — the host took your answer// ack.terminal === true — this approval will not come back for moreOn the wire that is your acknowledgement of the request, your decision, and the host’s ack:
{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}"}{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":\"b9\",\"method\":\"approval/decide\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a2a-3333-7abc-8def-00000000d001\",\"approvalId\":\"0198f0ac-7777-7000-8000-0000000000e1\",\"requirementId\":{\"approvalId\":\"0198f0ac-7777-7000-8000-0000000000e1\",\"sourceIndex\":0},\"choiceId\":\"allow_session\"}}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":\"b9\",\"result\":{\"commandId\":\"018f6a2a-3333-7abc-8def-00000000d001\",\"status\":\"accepted\",\"approvalId\":\"0198f0ac-7777-7000-8000-0000000000e1\",\"terminal\":true}}"}The ack is not the outcome
Section titled “The ack is not the outcome”accepted means the host received your answer. What was actually decided
arrives as approval/resolved:
// decision — "approvedForSession", "abort", ...// policyResult — "allow" or "deny"// amendment — present when the choice changed policy going forwardThat amendment is the difference a session-scoped choice makes: picking
“Allow for this session” tells the host not to ask about this subject again for
the rest of the session, and the amendment describes the rule that was added.
A once-scoped or denying choice carries no amendment, because nothing about the
future changed.
Denying
Section titled “Denying”Send the denying choice like any other, with the user’s reason when the choice accepts feedback:
await msp.connection.command("approval/decide", { sessionId, approvalId: request.approvalId, requirementId: request.currentRequirementId, choiceId: "abort", feedback: "Do not modify the manifest",});Then let the turn finish. It ends completed, exactly like the allow arm — the
tool call did not happen, and the agent says so in its next message. The
mistake to avoid is treating the denial as a failed turn:
// WRONG: the user answered the question you asked them. Nothing failed.if (resolved.policyResult === "deny") showError("The turn failed");Show the agent’s explanation instead. The tested recipe asserts it arrives:
after a denial the turn still reaches completed and the agent still produces
a message telling the user what it did not do.
Run it yourself
Section titled “Run it yourself”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:
git clone https://github.com/meta-models/muse-code-sdkcd muse-code-sdknpm ci && npm run build --workspace @muse-code/sdknpm run recipes --workspace @muse-code/sdk-cookbook -- --only approve-or-denyThis 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.
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 approve-or-deny 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 frames above against the committed transcripts, 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.