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:
- The
awaited state (who's waiting, on what, since when) lives only in your process's memory. A restart, a deploy, a crash — anything that kills the process — loses every pending approval with it, silently, with no record that it ever existed. - A process held open for hours or days waiting on one
canUseToolcall is still a process consuming a slot, a connection, maybe a container, for the entire wait — expensive if you're running many agents and only a few are ever actually blocked on a human at once. - There's no way to inspect or answer a pending decision from anywhere except that exact in-memory promise — no dashboard, no "list what's currently waiting," nothing durable to point a notification at.
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.