> ## Documentation Index
> Fetch the complete documentation index at: https://checkfu.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# ActionApprovals

> Put a human in the loop on a tool action without letting the decision drift from what was reviewed.

An **ActionApproval** is a human decision about one specific tool action. Checkfu creates one automatically when governance says a CapabilityGateway or customer-executed Custom tool needs review. You never request an approval directly.

## When a tool action needs approval

Every tool action passes four governance checks: whether the acting Principal and the agent installation may each `use_connection` on the Connection, and whether each may `use_tool` on the tool source.

| Outcome                                            | Result                                                      |
| -------------------------------------------------- | ----------------------------------------------------------- |
| Any check denies                                   | The tool action is denied                                   |
| Any check requires approval                        | The tool action requires approval                           |
| All four allow, and the tool action is a safe read | Allowed automatically                                       |
| All four allow, but the tool action mutates        | Requires approval unless an explicit ActionPolicy allows it |

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

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}}
%%{init: {"theme":"neutral","themeVariables":{"fontFamily":"ui-sans-serif, system-ui, sans-serif","primaryColor":"#F5F5F4","primaryBorderColor":"#A8A29E","primaryTextColor":"#282828","lineColor":"#78716C","secondaryColor":"#FAFAF9","tertiaryColor":"#FFFFFF"}}}%%
sequenceDiagram
    participant H as Harness
    participant G as Checkfu CapabilityGateway
    participant R as Reviewer
    H->>G: tool action
    G->>G: freeze the full tool action context
    G-->>H: requires_approval
    Note over G: run.requires_action + action_approval.pending
    R->>G: POST /action-approvals/{id}/responses
    Note over G: action_approval.resolved
    G-->>H: run.resumed, tool action proceeds or is refused
```

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 a `context_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 the `agent.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:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { canonicalArgumentsHash } from "@checkfu/sdk"

const proven = (await canonicalArgumentsHash(draftArguments)) === approval.arguments_hash
```

Exactly:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
arguments_hash = "sha256:" + lowercase_hex(SHA-256(utf8(canonicalJson(arguments))))
```

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

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

<Note>
  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](/reference/events#answering-a-parked-run).
</Note>

## 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:

| If this changed after approval                                 | Result                 |
| -------------------------------------------------------------- | ---------------------- |
| Any argument                                                   | Hash mismatch: refused |
| The tool's schema (a source re-sync bumped `tool_schema_hash`) | Refused                |
| The Connection (a reauthorization bumped `connection_version`) | Refused                |
| The acting Principal or agent version                          | Refused                |

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](#standing-approvals) — 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, use `user.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.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  curl --request POST \
    "https://api.checkfu.com/v1/action-approvals/$APPROVAL_ID/responses" \
    --header "Authorization: Bearer $CHECKFU_API_KEY" \
    --header "Checkfu-Version: 2026-08-27" \
    --header "Idempotency-Key: approval-$APPROVAL_ID" \
    --header "Content-Type: application/json" \
    --data '{
      "expected_version": 1,
      "decision": "approve",
      "instructions": "Approved for this claim only."
    }'
  ```

  ```ts TypeScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const resolution = await checkfu.actionApprovals.decide(
    approvalId,
    {
      expected_version: 1,
      decision: "approve",
      instructions: "Approved for this claim only.",
    },
    { idempotencyKey: `approval-${approvalId}` },
  )
  ```
</CodeGroup>

`decision` is `approve` or `deny`. An `approve` may also carry the optional `standing` object described under [Standing approvals](#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. An `approve` 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.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "expected_version": 1,
  "decision": "approve",
  "standing": { "ttl_seconds": 3600, "scope": "exact_arguments" }
}
```

`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:

| Scope                | Silences                                                                                      | Reach for it when                                                 |
| -------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `exact_arguments`    | the same `arguments_hash` only                                                                | the tool action repeats byte-identically — a poll, a fixed query  |
| `tool_on_connection` | that tool on that Connection, any arguments whose governing conditions evaluate determinately | the tool is safe in the shape you reviewed, not just the instance |

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** downgrade `require_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.

When several artifacts could cover a tool action, `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-approvals` lists them newest-first, each with its originating `action_approval_id`, bound subject and connection tuple, `connection_version`, `scope`, `expires_at`, `minted_by`, and `status` (`active`, `revoked`, `expired`). A window that has elapsed reads as `expired`.
* `GET /v1/standing-approvals/{id}` reads one. Argument values are never exposed — an `exact_arguments` artifact carries only the `arguments_hash` it silences.
* `POST /v1/standing-approvals/{id}/revocation` revokes 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 an `Idempotency-Key` lets 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 a `root` 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.

<Warning>
  `GET /action-approvals/{id}` never returns a CapabilityGateway proof. Preserve the CapabilityGateway approve response. If delivery is lost, retry the exact same decision; reuse the `Idempotency-Key` when you supplied one. A replayed CapabilityGateway response may contain the original token, but redemption still requires an unexpired, unconsumed ActionApproval. After CapabilityGateway expiry or consumption, the tool action must be re-requested. A proof-free Custom-tool response receipt can still replay its captured result for the receipt's scoped 24-hour lifetime.
</Warning>

## 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, through `run.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](/reference/events#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

<CardGroup cols={2}>
  <Card title="Handle an approval" icon="user-check" href="/guides/handle-an-approval">
    Detect the wait, present the decision, and resume the Run.
  </Card>

  <Card title="Events" icon="list-timeline" href="/reference/events">
    The wait-state events that drive this flow.
  </Card>
</CardGroup>
