User input and approvals
Route Claude's permission requests and clarifying questions to a real person with the canUseTool callback, then hand their decision back to the agent.
An unattended agent is great until it wants to delete a folder or has to guess which of two databases you meant. At those moments it should stop and ask. The SDK funnels both kinds of question through one callback, canUseTool, and pauses the run until you answer. Your job is to show the request to a person and return their decision.
Two kinds of request
- Permission for a tool call. Claude wants to run something nothing has pre-approved, such as a
Bashcommand or aWrite. - A clarifying question. Claude calls the built-in
AskUserQuestiontool with multiple-choice questions it has written.
Both arrive in canUseTool. This is different from an ordinary turn ending: the run is paused mid-loop and resumes as soon as your callback returns.
For clarifying questions, Claude writes the questions and options; you present them and return the choices. You cannot inject your own questions into this flow. If your app needs to ask something itself, do that in your own code.
Tip: The callback can wait as long as it likes; execution stays paused. If a human might take hours, do not keep a process alive for that. Register a
PreToolUsehook that returns thedeferdecision instead, so the process can exit and the session can be resumed later.
Wiring up the callback
const options = {
canUseTool: async (toolName: string, input: Record<string, unknown>, opts: { signal: AbortSignal; suggestions?: unknown[] }) => {
// show the request, wait for a decision, return allow or deny
},
};
async def can_use_tool(tool_name, input_data, context):
... # show the request, wait for a decision, return allow or deny
options = ClaudeAgentOptions(can_use_tool=can_use_tool)
| Argument | Contents |
|---|---|
toolName / tool_name | The tool, such as "Bash", "Edit" or "AskUserQuestion" |
input / input_data | The tool's parameters; shape depends on the tool |
options (TS) / context (Python) | Optional suggestions (ready-made PermissionUpdate entries) and a cancellation signal. In TypeScript signal is an AbortSignal; in Python the signal field is reserved for later. Python's type is ToolPermissionContext. |
Inputs you will commonly display:
| Tool | Useful fields |
|---|---|
Bash | command, description, timeout |
Write | file_path, content |
Edit | file_path, old_string, new_string |
Read | file_path, offset, limit |
The SDK references list every tool's input schema.
Warning: The callback is never consulted for calls that something earlier has already approved. An allow rule, or a mode such as
acceptEditsorbypassPermissions, settles the call first. If you list a tool bare inallowedTools, your callback only sees it when the permission flow sends it back for a prompt (for example via an ask rule orplanmode). If you need logic that runs on every single call, use aPreToolUsehook, which runs before the rest of the permission flow. Also note that allow rules do not pre-approve the handful of actions no mode auto-approves; SDK permissions explains which of those reach the callback and whatdontAskandautomodes do with them.
In some setups, dontAsk mode being the obvious one, Claude Code does not call the callback at all. To notify someone that an approval is waiting (Slack, email, push), use the PermissionRequest hook.
Approving and denying tool calls
Return one of two shapes:
| Decision | TypeScript | Python |
|---|---|---|
| Allow | { behavior: "allow", updatedInput } | PermissionResultAllow(updated_input=...) |
| Deny | { behavior: "deny", message } | PermissionResultDeny(message=...) |
On allow, the tool runs with Claude's original input unless you return a modified one. (Before Claude Code v2.1.207, an allow without updatedInput was rejected with a validation error, so include it if you support older versions.) On deny, write a message: Claude reads it and adjusts.
A terminal approver in TypeScript
This asks a person before every unapproved call while an agent tidies up old log files:
import { query } from "@anthropic-ai/claude-agent-sdk";
import * as readline from "node:readline/promises";
async function ask(question: string) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(question);
rl.close();
return answer.trim().toLowerCase();
}
for await (const msg of query({
prompt: "Archive log files in ./logs older than 30 days into logs/archive.tar.gz, then remove the originals",
options: {
canUseTool: async (toolName, input) => {
const summary = toolName === "Bash" ? String(input.command) : JSON.stringify(input);
const answer = await ask(`\n${toolName}: ${summary}\nAllow? [y/N] `);
return answer === "y"
? { behavior: "allow", updatedInput: input }
: { behavior: "deny", message: "The operator declined this step." };
},
},
})) {
if (msg.type === "result" && msg.subtype === "success") console.log(msg.result);
}
The Python equivalent, and a quirk
In Python, can_use_tool needs streaming input, so pass the prompt as an async generator. You also need a workaround: register a PreToolUse hook that simply returns {"continue_": True}, which keeps the stream open so the callback can be called.
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
from claude_agent_sdk.types import HookMatcher, PermissionResultAllow, PermissionResultDeny
async def can_use_tool(tool_name, input_data, context):
shown = input_data.get("command") if tool_name == "Bash" else input_data
answer = input(f"\n{tool_name}: {shown}\nAllow? [y/N] ").strip().lower()
if answer == "y":
return PermissionResultAllow(updated_input=input_data)
return PermissionResultDeny(message="The operator declined this step.")
async def keep_stream_open(input_data, tool_use_id, context):
return {"continue_": True}
async def prompt():
yield {"type": "user", "message": {"role": "user",
"content": "Archive logs older than 30 days into logs/archive.tar.gz, then remove the originals"}}
async def main():
async for msg in query(
prompt=prompt(),
options=ClaudeAgentOptions(
can_use_tool=can_use_tool,
hooks={"PreToolUse": [HookMatcher(matcher=None, hooks=[keep_stream_open])]},
),
):
if isinstance(msg, ResultMessage) and msg.subtype == "success":
print(msg.result)
asyncio.run(main())
Richer responses
A yes/no prompt is the bare minimum. Real approval UIs usually offer more:
| Response | How |
|---|---|
| Approve | Return allow with the input unchanged |
| Approve with changes | Return allow with an edited input. Claude sees the result but is not told you changed anything. Good for clamping paths or adding flags. |
| Approve and remember | Return allow with updatedPermissions set to one of the suggestions |
| Reject | Return deny with a reason |
| Suggest an alternative | Return deny with a message describing what to do instead |
| Change direction completely | Send a new message through streaming input |
Approve with changes, keeping every write inside a staging folder:
async def can_use_tool(tool_name, input_data, context):
if tool_name in ("Write", "Edit"):
path = input_data["file_path"]
if not path.startswith("/srv/staging/"):
safe = {**input_data, "file_path": "/srv/staging/" + path.lstrip("/")}
return PermissionResultAllow(updated_input=safe)
return PermissionResultAllow(updated_input=input_data)
Suggest an alternative:
canUseTool: async (toolName, input) => {
if (toolName === "Bash" && /\bgit push\b/.test(String(input.command))) {
return {
behavior: "deny",
message: "Do not push. Commit locally and tell me the branch name; I will open the PR myself.",
};
}
return { behavior: "allow", updatedInput: input };
};
Approve and remember. The third argument's suggestions contains ready-made PermissionUpdate entries. Echo one back in updatedPermissions; a suggestion whose destination is localSettings writes the rule to .claude/settings.local.json, so future sessions skip the prompt for matching calls.
canUseTool: async (toolName, input, { suggestions = [] }) => {
const choice = await askOperator(`Allow ${toolName}?`, ["once", "always", "no"]);
if (choice === "always") {
return {
behavior: "allow",
updatedInput: input,
updatedPermissions: suggestions.filter((s) => s.destination === "localSettings"),
};
}
if (choice === "once") return { behavior: "allow", updatedInput: input };
return { behavior: "deny", message: "Declined by operator" };
};
In Python, the same thing reads context.suggestions and returns PermissionResultAllow(updated_input=..., updated_permissions=[...]); it needs claude-agent-sdk 0.1.80 or later. In TypeScript, hide the "always" option when the callback options include suppressAlwaysAllowRule: true (Agent SDK v0.3.268 or later; Python's context does not carry this hint).
Answering clarifying questions
When a task has several sensible routes, Claude may call AskUserQuestion rather than guess. Your callback receives toolName === "AskUserQuestion" and an input full of questions. This happens a lot in plan mode, where Claude explores and gathers requirements before proposing anything, which makes plan mode a natural fit for interactive requirement gathering.
Make sure the tool is available
AskUserQuestion is on by default. If you restrict tools with a tools list, include it, or Claude cannot ask:
ClaudeAgentOptions(tools=["Read", "Glob", "Grep", "AskUserQuestion"], can_use_tool=can_use_tool)
What you receive
{
"questions": [
{
"question": "Which payment provider should the checkout use?",
"header": "Payments",
"options": [
{ "label": "Stripe", "description": "Already used by the subscriptions service" },
{ "label": "GoCardless", "description": "Direct debit, lower fees for UK customers" }
],
"multiSelect": false
},
{
"question": "Which customer notifications do you want?",
"header": "Emails",
"options": [
{ "label": "Receipt", "description": "Sent on successful payment" },
{ "label": "Failed payment", "description": "Sent when a charge fails" },
{ "label": "Renewal reminder", "description": "Sent a week before renewal" }
],
"multiSelect": true
}
]
}
| Field | Meaning |
|---|---|
question | Full text to show |
header | Short label, at most 12 characters |
options | Two to four choices, each with label and description (and optionally preview in TypeScript) |
multiSelect | Whether more than one option can be chosen |
What you send back
Return allow with an input containing the original questions plus an answers object. Each key is a question's question text; each value is the chosen option's label.
| Field | Purpose |
|---|---|
questions | The original array, passed straight through (required) |
answers | Question text mapped to the selected label |
response | Optional free-form reply, for when the user dismisses the questions and types something general |
For multi-select, pass an array of labels or a string joined with ", ". When response is set, Claude receives "The user responded: ..." instead of the per-question answers, so only use it for that dismiss-and-type case.
return {
behavior: "allow",
updatedInput: {
questions: input.questions,
answers: {
"Which payment provider should the checkout use?": "GoCardless",
"Which customer notifications do you want?": "Receipt, Failed payment",
},
},
};
Letting people type their own answer
Claude's options will not always fit. Add an "Other" choice in your UI that accepts text, and put the typed text in answers as the value (not the word "Other").
A complete terminal handler
This Python version numbers the options, accepts either numbers or free text, and routes everything else to auto-approve for brevity. It uses the same streaming prompt and keep-open hook as earlier.
from claude_agent_sdk.types import PermissionResultAllow
def to_answer(raw: str, options: list) -> str:
try:
picks = [int(part) - 1 for part in raw.split(",")]
labels = [options[i]["label"] for i in picks if 0 <= i < len(options)]
return ", ".join(labels) or raw
except ValueError:
return raw # free text
async def answer_questions(input_data):
answers = {}
for q in input_data.get("questions", []):
print(f"\n[{q['header']}] {q['question']}")
for n, opt in enumerate(q["options"], start=1):
print(f" {n}. {opt['label']}: {opt['description']}")
hint = "numbers separated by commas" if q.get("multiSelect") else "a number"
raw = input(f"Choose {hint}, or type your own answer: ").strip()
answers[q["question"]] = to_answer(raw, q["options"])
return PermissionResultAllow(updated_input={"questions": input_data["questions"], "answers": answers})
async def can_use_tool(tool_name, input_data, context):
if tool_name == "AskUserQuestion":
return await answer_questions(input_data)
return PermissionResultAllow(updated_input=input_data)
Try it with a prompt like "Help me choose how to structure invoicing for a new SaaS product".
Option previews (TypeScript)
Set toolConfig.askUserQuestion.previewFormat and Claude adds a preview to options where a visual comparison helps (layouts, colour schemes), leaving it off where it would not (yes/no, text-only choices).
previewFormat | preview holds |
|---|---|
| unset | Nothing; the field is absent |
"markdown" | ASCII art and fenced code blocks |
"html" | A styled <div> fragment. The SDK rejects <script>, <style> and <!DOCTYPE> before your callback runs. |
The setting applies to every question in the session. Always check for undefined before rendering.
query({
prompt: "Help me pick a layout for the pricing page",
options: {
toolConfig: { askUserQuestion: { previewFormat: "html" } },
canUseTool: async (toolName, input) => renderQuestionCards(toolName, input),
},
});
Limits
AskUserQuestionis not available inside subagents spawned through theAgenttool.- Each call carries one to four questions, each with two to four options.
Other ways to involve a person
- Streaming input for interrupting, redirecting or adding context while the agent works, and for chat-style interfaces.
- Custom tools for richer interactions: forms, multi-step wizards, or plugging into an existing ticketing or approval system. More work, but complete control.