obsidian/wiki/agent-sdk/user-input-approvals.md
2026-04-17 12:50:20 +01:00

6.2 KiB
Raw Blame History

title aliases tags sources created updated
Handle Approvals and User Input
canUseTool
user-approvals
ask-user-question
agent-sdk
permissions
user-input
python
typescript
raw/Handle approvals and user input.md
2026-04-17 2026-04-17

Handle Approvals and User Input

Surface Claude's approval requests and clarifying questions to users, then return their decisions back to the SDK via the canUseTool callback.

When Claude Pauses for Input

Claude stops and fires canUseTool in two situations:

Situation tool_name value Trigger
Tool needs permission "Bash", "Write", "Edit", etc. Tool not auto-approved by wiki/agent-sdk/configure-permissions
Clarifying question "AskUserQuestion" Claude needs direction before proceeding

This is distinct from a normal conversation turn — execution is paused until your callback returns.

canUseTool Callback Setup

async def handle_tool_request(tool_name, input_data, context):
    # prompt user, return allow or deny
    ...

options = ClaudeAgentOptions(can_use_tool=handle_tool_request)

Python requirement: streaming mode + a PreToolUse hook returning {"continue_": True} — without it, the stream closes before the callback fires.

async def dummy_hook(input_data, tool_use_id, context):
    return {"continue_": True}

options = ClaudeAgentOptions(
    can_use_tool=can_use_tool,
    hooks={"PreToolUse": [HookMatcher(matcher=None, hooks=[dummy_hook])]},
)

Tool Approval Requests

Callback arguments

Argument Description
tool_name Tool Claude wants to use ("Bash", "Write", "Edit", "Read")
input_data Tool-specific parameters (e.g. command, file_path, content)
context Suggestions (PermissionUpdate entries) + cancellation signal

Response types

Response Python TypeScript
Allow PermissionResultAllow(updated_input=input_data) { behavior: "allow", updatedInput }
Deny PermissionResultDeny(message="reason") { behavior: "deny", message }

Response strategies

  • Approve — pass input unchanged
  • Approve with changes — sanitize paths, add constraints before passing
  • Reject — block the tool, Claude sees your message and may adjust
  • Suggest alternative — block + guide Claude toward what user wants
  • Redirect — use streaming input to send a new instruction entirely

AskUserQuestion — Clarifying Questions

When Claude needs direction (common in wiki/agent-sdk/configure-permissions), it calls AskUserQuestion. Route on tool_name == "AskUserQuestion".

Input format Claude sends

{
  "questions": [
    {
      "question": "How should I format the output?",
      "header": "Format",
      "options": [
        { "label": "Summary", "description": "Brief overview of key points" },
        { "label": "Detailed", "description": "Full explanation with examples" }
      ],
      "multiSelect": false
    }
  ]
}

Response format you return

{
  "questions": [ /* pass through original */ ],
  "answers": {
    "How should I format the output?": "Summary",
    "Which sections should I include?": "Introduction, Conclusion"
  }
}
  • Single-select: one label
  • Multi-select: labels joined with ", "
  • Free text: user's raw text (add an "Other" option in your UI)

Option previews (TypeScript only)

Set toolConfig.askUserQuestion.previewFormat to "markdown" or "html" — Claude adds a preview field to options where a visual comparison helps. Check for undefined before rendering.

Complete Python Example

def parse_response(response: str, options: list) -> str:
    try:
        indices = [int(s.strip()) - 1 for s in response.split(",")]
        labels = [options[i]["label"] for i in indices if 0 <= i < len(options)]
        return ", ".join(labels) if labels else response
    except ValueError:
        return response

async def handle_ask_user_question(input_data: dict) -> PermissionResultAllow:
    answers = {}
    for q in input_data.get("questions", []):
        print(f"\n{q['header']}: {q['question']}")
        for i, opt in enumerate(q["options"]):
            print(f"  {i+1}. {opt['label']} - {opt['description']}")
        response = input("Your choice: ").strip()
        answers[q["question"]] = parse_response(response, q["options"])
    return PermissionResultAllow(
        updated_input={"questions": input_data.get("questions", []), "answers": answers}
    )

async def can_use_tool(tool_name, input_data, context):
    if tool_name == "AskUserQuestion":
        return await handle_ask_user_question(input_data)
    # display tool info, prompt y/n
    response = input(f"Allow {tool_name}? (y/n): ")
    if response.lower() == "y":
        return PermissionResultAllow(updated_input=input_data)
    return PermissionResultDeny(message="User denied this action")

Limitations

  • AskUserQuestion is not available in subagents spawned via the Agent tool
  • Each call supports 14 questions, each with 24 options

Alternatives to canUseTool

Mechanism Best for
wiki/agent-sdk/hooks-guide Auto-allow/deny without prompting; external notifications (Slack, email) via PermissionRequest hook
Streaming input Interrupting mid-task, providing context, chat interfaces
wiki/agent-sdk/custom-tools Structured forms, multi-step workflows, external approval systems

Key Takeaways

  • canUseTool is the single callback for both tool approvals and AskUserQuestion — branch on tool_name
  • Python requires streaming mode + a PreToolUse dummy hook to keep the stream alive
  • Deny responses include a message Claude reads — use it to redirect, not just block
  • AskUserQuestion is especially common in plan mode; include it in your tools array if you define one
  • You can modify tool input before allowing (sanitize, constrain) — not just pass-through
  • For automation without user prompts, use wiki/agent-sdk/hooks-guide instead of canUseTool

Sources

  • raw/Handle approvals and user input.md — official Agent SDK docs on user input handling