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

# Handle an approval

> Detect that a Run is waiting on a human, present the decision, and resume.

When governance requires review, the Run parks instead of failing. Your product has to notice, show someone the decision, and answer. This guide implements that loop.

## The loop

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}}
flowchart LR
  A[run.requires_action] --> B{action.kind}
  B -->|approval| C[reviewer decides]
  B -->|custom_tool| D[no ActionApproval: execute and post result]
  C --> E[POST /action-approvals/id/responses]
  E --> F{ActionApproval context}
  F -->|CapabilityGateway or deny| G[action_approval.resolved then run.resumed]
  F -->|approved custom_tool| H[run.action_authorized]
  H --> I[execute recorded agent.tool_use]
  I --> J[post user.custom_tool_result]
```

`CHECKFU_API_KEY` and `CHECKFU_WORKSPACE_ID` come from [Get access](/reference/access#the-three-variables-ready), and `APPROVAL_ID` is the `approval_…` the wait event carried. The TypeScript snippets below use the [TypeScript SDK](/reference/typescript-sdk) with this client:

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

const checkfu = new Checkfu({
  apiKey: process.env.CHECKFU_API_KEY,
})
```

<Steps>
  <Step title="Detect the wait" titleSize="h2">
    Watch the Session stream for `run.requires_action`. Key off the action payload, not the Session status. `session.status_waiting` tells you the Session is parked, but only the action tells you what it is parked on.

    ```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
    for await (const event of stream) {
      if (event.type !== "run.requires_action") continue

      const action = event.payload.action
      switch (action.kind) {
        case "action_approval":
          await queueForReview({
            approvalId: action.approval,
            toolUseId: action.tool_use_id,
            customToolAfterActionApproval:
              action.on_approve?.kind === "custom_tool",
          })
          break
        case "custom_tool":
          // This tool needs a result, but governance did not require review.
          await answerCustomTool(action.tool_use_id)
          break
        case "question":
          // Ordinary input, not authority. It also needs a human, and the
          // Session parks indefinitely until one posts user.question_answer.
          await answerQuestion(action.question, action.items)
          break
        // outcome_evaluation resumes on its own.
      }
    }
    ```
  </Step>

  <Step title="Show the reviewer what was frozen" titleSize="h2">
    `action_approval.pending` carries the frozen context, and `GET /v1/action-approvals/{id}` returns the full record including `context_summary`.

    Branch on that context before rendering it:

    * A **ToolInvocation ActionApproval** names the Connection, tool and source versions, outbound scheme, host, port, method and path, Session, Run, and acting Principal. It carries no query string, headers, body, or argument values; arguments appear only as `arguments_hash`.
    * A **Custom-tool ActionApproval** has `context_summary.kind === "custom_tool"`. It names the admitted agent definition and version, nullable installation and surface, `tool_use_id`, and logical tool name. It has no Connection, ToolSource, or outbound request. Read the exact arguments from the preceding `agent.tool_use` event when building the review; execute them only after authority is granted.

    Do not force both variants into one review-card shape. The [ActionApprovals concept](/concepts/action-approvals#what-a-reviewer-sees) lists the complete frozen contract for each.
  </Step>

  <Step title="Respond" titleSize="h2">
    <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>

    A CapabilityGateway approve response carries one deterministic, single-use proof token. A Custom-tool approval and every denial return no proof. `Idempotency-Key` is optional, but using a stable value preserves an encrypted copy of the exact HTTP response for retries.

    <Warning>
      `GET /action-approvals/{id}` never reveals a CapabilityGateway proof. Preserve a CapabilityGateway approve response. If delivery is lost, retry the exact same decision and reuse the `Idempotency-Key` when supplied. The keyed receipt can replay the captured response for up to 24 hours, but it does not extend proof expiry or make a consumed proof usable. This warning does not turn a proof-free Custom-tool response into a bearer credential.
    </Warning>
  </Step>

  <Step title="Continue the correct branch" titleSize="h2">
    For a CapabilityGateway decision or a Custom-tool denial, `action_approval.resolved` lands in the log, followed by `run.resumed`; the normal continuation tells the harness whether the tool action was approved or refused. Do not treat a denial as a Session failure.

    An approved Custom-tool action is different. Wait for `run.action_authorized`, correlate its `tool_use_id` to the exact preceding `agent.tool_use`, execute that recorded tool action in your application, and post its result:

    ```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
    await checkfu.sessions.events.send(
      sessionId,
      {
        type: "user.custom_tool_result",
        payload: {
          tool_use_id: toolUseId,
          result,
          authored_by: reviewerPrincipalId,
          caused_by: { kind: "action_approval", approval: approvalId },
        },
      },
      { idempotencyKey: `tool-result-${toolUseId}` },
    )
    ```

    ActionApproval alone does not start a Run, dispatch, lease, or Sandbox for this branch. The same compute-closed Run stays parked after `run.action_authorized`; the matching `user.custom_tool_result` is what closes that wait and admits the ordinary continuation Run.
  </Step>
</Steps>

## Handling the awkward cases

| Situation                                                  | What you see                                  | What to do                                                                |
| ---------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------- |
| Two reviewers open the same item                           | The second `POST` fails on `expected_version` | Re-read the ActionApproval; if it is already resolved, show the outcome   |
| The reviewer took too long                                 | Status `expired`                              | Nothing is redeemable. The Run must re-request the tool action            |
| A CapabilityGateway tool source re-synced mid-review       | Redemption refused on context mismatch        | Re-request; the reviewed tool action no longer matches what would execute |
| A CapabilityGateway Connection was reauthorized mid-review | Redemption refused on context mismatch        | Same: re-request                                                          |

The last two are the freeze doing its job. An approval is a decision about one exact tool action, and any change to that tool action invalidates it rather than approximating consent.

## Building the queue

For a live queue, drive off the event log rather than polling `GET /v1/action-approvals`. `action_approval.pending` tells you an item appeared; `action_approval.resolved` tells you it was answered, including when someone else answered it first. That is what keeps two open review tabs from fighting.

Use the list endpoint for backfill and reconciliation, not as the primary signal.

## Next steps

<CardGroup cols={2}>
  <Card title="ActionApprovals" icon="user-check" href="/concepts/action-approvals">
    The freeze, the proof token, and the governance checks that produce a review.
  </Card>

  <Card title="Resume a stream" icon="arrows-rotate" href="/guides/resume-a-stream">
    Make sure you never miss the wait event in the first place.
  </Card>
</CardGroup>
