permission_mode on ClaudeAgentOptions picks how permission requests are settled. 'default' prompts the user before any tool that has side effects. 'acceptEdits' auto-approves Write/Edit but still prompts for Bash. 'bypassPermissions' approves everything (use for trusted CI only). 'plan' puts the agent in dry-run mode — it plans actions without executing. 'dontAsk' never prompts and denies whatever was not pre-approved. The list grows with the SDK (newer releases also accept 'auto'), so read PermissionMode in the version you install.
Pick by who is on the other end
If a developer is at the keyboard reviewing each prompt, 'acceptEdits' often hits the right tradeoff (file edits flow, shell still asks). If the agent runs unattended but a person answers through your own UI, 'default' with a custom permission handler is the posture; if nobody answers at all, 'dontAsk' with a short allowed_tools list denies instead of hanging. 'bypassPermissions' is for trusted CI runners that already pass through review elsewhere.
Write your own permission handler
Beyond the modes, you can supply a callback that decides per-tool. The callback receives the tool name, the tool input, and a ToolPermissionContext, and returns PermissionResultAllow or PermissionResultDeny. It is only consulted for calls that would otherwise prompt: a tool you pre-approved in allowed_tools never reaches it, and neither does a shell command the CLI already treats as read-only, such as echo or ls. Test a handler with a command that writes. cwkPippa registers no such callback today, so this is the pattern for when a human UI sits on the other end, not a tour of its code.
Principle: Permission posture is a UX decision as much as a security decision. Match it to who is actually in the loop.
Code
Mode selection by context·python
from claude_agent_sdk import ClaudeAgentOptions
# Interactive developer pairing: accept edits silently, ask for shell.
dev_options = ClaudeAgentOptions(
cwd="/Users/me/repo",
permission_mode="acceptEdits",
)
# Server-side worker with a human somewhere: route every prompt to your UI.
server_options = ClaudeAgentOptions(
cwd="/srv/jobs",
permission_mode="default",
can_use_tool=permission_handler, # your function, next block
)
# Unattended and strict: nothing prompts; whatever is not pre-approved is denied.
batch_options = ClaudeAgentOptions(
cwd="/srv/jobs",
permission_mode="dontAsk",
allowed_tools=["Read", "Grep"],
)
# Trusted CI in a sandboxed container: bypass, and narrow what exists instead.
ci_options = ClaudeAgentOptions(
cwd="/workspace",
permission_mode="bypassPermissions",
tools=["Read", "Edit", "Bash"], # the only built-in tools this run has
)
# Plan-only mode for dry runs.
plan_options = ClaudeAgentOptions(
cwd="/srv",
permission_mode="plan",
)
Custom permission handler·python
from claude_agent_sdk import (
ClaudeAgentOptions,
PermissionResultAllow,
PermissionResultDeny,
ToolPermissionContext,
)
DEMANDS_REVIEW = {"Bash", "Write"}
async def permission_handler(tool_name: str, tool_input: dict, context: ToolPermissionContext):
if tool_name not in DEMANDS_REVIEW:
return PermissionResultAllow()
# Forward the request to your UI; the user clicks approve or deny.
decision = await ask_user_in_ui(tool_name, tool_input)
if decision == "approve":
return PermissionResultAllow()
return PermissionResultDeny(message="User denied via UI")
options = ClaudeAgentOptions(
cwd="/srv",
permission_mode="default",
can_use_tool=permission_handler, # never consulted for tools listed in allowed_tools
)
Audit one Agent SDK call in your project. Identify the permission_mode in use and one caller-fact that makes it the right choice. If the answer is 'I copy-pasted bypassPermissions', that is the bug to fix.
Hint
Think about who is the responsible human at runtime. The mode should match who can answer 'should I run this?'
Progress
Progress is local-only — sign in to sync across devices.