Permissions & approvals
Decide which tool calls run on their own, which wait for a person, and which never run.
Every tool call gets one of three answers before it runs: allow, ask (wait for a person to approve) or deny. You set the rules per agent; threads records every decision in the thread's log.
Out of the box, only read-only work runs on its own. Any tool you don't declare as effect: "read_only" asks for approval in the default mode. Allow it with a rule, or mark it read-only if it really is.
Set permissions
const coder = agent({
name: "coder",
model: scriptedModel({ responses: [{ content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", usage: { input_tokens: 1, output_tokens: 1 } }] }),
permissions: {
mode: "accept_edits",
allow: ["bash(git status)", "bash(npm test:*)", "web_fetch(domain:docs.example.com)"],
ask: ["bash(git push:*)"],
deny: ["bash(rm:*)", "read(secrets/**)", "mcp__github__*"],
},
approvers: [{ issuer: "slack:T024BE7LD", tenant: "acme", subject: "U42" }],
});Pass only the fields you change; the rest keep their defaults. In Python you can also pass a complete Permissions (from threads.log).
| Field | Default | Meaning |
|---|---|---|
mode | "default" | The starting mode. |
allow / ask / deny | [] | Rules. |
protected_paths | .git/**, .threads/**, .claude/**, .mcp.json, shell rc files, .gitconfig, .ssh/** | Edits to these always ask, even when a mode or rule would allow them. |
allow_bypass | false | Whether the thread may ever switch to bypass. |
How a call is decided
The first step that gives an answer wins:
Config guard
Any call that could change threads' own config (a .threads path in any argument, outside read-only tools) is denied. An agent can never rewrite its own setup.
Deny rules
A matching deny rule denies, then a deny rule remembered on the thread.
Plan mode
In plan mode, anything that isn't read-only is denied, except the agent's todo list.
Protected paths
An edit to a protected path asks.
Ask, then allow rules
A matching ask rule asks; otherwise a matching allow rule allows, then a rule remembered on the thread (see Approvals).
The mode
Nothing matched, so the current mode decides.
Hooks run alongside this: a beforeTool hook can deny or ask for any call, and a permissionRequest hook can answer a call that would ask. See Hooks.
Modes
Tools fall into three groups: read-only (tools declared effect: "read_only", such as read, ls, glob, grep), edits (write, edit, notebook_edit) and everything else (shell, web, your side-effecting tools). Reads inside the sandbox workspace are always allowed.
| Mode | Reads outside the workspace | Edits in the workspace | Everything else |
|---|---|---|---|
default | ask | ask | ask |
accept_edits | ask | allow | ask |
plan | ask | deny | deny |
dont_ask | deny | deny | deny |
bypass | allow | allow | allow |
In dont_ask, anything that would ask is denied instead, which suits unattended jobs. plan lets the agent look around and plan without changing anything; its todo list still works. Leave plan mode by switching the thread's mode yourself. bypass needs allow_bypass: true.
Change the mode
Switch a thread's mode between runs. The change is recorded in the log and applies from the next step.
const planned = await thread.setMode("plan", me); // read-only from the next step
if (!planned.ok) console.log(planned.error.code);
const back = await thread.setMode("default", me);
if (!back.ok) console.log(back.error.code);
const bypass = await thread.setMode("bypass", me);
if (!bypass.ok) console.log(bypass.error.code); // invalid_transition unless allow_bypass: truethread is a thread handle (see Human-in-the-loop) and me is the principal making the change.
Rules
A rule is a tool name, optionally with a specifier in parentheses: tool or tool(specifier). A tool name ending in * matches by prefix, such as mcp__github__* for every tool of one MCP server.
| Tool | Specifier | Example |
|---|---|---|
File tools (read, write, edit, ls, glob, grep, notebook_edit) | A gitignore-style glob, relative to the workspace | edit(src/**), read(secrets/**) |
bash | An exact command, or a prefix ending in :* | bash(git status), bash(npm test:*) |
bash(*) | Every command, whatever it contains. The .threads guard, deny rules, plan mode and ask rules still apply first. Use it only when the sandbox is your boundary. | bash(*) |
web_fetch | domain: a host, or domain:*. a parent domain | web_fetch(domain:*.example.com) |
spawn_agent, handoff | An agent name | spawn_agent(researcher) |
| Any tool | None | send_email, mcp__github__* |
For bash, a command is split into its simple commands (a && b | c, a; b), including the ones inside if, while, for and case, subshells ( … ), groups { …; } and $( … ). A deny or ask rule matches if any of them matches, at any depth. An allow rule allows only if every one of them is allowed, so git status && npm test needs both commands allowed. A command inside a control structure or a group is never allowed by a rule, and neither is anything the parser doesn't read (a redirection, $, a backslash, a leading PATH=, or a script handed to bash -c), so even a bare bash allow asks for those, or leaves them to the mode.
bash(*) is the exception: it matches every command in allow, ask and deny, and it is never offered as a suggested rule. With bash(*), the .threads guard and deny rules still read the command text, but text can be disguised, so treat them as guardrails: the sandbox is the boundary. Protected paths cover the file tools, not bash.
Approvals
When a call asks, the run stops and returns parked with reason awaiting_approval. A person approves or denies it, then the thread continues. The full flow is on Human-in-the-loop.
When approving, you can keep one of the call's suggested rules (such as send_email or bash(npm test:*)), so later matching calls on that thread run without asking.
A pending approval's reason says why the call asks, when one was recorded. A memory write that asks because of memory_write names the setting to change. The reason uses wire names, so memory_write in it is memoryWrite in TypeScript.
Who may approve
approvers lists the principals who may answer this agent's approvals through the host, including for its subagents and handoff targets.
When approvers is unset, the default differs by language today. TypeScript: any authenticated principal of the thread's tenant through the HTTP API, and nobody through a channel. Python: the principal who started the run. Set approvers explicitly if it matters.
Ceilings
Pass ceiling to a run to cap everything it starts. Each call is decided under the agent's own permissions and again under the ceiling, and the stricter answer wins. A ceiling covers the run's subagents and any agent it hands off to.
const result = await coder.run("hi", { store, ceiling: { deny: ["bash(curl:*)"] } });Subagents are always capped by their parent's permissions this way. See Subagents.