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:
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:
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.
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." 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 textA 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
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)system init
assistant calls mcp__shop__refund_order {'order_id': 'A17'}
user result (error): Permission to use mcp__shop__refund_order was not granted.
assistant Permission to use mcp__shop__refund_order was not granted.
result success: Permission to use mcp__shop__refund_order was not granted.
refunded: []Nothing approved the refund, so the desk refused it. Changing one option changes the outcome:
# 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)system init
assistant calls mcp__shop__refund_order {'order_id': 'A17'}
user result: Order A17 has been refunded.
assistant Order A17 has been refunded.
result success: Order A17 has been refunded.
refunded: ['A17']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
REFUNDEDstayed 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.
Pick one to watch it run, step by step.
The six modes
| Mode | What it does to a call that would otherwise prompt |
|---|---|
default | Nothing is auto-approved by the mode. The call goes to your callback, and with no callback it is denied |
acceptEdits | File edits and filesystem commands such as mkdir, mv and rm are approved, inside the working directory or an added directory. Other tools still prompt |
plan | Claude explores and plans. File edits are never auto-approved, even by an allow rule, and reach your callback instead |
dontAsk | Anything that would prompt is denied, and the callback is never called. Calls an allow rule approves still run |
bypassPermissions | Everything that reaches the mode step is approved |
auto | A 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_toolsfor the calls you trust, ordontAskfor a fixed surface with a hard denial behind it.
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.Related
- Previous: Tool search for large tool sets
- Next: Allow and deny rules
- Reference: the permissions page and the permission modes reference
- 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_toolsand 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.