Claude Agent SDK permissions: canUseTool vs PreToolUse, and which one sees every call

The Claude Agent SDK gives you two places to check a tool call: the canUseTool callback and a PreToolUse hook. They run at opposite ends of the permission flow. Put a check in the wrong one and it never runs.

The short answer

In the Claude Agent SDK, a tool call passes six steps in order: hooks, deny rules, ask rules, the permission mode, allow rules, and finally your canUseTool callback.1 canUseTool is the last step, so it only sees calls nothing earlier resolved. The docs warn that a call approved earlier, by an allow rule, acceptEdits or bypassPermissions, skips your callback, “so permission checks you put there are silently bypassed for that tool”.1

A PreToolUse hook is the first step. It runs before everything else, and a hook deny applies even in bypassPermissions mode.1 The rule of thumb: use canUseTool to ask a person, and a PreToolUse hook for any check that must run on every call.

Every MCP tool call goes through your rules first. Here a delete that doesn’t say which rows is stopped before it reaches the database. These are the rules deedglass init starts with; the call is made up.

The six steps

The docs give the evaluation order, and each step has a catch worth knowing.1

Step What happens The catch
1. Hooks A hook can deny, ask, allow or pass A hook allow doesn’t skip the deny and ask rules below
2. Deny rules disallowed_tools and settings deny rules block the call Holds even in bypassPermissions and after a hook allow
3. Ask rules Settings ask rules send the call to canUseTool Holds even in bypassPermissions
4. Permission mode bypassPermissions approves, acceptEdits approves file operations, plan sends edits to the callback Approval here means the callback never runs
5. Allow rules allowed_tools and settings allow rules approve A bare tool name approves every call to that tool
6. canUseTool Your callback decides Skipped entirely in dontAsk mode, which denies instead

Our framework comparison put the SDK next to eight others;1 this post goes deeper on the two hooks you write yourself.

What canUseTool is for

canUseTool is the approval prompt. It fires when Claude wants a tool that no rule or mode has approved, and when Claude asks a clarifying question through AskUserQuestion.2 It receives the tool name, the tool’s input (for Bash, command, description and timeout) and a context object with optional suggestions for rules that would avoid prompting again.2

It returns one of two answers:2

Code Program code, for developers. The text around it says what it does.

canUseTool: async (toolName, input) => {
  if (toolName === "Bash" && /\bpsql\b/.test(String(input.command))) {
    const ok = await askReviewer(toolName, input);   // your own UI
    if (!ok) return { behavior: "deny", message: "Reviewer declined the database command" };
  }
  return { behavior: "allow", updatedInput: input };
}

Three details from the docs matter in production:

  • It can wait forever. Execution stays paused until the callback returns. If a person may take longer than your process can stay up, the docs suggest a PreToolUse hook returning defer, so the process can exit and resume later.2
  • You can change the input. Returning a modified updatedInput runs the tool with your version, and Claude isn’t told you changed anything.2 That’s useful for scoping a path, and a reason to log what you changed.
  • “Always allow” writes a rule. Echoing a suggestion with the localSettings destination back in updatedPermissions writes the rule to .claude/settings.local.json, so future sessions skip the prompt.2 That rule then approves calls at step 5, and your callback won’t see them again.

In Python, the docs’ examples pass the prompt as a stream and register a no-op PreToolUse hook, labelled a “required workaround” that keeps the stream open for can_use_tool.2 If your Python callback never fires, check that first.

What a PreToolUse hook is for

A hook is code that runs before the permission flow. You register it with a matcher on the tool name, and it returns a decision in hookSpecificOutput.permissionDecision: allow, deny, ask or defer, plus a reason Claude sees.3

Code Program code, for developers. The text around it says what it does.

async def block_destructive_sql(input_data, tool_use_id, context):
    cmd = input_data["tool_input"].get("command", "")
    if re.search(r"\b(drop|truncate)\b", cmd, re.I):
        return {"hookSpecificOutput": {
            "hookEventName": input_data["hook_event_name"],
            "permissionDecision": "deny",
            "permissionDecisionReason": "Destructive SQL needs a human; open a ticket"}}
    return {}

options = ClaudeAgentOptions(
    hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[block_destructive_sql])]})

How hooks combine and fail, from the docs:3

Behaviour What the docs say
Several hooks match They run in parallel; the most restrictive result wins
Priority deny beats defer, which beats ask, which beats allow
Default timeout 600 seconds for PreToolUse, set per matcher with timeout
Hook times out The tool call is blocked and the turn continues
Matchers Match tool names only, not paths or arguments

The timeout behaviour is the safe one: a PreToolUse callback that doesn’t answer blocks the call.3 Our post on Copilot’s preToolUse hooks shows that not every agent behaves this way.

Four configurations that skip your callback

These come straight from the docs, and each one quietly turns canUseTool into dead code for some calls.1

  1. bypassPermissions mode. Everything that reaches step 4 is approved. The TypeScript SDK emits a process warning, code CLAUDE_SDK_CAN_USE_TOOL_SHADOWED, when you pass a callback with this mode.
  2. A bare tool name in allowedTools. "Bash" approves every Bash call at step 5. The same warning lists such entries. A scoped rule such as Bash(npm test *) approves only matching calls and doesn’t trigger it.
  3. acceptEdits mode. File edits and commands such as rm, mv and sed inside the working directory are approved at step 4.
  4. dontAsk mode. Step 6 is skipped and the call is denied. That’s the right choice for a headless agent, but your callback won’t run.

One more trap: allowed_tools does not constrain bypassPermissions. Setting allowed_tools=["Read"] with bypassPermissions still approves Bash, Write and Edit. To block specific tools in that mode, use disallowed_tools.1

A pattern that holds up

For an agent that can touch anything that matters:

  1. Hard rules in a PreToolUse hook. Destructive SQL, production hosts, secrets files. The hook runs first and its deny holds in every mode.1
  2. Hide what the agent never needs. A bare name in disallowed_tools removes the tool from the request, so Claude can’t attempt it.1
  3. Scoped allow rules for the routine. Bash(npm test *), not Bash.
  4. canUseTool for the judgement calls. It sees what’s left: calls that need a person.
  5. dontAsk for headless runs. With no one to ask, a hard deny beats a callback that waits forever.1
  6. Log decisions outside the agent. Record what each hook and callback decided, including any updatedInput you rewrote.

Limits of both

Both check the request, not the result. A hook sees a command string; it doesn’t see what the database did with it. A Write|Edit matcher doesn’t run when Claude writes the same file with Bash; the docs say so in their own .env example.3 Regex on shell commands misses aliases, variables and scripts; our post on why regex can’t block DROP TABLE shows how. And a hook with an allow can’t approve recognised critical-path removals such as rm -rf ~, which is a backstop, not a policy.1

For anything with side effects outside the machine, pair the hook with a check on the target system itself: a database role with only the rights the agent needs, and a record of what actually changed.

Questions people ask

Why isn’t my canUseTool callback being called?

Something earlier approved the call: an allow rule (often a bare tool name), acceptEdits, bypassPermissions, or a saved “always allow” rule. In dontAsk mode it’s never called.1 In Python, check the streaming-input workaround.2

Can a PreToolUse hook approve a call?

Yes, by returning allow, but deny and ask rules are still evaluated after it.1

Which should I use for human approval?

canUseTool, for calls that reach it. For approvals that must cover every call of a tool, have a hook return ask or defer.3

To see which agents and MCP servers on your machine can reach a database, run npx deedglass scan (the grade, explained). To watch approvals and read-backs on a real database in your browser, open the playground.

Written with AI assistance and checked against our claims register.

The takeaway

In the Claude Agent SDK, canUseTool runs last and is skipped for calls already approved; a PreToolUse hook runs first, and its deny holds even in bypass mode.

Read nextAI agent framework guardrails compared: where nine frameworks let you stop a tool call Try it: install in 7 s

Related on this site

More posts

Sources

  1. 1Claude Agent SDK docs: Configure permissions · code.claude.com · checked 27 October 2026
  2. 2Claude Agent SDK docs: Handle approvals and user input · code.claude.com · checked 27 October 2026
  3. 3Claude Agent SDK docs: Intercept and control agent behavior with hooks · code.claude.com · checked 27 October 2026

Published 27 October 2026, sources last checked 27 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded.

Pricing