Require approval for selected tool calls
An approval is a suspension, not a callback. The Run releases its lease, persists, and waits. Nothing is held in memory, so the process that resumes it need not be the one that...
An approval is a suspension, not a callback. The Run releases its lease, persists, and waits. Nothing is held in memory, so the process that resumes it need not be the one that asked. Approvals, clarifying questions and webhook waits are one mechanism rather than three, because they all run through the same lease and log machinery.
Annotate, then select
Tools carry MCP-style annotations. Selectors on the Runtime decide which
classes need a decision.
@registry.register(annotations={"read-only"})
async def lookup_order(order_id: str) -> dict[str, str]:
"""Look up an order by its id."""
@registry.register(interruptible=False, annotations={"destructive"})
async def issue_refund(order_id: str, cents: int) -> str:
"""Refund an order."""
runtime = psych_runtime.Runtime(
store=store,
model=model,
registry=registry,
approval_selectors=("@destructive",), # default is ("@write", "@destructive")
)approval_selectors is process-wide by design: one host enforces one approval
policy for every agent it runs. A consumer wanting it per agent builds the
Runtime per Attempt, which is what
examples/playground/backend/app/runtime_router.py does. Putting it on the Spec
would put an approval policy inside a Version hash.
Classification, and the two rules that surprise people
destructive beats write beats read-only when a tool carries more than one.
A tool claiming to be both read-only and destructive is either mis-annotated or
lying, and both readings make the strict one right.
An unannotated tool is treated as write, never exempt. The tempting
alternative is to read the selectors literally: no annotations, so it matches
none of the three, so no approval. That hands an exemption to exactly the
servers least likely to have earned it. An MCP server that forgot to annotate a
destructive tool should not buy that tool a pass. A missing annotation costs an
extra approval prompt rather than an unreviewed destructive call.
A partially annotated tool reads MCP's own default. destructiveHint
defaults to true, so {"title": "Delete user"} is destructive rather than merely
a write. Reading it as a write would let someone who narrowed their selectors to
@destructive wave through exactly the calls the annotation exists to catch.
Beyond the selectors, always and never name individual tools. never is
checked last, so an explicit exemption beats a selector. Use it sparingly.
A deferred MCP tool is judged as itself, not as call_tool. When a
server's catalogue is deferred out of the prompt (psych-mcp), the model
reaches its tools through the call_tool wrapper. The gate resolves the tool
the call names and applies the selectors, always, never and your Policy
to that tool's qualified name (github__delete_repo) and annotations. A
target the Run cannot resolve is treated as destructive.
The Policy port
Selectors decide what class needs review. Policy decides whether this
Scope may do this, now.
class OurPolicy:
async def allow_tool(self, scope, tool, args) -> psych_runtime.Decision:
if tool == "issue_refund" and args["cents"] > 400_000:
return Decision.ask("refunds over £4000 need a manager")
if not await our_rbac.may(scope.principal, tool):
return Decision.deny("your role does not include refunds")
return Decision.allow()
async def allow_run(self, scope, version) -> Decision:
return Decision.allow()
runtime = psych_runtime.Runtime(..., policy=OurPolicy())Arguments are passed because "may refund" and "may refund £4000" are different questions, and only you know which one you are asking.
Decision.deny(reason) reaches the model as the tool's result, so it can
explain or try something else. Write it as an explanation, not an error code.
Decision.ask(reason) suspends for a human instead of failing.
A Policy that raises is treated as a denial rather than a crash: an
authorization system being down should stop work rather than let it through, and
should not take the Run's whole log with it.
The default is AllowAll, which says yes to everything. It ships so a consumer
evaluating Psych does not have to write an authorization system first. It is not
a security control and its name says so.
Reading and resolving a pending approval
status = await psych_runtime.status(store, run_id, scope=scope)
if status.pending_approval is not None:
call = status.pending_approval # the exact call awaiting a decision
await psych_runtime.resume(store, run_id, approved=True, by="manager-7")by= is who decided, as you identify people. Psych records it on the resumed
record and interprets it as nothing. It exists because an approval of a
destructive call whose log cannot say who approved it is not an audit trail.
psych_runtime.status() is the projection to serve to a UI. psych_runtime.state() is the
reducer's own working object and carries bookkeeping that exists to make the
next fold cheap; use status() unless you are extending the runtime.
Expiry
SuspensionPolicy.approval_expires_seconds defaults to 24 hours. A decision
arriving after that raises SuspensionExpired, and the Run is settled
ABANDONED first, so a stale approval never executes. The settlement
carries a suspension_expired failure, and a parent waiting on that Run as a
child is notified like any other ending.
Pass scope= to resume() when serving end users; a Run belonging to another
tenant is refused with AccessDenied before anything is decided.
Approvals and code execution
An approval suspends the Run and resumes into a turn. There is no turn inside a running subprocess and nothing can resume into one, so a program that calls a tool the selectors would gate is refused before the tool runs — the only refusal that prevents anything — with:
ToolError.kind == "approval_required_in_program"The refusal is recorded as a failed tool call under its parent run_code, so a
report shows the attempt rather than a gap. The model is told to call the tool
directly, and the tool is still in its tool list to call: a gated tool is not
withdrawn, it is only unreachable from inside a program.
This applies to every origin. Before this, _gate looked a binding's
annotations up in the code-tool registry, found nothing for an HTTP, MCP or
A2A tool, and let it through ungated — an approval-gated HTTP POST was callable
from a program with no approval at all. The annotations now come from the
turn's resolved tool set, which is where every origin's annotations live.
Gotchas
- Approval is per call, not per turn. A turn asking for three destructive calls suspends on each.
interruptible=Falseand approvals are unrelated. One is about aborting mid-call; the other is about permission to start.- A denied call is not a failed Run. The model is told and continues.
- A workflow
toolstep is gated too.Decision.ask()suspends the Run on that step (status.pending_approvalwithcall_id=Noneandstep_idset, carrying the tool and its resolved arguments);resume(approved=True)runs it,approved=Falsefails the step with kinddenied, which is never retried. Seepsych-workflows. - The failure-streak guard does not count a call a policy refused before execution, nor one the user cancelled. It counts genuine tool failures.