Handling approvals and questions
An approval is a tool call your callback puts to a person, and a clarifying question is Claude asking one through the AskUserQuestion tool. Both arrive at can_use_tool, and both wait for what you return.
Last updated: 20 Oct, 2026 · Claude Agent SDK
The callback in the previous lesson decided by itself, from a list in the program. A real desk puts the decision to a person, and sometimes Claude is the one with the question.
shop_desk.py changes in four places: offered adds AskUserQuestion to the tools a session has, permit routes it to the callback whatever the rules say, ask_permission sends a suggestion and applies one that comes back, and run turns the answers into a tool result.
A question is not an ordinary call
offered puts the question tool in every session that has not taken it away:
def offered(self):
asked = self.options.tools
if asked is None or isinstance(asked, dict):
return list(BUILT_IN) + ["AskUserQuestion"]
# a tools list that leaves AskUserQuestion out takes away Claude's questions
return [name for name in asked if name in BUILT_IN or name == "AskUserQuestion"] async def permit(self, name, args, call):
o = self.options
if any(matches(rule, name, args) for rule in o.disallowed_tools):
return False, f"Permission to use {name} was denied by a rule."
# a question is always put to you, whatever the rules and the mode say
asks = name == "AskUserQuestion"
if not asks and (o.permission_mode == "bypassPermissions"
or any(matches(rule, name, args) for rule in o.allowed_tools)):
return True, args # dontAsk never prompts, so everything still undecided is denied
if o.can_use_tool is None or o.permission_mode == "dontAsk":
return False, f"Permission to use {name} was not granted."
return await self.ask_permission(name, args, call)The permissions page is specific about this: AskUserQuestion, MCP tools whose server marks them as needing a person, and connector tools an organisation has set to ask all fall through to the callback even when an allow rule matches. In dontAsk they are denied instead, because that mode never prompts.
async def run(self, name, args, call):
# the answers you collected are what the question tool returns to the model
if name == "AskUserQuestion":
picked = args.get("answers") or {}
return "; ".join(f"{q}: {a}" for q, a in picked.items()), FalseThe answers you return become the tool's result, which is how Claude reads them.
Suggesting a rule, and applying the one that comes back
async def ask_permission(self, name, args, call):
# a suggestion is the rule your code can echo back to stop the asking
suggest = [{"type": "addRules", "behavior": "allow", "destination": "session",
"rules": [{"toolName": name}]}]
decision = (await self.ask({"subtype": "can_use_tool", "tool_name": name,
"input": args, "tool_use_id": call,
"permission_suggestions": suggest,
"blocked_path": None}))["response"]
if decision["behavior"] != "allow": return False, decision["message"]
# a rule you sent back is added to the session, so the next call matches it
for update in decision.get("updatedPermissions") or []:
for rule in update["rules"]:
self.options.allowed_tools.append(rule["toolName"])
return True, decision["updatedInput"]A suggestion is a ready-made PermissionUpdate the prompt can offer: approve this, and do not ask again. The SDK turns each one into a PermissionUpdate object on context.suggestions, and anything you echo back in updated_permissions is applied. A real suggestion with the localSettings destination writes the rule to .claude/settings.local.json, so later sessions skip the prompt too.
The five answers a callback can give
- Approve.
PermissionResultAllow(updated_input=input_data), and the tool runs with the arguments Claude chose. - Approve with changes. Return different arguments in
updated_input, to scope a path or trim a command. Claude is not told you changed anything. - Approve and remember. Echo a suggestion back in
updated_permissionsso matching calls stop prompting. - Reject.
PermissionResultDeny(message=...), with a message Claude reads and can act on. - Reject with a better idea. The same deny, with the alternative in the message: "the customer does not want a card refund, offer shop credit".
A question, an approval and a rule that sticks
The scripted model asks a question, then refunds two orders. The three pieces below go into one file beside shop_desk.py, with the run at the end:
QUESTION = {"questions": [{"question": "Where should the refund go?",
"header": "Refund to",
"options": [{"label": "Card", "description": "Back to the card"},
{"label": "Credit", "description": "Shop credit"}],
"multiSelect": False}]}async def desk_person(tool_name, input_data, context):
if tool_name != "AskUserQuestion":
print("approve?", tool_name, input_data, [s.type for s in context.suggestions])
# approve, and send the suggested rule back so this is not asked twice
return PermissionResultAllow(updated_input=input_data,
updated_permissions=context.suggestions)
return await answer(input_data)async def answer(input_data):
"""Show Claude's questions, collect the choices, hand both back."""
answers = {}
for question in input_data["questions"]:
print(question["header"], "-", question["question"])
for option in question["options"]:
print(" *", option["label"], ":", option["description"])
# a terminal app would call input() here; this run takes the last option
answers[question["question"]] = question["options"][-1]["label"]
return PermissionResultAllow(updated_input={"questions": input_data["questions"],
"answers": answers})View the code here
import asyncio
import itertools
import json
import os
import re
from fnmatch import fnmatch
from claude_agent_sdk import (AssistantMessage, ClaudeAgentOptions, ResultMessage,
SystemMessage, TextBlock, ToolResultBlock, ToolUseBlock,
Transport, UserMessage, query)
ORDER = re.compile(r"\b[A-Z]\d+\b")
SESSIONS = itertools.count(1)
WANTS = {"refund": "refund_order", "stock": "stock_level"}
def read_file(args):
"""What the desk serves for a Read call: the file's lines, numbered."""
lines = open(args["file_path"]).read().splitlines()
return "\n".join(f"{number:>5} {line}" for number, line in enumerate(lines, 1))
BUILT_IN = {"Read": read_file}
def matches(rule, name, args):
"""Does one allow or deny rule match this call?"""
head, _, scope = rule.partition("(")
if not fnmatch(name, head):
return False
# a bare rule covers the whole tool; a scoped one only matching calls
target = args.get("file_path") or args.get("command") or ""
return not scope or fnmatch(target, scope.rstrip(")"))
class ShopModel:
"""Stands in for Claude: reads the conversation, picks the next step."""
def __init__(self, script=None):
self.script = list(script or [])
self.searched = False
def reply(self, said, results, tools):
if self.script:
return self.script.pop(0)
# a search result is not an answer, so a searching turn does not stop here
if results and not self.searched:
return {"text": " ".join(results)}
orders = ORDER.findall(said[-1]) or ORDER.findall(" ".join(said))[-1:]
wanted = next((name for word, name in WANTS.items() if word in said[-1].lower()),
"lookup_order")
tool = next((name for name in tools if name.endswith(wanted)), None)
# the tool this message needs has no definition in context: search for it first
if orders and tool is None and not self.searched and "ToolSearch" in tools:
self.searched = True
return {"calls": [("ToolSearch", {"query": wanted.replace("_", " ")})]}
# the definition is in context now, so the call itself can be made
if orders and tool:
self.searched = False
return {"calls": [(tool, {"order_id": order}) for order in orders]}
return {"text": f"I cannot help with order {orders[0]} yet." if orders
else "Which order is this about?"}
class ShopDesk(Transport):
"""Stands in for the Claude Code binary: it runs the loop over JSON lines."""
def __init__(self, options=None, model=None):
self.options = options or ClaudeAgentOptions()
self.model = model or ShopModel()
self.session_id = f"session-{next(SESSIONS)}"
self.inbox, self.outbox = asyncio.Queue(), asyncio.Queue()
self.prompts, self.said = asyncio.Queue(), []
self.ids, self.setup = itertools.count(1), {}
self.ready, self.tools = False, {}
self.waiting = {}
self.external, self.servers = {}, []
# Claude Code defers by default; the desk waits to be asked, so a run stays short
self.deferring = self.options.env.get("ENABLE_TOOL_SEARCH", "false").startswith(
("true", "auto"))
self.loaded = set()
async def connect(self):
self.ready = True
def is_ready(self):
return self.ready
async def write(self, data):
# one JSON line at a time, which is what the SDK writes to a real process
await self.inbox.put(json.loads(data))
async def end_input(self):
# for a real process this would close its standard input
await self.inbox.put(None)
async def close(self):
self.ready = False
async def send(self, message):
await self.outbox.put(message)
async def read_messages(self):
# the listener and the worker run alongside the SDK's own read loop
asyncio.create_task(self.listen())
self.worker = asyncio.create_task(self.work())
while (message := await self.outbox.get()) is not None:
if isinstance(message, Exception):
raise message
yield message
async def listen(self):
while (message := await self.inbox.get()) is not None:
# a control request is the SDK asking a question; initialize is first
if message["type"] == "control_request":
if message["request"]["subtype"] == "initialize":
self.setup = message["request"]
await self.respond(message["request_id"])
# a control response answers a question the desk itself asked
elif message["type"] == "control_response":
answer = message["response"]
self.waiting.pop(answer["request_id"]).set_result(answer)
elif message["type"] == "user":
await self.prompts.put(message["message"]["content"])
# the input has ended: finish the work in hand, then close the stream
await self.prompts.put(None)
await self.worker
await self.send(None)
async def respond(self, request_id):
# the stand-in says yes to every request the SDK makes of it
await self.send({"type": "control_response", "response": {
"subtype": "success", "request_id": request_id, "response": {}}})
async def ask(self, request):
# a future per request, so the answer can be awaited where it was asked
request_id = f"desk_{next(self.ids)}"
self.waiting[request_id] = asyncio.get_running_loop().create_future()
await self.send({"type": "control_request", "request_id": request_id,
"request": request})
return await self.waiting[request_id]
async def work(self):
while (prompt := await self.prompts.get()) is not None:
await self.turn(prompt)
async def start(self, prompt):
self.said.append(prompt)
if not self.tools:
self.tools = await self.find_tools()
# the init message reports what each configured server is doing
await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
"session_id": self.session_id, "tools": list(self.tools),
"mcp_servers": self.servers})
async def turn(self, prompt):
await self.start(prompt)
results, turns = [], 0
# the model is given the definitions in context, not every tool the desk has
while "calls" in (step := self.model.reply(self.said, results, self.in_context())):
turns += 1
results = [await self.use(name, args) for name, args in step["calls"]]
await self.say(step["text"])
await self.finish("success", turns + 1, step["text"])
def offered(self):
asked = self.options.tools
if asked is None or isinstance(asked, dict):
return list(BUILT_IN) + ["AskUserQuestion"]
# a tools list that leaves AskUserQuestion out takes away Claude's questions
return [name for name in asked if name in BUILT_IN or name == "AskUserQuestion"]
async def mcp(self, server, method, params):
message = {"jsonrpc": "2.0", "id": next(self.ids), "method": method,
"params": params}
answer = await self.ask({"subtype": "mcp_message", "server_name": server,
"message": message})
return answer["response"]["mcp_response"]["result"]
async def connect_stdio(self, name, config):
# Claude Code starts the command and talks MCP over its standard input
process = await asyncio.create_subprocess_exec(
config["command"], *config.get("args", []),
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
env={**os.environ, **config.get("env", {})})
self.external[name] = process
hello = await self.stdio(name, "initialize", {"protocolVersion": "2025-06-18"})
if hello is None:
return {"name": name, "status": "failed",
"error": "the server gave no answer to initialize"}
return {"name": name, "status": "connected", "serverInfo": hello["serverInfo"]}
async def stdio(self, name, method, params):
process = self.external[name]
request = {"jsonrpc": "2.0", "id": next(self.ids), "method": method,
"params": params}
process.stdin.write((json.dumps(request) + "\n").encode())
await process.stdin.drain()
# a server that died writes nothing and its pipe reaches end of file
line = await process.stdout.readline()
return json.loads(line)["result"] if line else None
async def list_from(self, name, config):
# an in-process server is handed over by the SDK; a stdio one is a command to run
if not isinstance(config, dict) or config.get("type") == "sdk":
await self.mcp(name, "initialize", {"protocolVersion": "2025-06-18",
"capabilities": {}, "clientInfo": {"name": "desk", "version": "1"}})
listed = await self.mcp(name, "tools/list", {})
return {"name": name, "status": "connected", "tools": listed["tools"]}
status = await self.connect_stdio(name, config)
if status["status"] == "connected":
status["tools"] = (await self.stdio(name, "tools/list", {}))["tools"]
return status
async def find_tools(self):
tools = {name: ("built-in", name) for name in self.offered()}
self.servers = []
for name, config in self.options.mcp_servers.items():
status = await self.list_from(name, config)
self.servers.append(status)
for tool in status.pop("tools", []):
tools[f"mcp__{name}__{tool['name']}"] = (name, tool["name"])
# a deny rule with no scope removes the tool before any call is made
bare = [rule for rule in self.options.disallowed_tools if "(" not in rule]
return {name: where for name, where in tools.items()
if not any(fnmatch(name, rule) for rule in bare)}
def in_context(self):
# what the model has definitions for: everything, or the searcher plus what it found
if not self.deferring:
return dict(self.tools)
found = {name: self.tools[name] for name in self.loaded}
return {"ToolSearch": ("built-in", "ToolSearch"), **found}
def search(self, query):
words = set(query.lower().split())
# the search matches the query against tool names, as the docs describe
hits = [name for name in self.tools
if words & set(name.lower().replace("mcp__", "").split("_"))]
# five at most, and each one's definition stays in context from now on
self.loaded.update(hits[:5])
return "Loaded: " + ", ".join(hits[:5]) if hits else "No tool matched."
async def use(self, name, args, parent=None):
call = f"toolu_{next(self.ids)}"
block = {"type": "tool_use", "id": call, "name": name, "input": args}
await self.send(self.message("assistant", [block], parent))
# a refused call reuses args as the text to send back, so Claude is told why
allowed, args = await self.permit(name, args, call)
text, failed = (await self.run(name, args, call)) if allowed else (args, True)
result = {"type": "tool_result", "tool_use_id": call, "content": text,
"is_error": failed}
await self.send(self.message("user", [result], parent))
return text
async def run(self, name, args, call):
# the answers you collected are what the question tool returns to the model
if name == "AskUserQuestion":
picked = args.get("answers") or {}
return "; ".join(f"{q}: {a}" for q, a in picked.items()), False
if name == "ToolSearch":
return self.search(args["query"]), False
where, tool = self.tools[name]
if where == "built-in":
try:
return BUILT_IN[tool](args), False
except Exception as error:
return f"{type(error).__name__}: {error}", True
call_args = {"name": tool, "arguments": args}
result = (await self.stdio(where, "tools/call", call_args) if where in self.external
else await self.mcp(where, "tools/call", call_args))
return result["content"][0]["text"], result.get("isError", False)
async def say(self, text):
# with include_partial_messages on, the text goes out a word at a time
if self.options.include_partial_messages:
for word in text.split(" "):
delta = {"type": "text_delta", "text": word + " "}
await self.send({"type": "stream_event", "uuid": f"ev_{next(self.ids)}",
"session_id": self.session_id, "event": {
"type": "content_block_delta", "index": 0,
"delta": delta}})
# the whole message still follows, so code written earlier keeps working
await self.send(self.message("assistant", [{"type": "text", "text": text}]))
async def finish(self, subtype, turns, text):
ok = subtype == "success"
result = {"type": "result", "subtype": subtype, "is_error": not ok,
"duration_ms": 0, "duration_api_ms": 0, "num_turns": turns,
"session_id": self.session_id, "total_cost_usd": 0.0,
"usage": {"input_tokens": len(" ".join(self.said).split()),
"output_tokens": len(text.split())}}
# cost is zero and usage counts words, because no model was called
result.update({"result": text} if ok else {"errors": [text]})
await self.send(result)
def message(self, kind, content, parent=None):
body = {"role": kind, "content": content}
if kind == "assistant":
body.update(model="shop-desk", id=f"msg_{next(self.ids)}")
return {"type": kind, "message": body, "parent_tool_use_id": parent,
"session_id": self.session_id}
async def permit(self, name, args, call):
o = self.options
if any(matches(rule, name, args) for rule in o.disallowed_tools):
return False, f"Permission to use {name} was denied by a rule."
# a question is always put to you, whatever the rules and the mode say
asks = name == "AskUserQuestion"
if not asks and (o.permission_mode == "bypassPermissions"
or any(matches(rule, name, args) for rule in o.allowed_tools)):
return True, args
# dontAsk never prompts, so everything still undecided is denied
if o.can_use_tool is None or o.permission_mode == "dontAsk":
return False, f"Permission to use {name} was not granted."
return await self.ask_permission(name, args, call)
async def ask_permission(self, name, args, call):
# a suggestion is the rule your code can echo back to stop the asking
suggest = [{"type": "addRules", "behavior": "allow", "destination": "session",
"rules": [{"toolName": name}]}]
decision = (await self.ask({"subtype": "can_use_tool", "tool_name": name,
"input": args, "tool_use_id": call,
"permission_suggestions": suggest,
"blocked_path": None}))["response"]
if decision["behavior"] != "allow":
return False, decision["message"]
# a rule you sent back is added to the session, so the next call matches it
for update in decision.get("updatedPermissions") or []:
for rule in update["rules"]:
self.options.allowed_tools.append(rule["toolName"])
return True, decision["updatedInput"]
def describe(message):
# the two messages with no content blocks are read from their own fields
if isinstance(message, SystemMessage):
return f"system {message.subtype}"
if isinstance(message, ResultMessage):
return f"result {message.subtype}: {message.result or message.errors}"
# a type this course does not use prints its name rather than failing
if not isinstance(message, (AssistantMessage, UserMessage)):
return type(message).__name__
return describe_block(message.content[0])
def describe_block(block):
if isinstance(block, TextBlock):
return f"assistant {block.text}"
if isinstance(block, ToolUseBlock):
return f"assistant calls {block.name} {block.input}"
# a tool result sits on the user's side, and its error flag is worth seeing
if isinstance(block, ToolResultBlock):
flag = " (error)" if block.is_error else ""
return f"user result{flag}: {block.content}"
return type(block).__name__
async def watch(prompt, options, model=None):
"""Run one question through a fresh desk and print every message that comes back."""
async for message in query(prompt=prompt, options=options,
transport=ShopDesk(options, model)):
print(describe(message))
from claude_agent_sdk import create_sdk_mcp_server, tool
ORDERS = {"A17": "Order A17 shipped on 3 March."}
REFUNDED = []
# the description is what Claude reads to decide this is the right tool
@tool("lookup_order", "Look up an order's shipping status by its id, such as A17.",
{"order_id": str})
async def lookup_order(args):
order = args["order_id"]
# an unknown id is answered in words, not raised, so Claude can pass it on
status = ORDERS.get(order, f"No order {order} was found.")
return {"content": [{"type": "text", "text": status}]}
# the money moves, so this is the call that must not run unasked
@tool("refund_order", "Refund an order by its id. The money goes back to the customer.",
{"order_id": str})
async def refund_order(args):
# recorded so a lesson can show whether the refund happened at all
REFUNDED.append(args["order_id"])
text = f"Order {args['order_id']} has been refunded."
return {"content": [{"type": "text", "text": text}]}
shop = create_sdk_mcp_server("shop", tools=[lookup_order, refund_order])
from claude_agent_sdk import ClaudeAgentOptions, PermissionResultAllow
from shop_desk import ShopModel, watch
from shop_tools import REFUNDED, shop
# one question, then two refunds, so the second shows the rule at work
script = [{"calls": [("AskUserQuestion", QUESTION)]},
{"calls": [("mcp__shop__refund_order", {"order_id": "A17"})]},
{"calls": [("mcp__shop__refund_order", {"order_id": "B22"})]}]
options = ClaudeAgentOptions(mcp_servers={"shop": shop}, can_use_tool=desk_person)
await watch("Please refund my order A17", options, model=ShopModel(script=script))
print("refunded:", REFUNDED)system init
assistant calls AskUserQuestion {'questions': [{'question': 'Where should the refund go?', 'header': 'Refund to', 'options': [{'label': 'Card', 'description': 'Back to the card'}, {'label': 'Credit', 'description': 'Shop credit'}], 'multiSelect': False}]}
Refund to - Where should the refund go?
* Card : Back to the card
* Credit : Shop credit
user result: Where should the refund go?: Credit
assistant calls mcp__shop__refund_order {'order_id': 'A17'}
approve? mcp__shop__refund_order {'order_id': 'A17'} ['addRules']
user result: Order A17 has been refunded.
assistant calls mcp__shop__refund_order {'order_id': 'B22'}
user result: Order B22 has been refunded.
assistant Order B22 has been refunded.
result success: Order B22 has been refunded.
refunded: ['A17', 'B22']Reading the three requests
- The question came through the callback, with its header, its text and its two options, and the answer went back as
{question: label}alongside the original questions, which the tool needs. - The first refund was an approval. The callback printed it, allowed it, and sent the suggested rule back.
- The second refund was never asked about. The rule from the first answer is in the session's allow list now, so the call was approved before the callback's step.
- Both refunds ran.
REFUNDEDholds A17 and B22.
Collecting the answer from a real person
In a terminal application the answer comes from input(), and a number picks an option while anything else is taken as free text:
def parse_response(response, options):
"""A number picks an option; anything else is the person's own words."""
# several numbers are a multi-select answer, so split before converting
try:
picked = [int(part.strip()) - 1 for part in response.split(",")]
labels = [options[i]["label"] for i in picked if 0 <= i < len(options)]
return ", ".join(labels) if labels else response
except ValueError:
return responseThe shape of the answer matters more than the interface. Each key is the question's own text, each value is the chosen option's label, a multi-select question takes a list of labels or a comma-joined string, and a free-text answer is the person's words rather than the word Other. There is also a response field for a reply that answers no question in particular, and Claude then receives "The user responded: ..." instead of the per-question list.
A tool approval against a clarifying question
| Tool approval | AskUserQuestion | |
|---|---|---|
| Who starts it | The permission flow, for a call nothing approved | Claude, when the task has more than one sensible path |
| What you get | A tool name and its arguments | A questions list, each with 2 to 4 options |
| What you return | Allow, or deny with a message | Allow, with questions and answers |
| Skipped by an allow rule | Yes | No, it always reaches the callback |
| In dontAsk mode | Denied | Denied |
Two places this shows up in an application
- An approval queue. The callback writes the request to your own store and waits, and a person approves it in your interface. A call from 1 to 4 questions is a form, not a chat.
- Plan mode. Questions are common while Claude is planning, which makes plan mode the natural fit for a flow that gathers requirements before anything is changed.
tools list that leaves out AskUserQuestion takes away Claude's ability to ask anything, and the run guesses instead. Include it in the list whenever you restrict the built-in tools. Subagents cannot use it at all.Related
- Previous: Asking your code with can_use_tool
- Next: Turn and budget limits
- Reference: the user input page and PermissionUpdate
- Pick the first option rather than the last and read what the tool result says.
- Return the suggestions only for the lookup tool, and watch the refund keep asking.
- Add a second question to
QUESTIONwithmultiSelectset to true, and return a list of labels.
Every expert started right here.