Module 17 · 55 min

Human-in-the-Loop

You can route tool approvals and clarifying questions through canUseTool, place that callback in the evaluation order that decides every tool call, and say what happens to an approval nobody answers.

Surface
canUseTool · permissionMode
Workbench tag
module-17
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30
A taut cord running between two separate plinths, wound around a matching spool on each one.

Since module 15 the workbench has run with permissionMode: "dontAsk": every tool call the rules did not already allow was denied without a word. That held because there was no way to ask anyone anything. This module builds the way out. The server switches to the SDK’s normal mode, hands it a callback, and when the agent wants to write a file, a card appears in the browser and the agent waits for your answer.

A callback is a function you hand to a library so the library can call you when something happens. The one this module is about is canUseTool: the SDK calls it when a tool call needs a person’s decision, and your function answers allow or deny. The permissions page is the reference; record 0035 in the workbench repository quotes the exact declaration from the installed SDK, read off the package rather than remembered.

flowchart TD
a["agent wants to run Write"] --> b["SDK: hooks, rules,<br/>mode all pass"] --> c["canUseTool called,<br/>stays pending"]
c --> d["server sends<br/>approval frame"] --> e["browser draws a card:<br/>Allow / Deny"]
e --> f["person clicks, or the<br/>timeout fires"] --> g["decision goes back,<br/>callback resolves"]
g --> h["tool runs, or the model<br/>is told it did not"]
One approval, end to end. The SDK pauses inside the callback; nothing on the right moves until the person clicks, or the timeout answers for them.

Where the callback sits

Your callback is not the first word on a tool call, it is the last. The SDK settles each call by walking an order, and everything earlier in the order can end the walk before your function is reached: hooks first, then deny rules, then ask rules, then the permission mode, then allow rules, and your callback only for whatever is still unsettled.

Two consequences follow, and each one is a bug someone will meet. First, allowedTools pre-approves; it does not restrict. A tool named there is allowed at the allow-rules step, so your callback never hears about it, naming a tool in allowedTools because you want to be asked about it produces the opposite of what you wanted. Second, a mode that settles everything starves the callback entirely. That is what dontAsk was doing since module 15, and why record 0034 supersedes record 0016 before any of this module’s code can matter. The SDK knows this failure is common enough to warn about: when your callback can never run, it emits a Node process warning with the code CLAUDE_SDK_CAN_USE_TOOL_SHADOWED. This server would bury that warning, because it prints one JSON line per log event and a prose warning on stderr breaks anything reading the stream, so it subscribes to process warnings, filters on that code, and re-emits it as its own warn-level line. Record 0041 has the wiring.

One callback, two kinds of ask

The callback’s answer type has two arms and no third:

// From @anthropic-ai/claude-agent-sdk 0.3.251, condensed. Record 0035
// quotes the full declaration with line numbers.
{ behavior: "allow", updatedInput: input }
{ behavior: "deny",  message: "why not" }

An approval maps onto that cleanly: the card’s Allow button becomes the first arm, Deny the second. Questions are stranger. When the agent wants to ask the person something, it calls a tool named AskUserQuestion, and that tool call arrives at the same callback as every other. The server turns it into a question frame with the text and the options; the browser draws one button per option. There is no arm of the answer type that carries a tool result. The chosen option cannot be handed to the tool. So the server answers { behavior: "deny", message: "The person answered: <choice>" }, the one arm that carries a string. The model reads the answer in the deny message and acts on it in its next turn. That is not a euphemism for allow: the tool call genuinely did not run, and the session’s result lists it under permission_denials, which is what a transcript reader needs to know. Record 0038 walks the rejected alternatives, including the SDK’s separate onUserDialog surface, which is where a later revisit would take this.

Three frames on the wire

The protocol grows again without changing its version: approval and question from server to browser, decision back. An approval carries the same one-line summary the activity feed uses, so the card can say Write notes/plan.md rather than Write; a decision carries "allow" or "deny" for an approval, or one of the options verbatim for a question. One decision per ask, a second, or one naming an id that is not pending, is answered with an error frame. And the rule from module 16 still holds: a prompt’s result or error resolves everything, so an unanswered card cannot outlive its turn.

Watch it ask

Check out the tag and run fake mode. The fake’s approval script is opt-in two ways: WORKBENCH_FAKE_APPROVALS=1 turns it on for every prompt, and the word “approve” anywhere in a prompt turns it on for that prompt alone, because a browser cannot set an environment variable on a server that is already running. Record 0040 has the reasoning; a plain prompt still gets the exact session module 16 documented.

git checkout module-17
WORKBENCH_FAKE_SDK=1 npm run dev

Type a prompt containing “approve” at http://localhost:3000. Partway through the activity feed a card appears with the tool, the summary, and two buttons; answering collapses it to one line and the turn carries on into a question card with two options. The demo script shows the same exchange as frames, from a real run:

[recv] {"type":"activity","id":"demo-...","tool":"Write","phase":"start","summary":"Write notes/plan.md"}
[recv] {"type":"approval","id":"demo-...","approvalId":"demo-...-a1","tool":"Write","summary":"Write notes/plan.md"}
[approval] Write notes/plan.md -> allow
[recv] {"type":"activity","id":"demo-...","tool":"Write","phase":"done","summary":"Write notes/plan.md"}
[recv] {"type":"question","id":"demo-...","questionId":"demo-...-q2","text":"Which file should the notes go in?","options":["notes/plan.md","notes/scratch.md"]}
[question] Which file should the notes go in? -> "notes/plan.md"
[recv] {"type":"result",...,"subtype":"success","turns":1,"costUsd":0}

Notice the card is not a popup. It sits inline in the turn, between the activity feed and the streaming answer, the request is about this turn: the tool calls that led to it are directly above, the answer it will change is directly below, and nothing steals your cursor while the answer is still writing itself. Record 0075 weighs that against the modal dialog every UI kit would have handed us.

Build

The ladder:

  1. Run it. Fake mode, a prompt with “approve” in it, answer both cards. Then run npm run demo -w server and read the same exchange as frames; add —deny and read what changes in the result.
  2. Read one file. server/src/sdk-stream.js, the createCanUseTool function, with record 0035 beside it.
  3. Change one line and see it. Start the server with WORKBENCH_APPROVAL_TIMEOUT_MS=3000, send the approve prompt, and touch nothing. The card waits three seconds, the feed moves again, and the turn finishes without you.
  4. Build. The question card answers with a click. Give the approval card a keyboard shortcut of your choosing in client/public/view.js, and prove it with the end-to-end test still passing: npm test -w client.
The mistake most people make first

You wire up canUseTool, you test it by clicking Allow, it works, you ship it. Then someone walks away from the tab. The agent asks, nobody answers, and the callback the SDK is waiting on has, in the SDK’s own words, no deadline of its own, nothing on the SDK side ever gives up. Your app now holds a session open forever on behalf of a person who went to lunch.

Every deadline is the host’s to set, so the workbench sets one: WORKBENCH_APPROVAL_TIMEOUT_MS, default two minutes, and when it fires the ask is settled as a deny naming the timeout as the cause. Deny, because the two wrong answers are not the same size: a deny that should have been an allow costs a retry, while an allow that should have been a deny wrote a file nobody agreed to, with a record saying a person did. The default answer to a question nobody answered is no. Run the third ladder step and watch it happen: with the timeout at three seconds, the unanswered approval denies at three, the unanswered question at six, and the turn still ends in a normal result. The timeout sends no frame of its own, so the card sits there looking pending until that result lands and resolves it, which is the one part readers of the protocol file trip on. Record 0036 is the full argument.

Does this travel?

The shape travels whole: a permission callback that suspends the agent, a UI that renders the ask, a timeout that answers for the absent, and a default of no. Any agent framework with a permission hook wants all four, and the deny-that-carries-an-answer trick works anywhere a callback’s refusal message reaches the model. What does not travel is the order of evaluation, the CLAUDE_SDK_CAN_USE_TOOL_SHADOWED warning, and AskUserQuestion arriving through the permission path at all, those are this SDK’s own, and the permissions page is the reference to check, not this one.

Check yourself

  1. You add a tool to allowedTools so that your shiny new approval card will show for it. What actually happens when the agent calls that tool, and which step of the evaluation order is responsible?
  2. A question card shows three options and the person clicks the second one. Describe the frame the client sends, the value the server returns to the SDK, and what the session’s result will say about the AskUserQuestion call.
  3. Your teammate reads the protocol file and asks why their card still says pending a minute after the server’s log says the timeout denied it. What do you tell them, and which frame will finally change the card?