Skip to content
Developer Preview

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.

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.

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 more

On 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}}"}

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 forward

That 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.

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.

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 approve-or-deny

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 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.