Psych Runtime
Guides

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...

This is the documentation for unreleased changes on main. For the released library, read v0.1.

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.

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.

Gotchas

  • Approval is per call, not per turn. A turn asking for three destructive calls suspends on each.
  • interruptible=False and 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.
  • The failure-streak guard does not count a call a policy refused before execution, nor one the user cancelled. It counts genuine tool failures.

On this page