Claude Agent SDKclaude-agent-sdk 0.2.157 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

Permission modes

A permission mode is a session-wide setting that decides what happens to a tool call no rule has approved. It is one step in a fixed order of checks that every call goes through.

Last updated: 20 Oct, 2026 · Claude Agent SDK

The tools part gave the desk a lookup, a file reader and a catalogue of other people's tools, and ran every call it was asked for. That was fine for a lookup. The shop also needs refunds, and a refund moves money.

A tool that should not run on its own

shop_tools.py gains a second tool. REFUNDED records every refund, so a run can show whether one happened:

python
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])

The order Claude Code checks a call in

The permissions page sets out six steps, in this order. A call is resolved at the first step that has an opinion:

Hooks. Your PreToolUse hooks run first. A hook can deny the call outright or pass it on, and the hooks lessons write them.Step 1 of 6

The desk follows the same order. This lesson writes the mode step, the next one writes the rules, and the one after that writes the callback.

The stand-in this part runs against

Here is the file as the tools part left it: the scripted model, the transport that stands in for the Claude Code binary, the MCP plumbing, the built-in Read, the stdio servers and the tool search. Save it as shop_desk.py, and the lessons below add to it.

import asyncio
import itertools
import json
import os
import re

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}


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?"}

The mode step, and the call that asks it

Two changes: permit is added, and use is replaced with one that asks it before running anything.

python
    async def permit(self, name, args, call):
        # the mode step approves what reaches it, or sends the call on
        if self.options.permission_mode == "bypassPermissions":
            return True, args
        return False, f"Permission to use {name} was not granted."
python
    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

A refused call still sends a tool result, marked as an error and carrying the reason, because Claude has to be told why its request did nothing.

The same refund in two modes

Example
from claude_agent_sdk import ClaudeAgentOptions
from shop_desk import watch
from shop_tools import REFUNDED, shop

options = ClaudeAgentOptions(mcp_servers={"shop": shop})
await watch("Please refund my order A17", options)
print("refunded:", REFUNDED)

Nothing approved the refund, so the desk refused it. Changing one option changes the outcome:

Example
# bypassPermissions approves everything that reaches the mode step
loose = ClaudeAgentOptions(mcp_servers={"shop": shop},
                           permission_mode="bypassPermissions")

await watch("Please refund my order A17", loose)
print("refunded:", REFUNDED)

What the two runs tell you

  • In the default mode the refund reached the end of the checks with nothing to approve it and nobody to ask, so it was denied and REFUNDED stayed empty.
  • In bypassPermissions the same call ran, and the shop paid out.
  • Claude was told either way. The denial came back as an error tool result with the reason in it, which a real model would turn into a sentence for the customer.

Where a hook sits, and what comes after it

The trace below runs one call through all six steps. Pick a scenario and step through it.

What happens before a tool runs
denyClaude asks for a toola tool_use blockPreToolUse hooksyours run firstDeny, mode, allowthe rules and the modecan_use_toolyour callback decidesdenied, with a reasonClaude reads itthe tool runsand returns a result
Hover or tap a piece to see what it is and which lesson built it.
Follow a tool call

Pick one to watch it run, step by step.

The six modes

ModeWhat it does to a call that would otherwise prompt
defaultNothing is auto-approved by the mode. The call goes to your callback, and with no callback it is denied
acceptEditsFile edits and filesystem commands such as mkdir, mv and rm are approved, inside the working directory or an added directory. Other tools still prompt
planClaude explores and plans. File edits are never auto-approved, even by an allow rule, and reach your callback instead
dontAskAnything that would prompt is denied, and the callback is never called. Calls an allow rule approves still run
bypassPermissionsEverything that reaches the mode step is approved
autoA classifier model reviews actions such as shell commands and network requests, and allows or blocks each one it reviews

Those six are the values the installed PermissionMode type accepts. The mode can also be changed while a streaming session runs, with ClaudeSDKClient.set_permission_mode(), which is how an application starts strict and loosens up once the person has seen what the agent is doing.

What bypassPermissions turns off, and when it is reasonable

bypassPermissions approves every call that reaches the mode step. With the built-in tools in play that means Bash, Write and Edit run without anyone seeing them first, whatever allowed_tools says, because an unlisted tool is not matched by any allow rule and falls through to the mode. Four things still apply: deny rules, explicit ask rules, hooks, and the actions no mode auto-approves.

  • Reasonable: a throwaway container or a sandbox that holds nothing you care about, with no credentials in its environment and no network access to your systems.
  • Reasonable: a batch job over a copy of a repository, where the worst case is a spoiled copy.
  • Not reasonable: a developer machine, a shared server, anything with production credentials in its environment, or any session whose prompt can carry text from a customer.
  • Never a convenience default. If the prompts are the problem, the fix is allowed_tools for the calls you trust, or dontAsk for a fixed surface with a hard denial behind it.
Watch out. On Linux and macOS, Claude Code refuses to start in bypassPermissions as root or under sudo outside a recognised sandbox, and the query fails before the first turn. A subagent inherits this mode only when the parent session itself runs in it.
Try it yourself
  • Set permission_mode="dontAsk" and run the refund. The message is the same, and the next lesson shows where the two modes part company.
  • Add the refund tool to allowed_tools and run it in the default mode.
  • Print flags(options) for the bypass options and find the mode on the command line.

You understood something today that you didn't yesterday.