When a tool action needs approval
Every tool action passes four governance checks: whether the acting Principal and the agent installation may eachuse_connection on the Connection, and whether each may use_tool on the tool source.
A “safe read” always requires safety hints that mark the tool
read_only, not destructive, and not requires_approval, and a read-only transport method. MCP-bound tools always POST, and their hints are authored by the upstream server and refreshed on every source sync, so an MCP-bound tool is never a safe read and never auto-allows on hints alone; an explicit allow ActionPolicy on every mandatory check is the only way to auto-allow one. Anything else defaults to review. The default is deliberate: an unreviewed mutation is the failure mode worth being conservative about.
The flow
The Run parks; it does not fail. A denial resumes the Run too: the harness is told the tool action was refused and continues from there. For a Custom tool,agent.tool_use records the exact arguments before the decision. ActionApproval authorizes your application to execute that already-recorded tool action and submit its result; it is not a confidentiality gate over the arguments and is not itself a result. Read the tool action from agent.tool_use, act after Checkfu emits run.action_authorized, and post the exact user.custom_tool_result. The same compute-closed Run stays parked throughout. ActionApproval itself starts no Run, dispatch, lease, or Sandbox; posting the result creates the one ordinary continuation Run.
When the tool action belongs to a child SessionThread, the primary Session log
cross-posts agent.tool_use with session_thread_id. Send
user.tool_confirmation through that primary Session’s event endpoint and
echo both the thread and tool-use ids. Checkfu still applies the ActionApproval’s
frozen policy and responder-authority checks before accepting the event.
allow changes the same child wait from ActionApproval to Custom-result; your app
must then send user.custom_tool_result with the same two ids. deny resumes
the child with policy guidance.
What a reviewer sees
An ActionApproval carries acontext_summary. ToolInvocation ActionApprovals include the Session, Run, Agent, Connection and tool versions, tool schema hash, and the outbound request’s scheme, host, port, method, and path only. Custom-tool ActionApprovals instead carry kind: "custom_tool", the admitted AgentDefinition and version, nullable installation and surface, and the exact tool-use ID and logical tool name. They never invent Connection or ToolSource fields.
It deliberately excludes the query string, headers, and body. Arguments appear as arguments_hash, never as values. A review surface can therefore be shown to someone who is not cleared to read the payload.
Proving a draft is the approved payload
Because the ActionApproval exposes only the hash, showing a human “here is what will run” is a claim until you prove it. Obtain the argument bytes through your own channel — for a Custom tool they are on theagent.tool_use event — then recompute the hash and compare. Equality proves the bytes on screen are exactly the frozen ones; a mismatch means the draft is not the approved payload and must not be presented as it.
The recipe is public, and @checkfu/sdk ships it so you never re-implement it:
canonicalJson is JSON with recursively sorted object keys and no insignificant whitespace. Object key order is the only thing normalized — array order stays significant, so reordering a list changes the hash. Values that JSON cannot represent losslessly (undefined members, functions, NaN, class instances) are refused rather than silently dropped, because hashing a lossy re-encoding would “prove” a payload you never saw. canonicalJson is exported too, if you want to inspect or log the exact pre-image.
This recipe is part of the public contract, not an implementation detail. A drift test pins the SDK helper byte-for-byte against the platform implementation that computes
arguments_hash, so a future canonicalization change fails a gate rather than silently invalidating your proof.Naming differs by surface in the current wire contract: the ActionApproval resource (
GET /v1/action-approvals/{id}) carries its public review summary under context_summary with arguments_hash, while the event action_approval.pending carries the event-loop coordinates and overlapping frozen authority under frozen_context with arg_hash. Match on the surface you are reading. See the event payload.The freeze
At request time Checkfu freezes the complete tool action context and hashes it. At redemption it recomputes that hash and compares it in constant time. Any mismatch refuses the tool action. The hash covers every input to the decision, so all of these are caught by the same comparison:
An approval is also single-use and time-bounded. Approving a ToolInvocation derives one deterministic proof token; redeeming it moves the ActionApproval from
approved to consumed. The item resource never reveals that proof, though an exact response retry can return the same token again and a keyed retry can replay its encrypted HTTP receipt. Expiry is checked before consumption, so a stale approval cannot be banked and spent later. A Custom-tool approve has no bearer proof to redeem: the Session consumes the decision by advancing its exact wait, and duplicate responses replay the same proof-free result.
Single-use is about this ActionApproval. If you want the next identical tool action to skip the human too, that is a standing approval — a separate, listable, revocable artifact, not a longer life for this one.
Status values are pending, approved, denied, consumed, and expired. For a ToolInvocation, approved and consumed are distinct: the first means a human said yes, the second means the proof was redeemed. A Custom-tool ActionApproval has no proof to consume; run.action_authorized in the Session log is the durable fact that its approved decision advanced the wait.
Responding
The ActionApproval resource route below remains the optimistic-concurrency surface for an ActionApproval queue and for ToolInvocation ActionApprovals. To mirror CMA when answering a tool confirmation from a Session stream, useuser.tool_confirmation
instead; for a child, include its cross-posted session_thread_id. Both paths
retain Checkfu’s responder authorization and immutable frozen context.
A child-thread ActionApproval is intentionally refused on the ActionApproval resource
response route. Answer it through the primary Session event endpoint so the
public thread generation and its native wait remain under one Session mutation
authority.
decision is approve or deny. An approve may also carry the optional standing object described under Standing approvals; a deny may not. expected_version protects the decision from a concurrent response. An exact retry by the same responder with the same request can re-derive the committed result while the ActionApproval’s aggregate state still permits that replay. The optional but recommended stable Idempotency-Key adds a separate encrypted HTTP receipt: the same API key, Workspace, route, key, and request can replay that captured response for up to 24 hours. Receipt replay does not extend proof expiry or make a consumed proof usable again. Optional instructions are carried through to the harness and recorded in action_approval.resolved, so a reviewer can attach a condition rather than only a verdict.
A CapabilityGateway approval returns a proof token. A Custom-tool approval and every denial return no proof.
Standing approvals
Answering the same tool action every hour is how a review queue stops being read. Anapprove response may therefore carry an optional standing object, and accepting it mints a StandingApproval (D194): a bounded, listable, revocable artifact that silences future approvals for one exact tuple.
ttl_seconds runs from 60 seconds to 30 days. scope is one of two, and the narrower one is the default you should reach for:
The artifact binds the same conjunctive subject pair governance already checks — acting Principal and AgentInstallation — plus the SurfaceScope the ActionApproval was raised on, the connection/tool tuple, and the
connection_version. That last one matters operationally: a reauthorization that bumps connection_version kills every standing approval bound to it, structurally, the same way it refuses a frozen ActionApproval.
What it can and cannot do
The precedence rule is the whole design, and it is worth stating as a limit rather than a feature. A StandingApproval may only downgraderequire_approval to allow, for its exact tuple. It:
- never overrides
deny— a denial returns before the silencer is consulted; - never widens a PermissionAssignment — a missing or out-of-window PermissionAssignment denies before policies are even read;
- never survives Connection rotation;
- and never applies when argument conditions failed closed. A restrictive rule that fired because arguments were uninspectable (
indeterminate) is un-silenceable under either scope. Envelope presence is not evidence of determinacy.
exact_arguments is preferred over tool_on_connection; ties inside one scope break on the smallest id.
Living with them
Mint, each use, expiry, and revocation are all audited, and every silenced approval names the exact artifact that silenced it — so “why did this not stop for a human?” always has a recorded answer.GET /v1/standing-approvalslists them newest-first, each with its originatingaction_approval_id, bound subject and connection tuple,connection_version,scope,expires_at,minted_by, andstatus(active,revoked,expired). A window that has elapsed reads asexpired.GET /v1/standing-approvals/{id}reads one. Argument values are never exposed — anexact_argumentsartifact carries only thearguments_hashit silences.POST /v1/standing-approvals/{id}/revocationrevokes one. Revocation is immediate and audited, and the next tool action for that tuple parks for a human again. It is idempotent in the strict sense: revoking an already-revoked or expired artifact is refused as a conflict, and anIdempotency-Keylets an exact retry replay the response. The originating ActionApproval and any tool actions already silenced are unaffected — their audit trail stands.
Who may respond
By default aroot or admin key answers, and the audit record names that key. That path is unchanged.
To let the person who owns the place answer instead — the channel owner approving that channel’s actions — send the optional acted_as with the responding Principal. Two things must then hold. Your key must be root, admin, or developer: an execution-plane runner credential and a read-only viewer key can never assert a responder, because a runtime must not approve the tool action it is running. And the asserted Principal must itself hold an approve PermissionAssignment on the resource the ActionApproval’s subject maps to — the surface or the connection for a ToolInvocation ActionApproval, the declaring agent-definition or its surface for a Custom-tool ActionApproval. An Automation-step ActionApproval maps to no PermissionAssignment resource and stays role-only.
A deny ActionPolicy rule covering any of those resources refuses the response outright, whether or not the Principal holds a PermissionAssignment there, so a denial cannot be routed around via the other resource. PermissionAssignment time bounds apply: an expired or not-yet-active approve PermissionAssignment does not answer. acted_as is never silently ignored — if it is present and that path does not hold, the whole response is refused.
The audit record then names the asserted Principal as the responder and the asserting key as authenticated_by, so a response is always traceable to the credential an incident response would revoke.
A Question is not an ActionApproval
A parked Run is not always waiting on authority. When an agent needs a human to choose between options, it raises a Question, which is ordinary input and grants nothing. The Run parks the same way, throughrun.requires_action, but the action is kind: "question" and names a qst_… Question with one to four uniquely identified items rather than an ActionApproval. There is no frozen context, no proof token, and no reviewer permission: answering is authoring input, not granting authority.
Only a normalized structured observation from the harness creates one. Prose that merely reads like a question stays an ordinary agent.message, so a Session cannot be trapped by punctuation. You answer with user.question_answer, covering every item exactly once and honoring each item’s allow_multiple. That completes the yielded Run and creates a fresh continuation Run; user.interrupt instead cancels it and returns the Session to idle, so an unanswered Question cannot strand the Session either way. See Answer an agent question.
Building a review queue
GET /v1/action-approvals lists them with cursor pagination; GET /v1/action-approvals/{id} fetches one. For a live queue, drive off the event log rather than polling: action_approval.pending tells you a decision is needed and carries the frozen context, and action_approval.resolved tells you it was answered, including when someone else answered it first.
Next steps
Handle an approval
Detect the wait, present the decision, and resume the Run.
Events
The wait-state events that drive this flow.