Human approval in the Claude Agent SDK: canUseTool, defer, and resume

A callback that "can stay pending indefinitely" is not the same thing as a callback you should actually leave pending indefinitely. Here's the difference, in both SDKs.

On this page

When canUseTool actually fires

canUseTool is not called on every tool call — it's the last stop in the permission flow, reached only when hooks, deny rules, ask rules, and the current permission mode all fail to resolve a decision on their own. A call that's auto-approved by an allow rule, or auto-denied by a hook, never reaches your callback at all. That matters for how you design it: canUseTool is the place for calls that genuinely need a judgment nothing upstream could make — not a general logging hook for every tool call the agent makes.

Both SDKs implement this by launching the CLI with --permission-prompt-tool stdio under the hood and answering can_use_tool control requests over the same stdio stream-json channel the SDK already uses to talk to the CLI process. That's also why "stdio" is a reserved MCP server name in your own config (see the raw CLI contract this is built on) — and why passing both canUseTool and permissionPromptToolName to the SDK throws; they're mutually exclusive wiring for the same underlying mechanism.

The callback shape, TS and Python

// TypeScript
canUseTool: async (
  toolName: string,
  input: Record<string, unknown>,
  options: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
) => Promise<PermissionResult>
// PermissionResult =
//   { behavior: "allow", updatedInput: Record<string, unknown>, updatedPermissions?: PermissionUpdate[] }
// | { behavior: "deny", message: string, interrupt?: boolean }
# Python
CanUseTool = Callable[[str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult]]
# returns PermissionResultAllow(updated_input=...) or PermissionResultDeny(message=...)

A basic implementation that allows read-only tools and asks for everything else:

async function canUseTool(toolName, input, { signal }) {
  if (toolName === "Read" || toolName === "Grep") {
    return { behavior: "allow", updatedInput: input };
  }
  const approved = await askAHuman(toolName, input, signal); // your own implementation
  return approved
    ? { behavior: "allow", updatedInput: input }
    : { behavior: "deny", message: "denied by operator" };
}
async def can_use_tool(tool_name, input, context):
    if tool_name in ("Read", "Grep"):
        return PermissionResultAllow(updated_input=input)
    approved = await ask_a_human(tool_name, input)  # your own implementation
    return (
        PermissionResultAllow(updated_input=input)
        if approved
        else PermissionResultDeny(message="denied by operator")
    )

Why not to just block inside it

The docs are explicit that the callback "can stay pending indefinitely" — the SDK itself only cancels it when the whole query is cancelled, not on any internal timeout of its own. That's tempting: just await a promise that resolves whenever a human answers, and let it sit there for however long that takes. It works for a demo. It's a bad idea for anything long-running, for a few concrete reasons:

defer + resume instead

The durability seam the docs actually recommend is different: use a PreToolUse hook (not canUseTool itself) that returns the defer decision (CLI ≥2.1.89, -p-only). That exits the current process with stop_reason: "tool_deferred" and a deferred_tool_use: {id, name, input} payload describing exactly the pending call — the process is free to exit entirely, and the decision's state lives wherever you put it (a database row, a queue message), not in that process's memory.

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [{ "type": "command", "command": "./defer-if-needed.sh" }] }
    ]
  }
}
# defer-if-needed.sh — write the pending call somewhere durable, then defer
input=$(cat)
echo "$input" | store_pending_approval   # your own durable store
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer"}}'

Later — seconds, hours, or days afterward, once your durable store shows the decision made — resume the same session, which re-fires the same hook for that exact deferred call:

// TypeScript
for await (const message of query({
  prompt: "continue",
  options: { resume: sessionId, permissionPromptToolName: "stdio" },
})) { /* ... */ }
# Python
options = ClaudeAgentOptions(resume=session_id)
async for message in query(prompt="continue", options=options):
    ...
# CLI equivalent, if you're not going through the SDK for the resume step
claude -p --resume "$SESSION_ID" --permission-prompt-tool mcp__yourservice__permission_prompt

This is the same durability tradeoff the OpenClaw poll-by-id pattern and cron's own locking/timeout concerns are solving in their own frameworks — split "ask" from "wait" so the waiting doesn't have to happen inside one live process.

Cross-host resume with a session store

By default, session state persists as JSONL under ~/.claude/projects/... on whichever machine paused it — fine if the same machine will resume it later, not fine if you want to defer on one worker and resume on a completely different one (a common shape for a fleet of disposable workers rather than one long-lived box). Both SDKs expose a sessionStore (TypeScript) / session_store (Python) adapter — implement append(key, entries) / load(key) against S3, Redis, or Postgres and the SDK writes there instead of local disk, so a deferred session can resume anywhere that has access to the same store. See anthropics/claude-agent-sdk-typescript/examples/session-stores for a worked reference implementation.

MCP servers with auth headers

The same shape works for wiring your own MCP server into the SDK options, not just for the permission-prompt path:

options = ClaudeAgentOptions(
    mcp_servers={"yourservice": {"type": "http", "url": "https://api.example.dev/mcp",
                                  "headers": {"Authorization": f"Bearer {os.environ['API_KEY']}"}}},
    allowed_tools=["mcp__yourservice__*"],
)

OAuth isn't automated by either SDK — there's no browser flow built in. Complete the OAuth exchange yourself, however you'd do it outside the SDK, and drop the resulting token straight into headers, the same way you'd drop in a static API key. Connection status per server (pending/connected/failed/needs-auth/disabled) comes back in the system:init message and is pollable afterward via mcpServerStatus() (TS) / get_mcp_status() (Python), which is the thing to check before assuming a tool call against that server will actually work.