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

# Handle an approval

> Detect a parked action, distinguish its authority path, respond idempotently, and observe the governed continuation.

When ActionPolicy returns `require_approval`, Checkfu parks the Run and creates an [ActionApproval](/concepts/action-approvals). Your application notices the wait, shows an authorized reviewer the exact frozen action, and answers it.

Connected ToolInvocations and Custom tools share the human-decision resource but not the same surrounding authorization or execution path.

The examples use the TypeScript SDK:

<Warning>
  Plain HTTP against the Checkfu API needs no private package.
  The Checkfu CLI and TypeScript SDK are optional private-alpha artifacts; the HTTP requests work without them.
  Part of this flow is behind a rollout fence; a deployment that has not enabled it refuses the request rather than running it.
  Confirm [capability status](/getting-started/status) before making availability part of your application's contract.
</Warning>

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

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

<Steps>
  <Step title="Detect the wait" titleSize="h2">
    Drive a live review queue from the Session event stream. `session.status_waiting` says the Session is parked; `run.requires_action` says why.

    ```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,
            customToolAfterApproval:
              action.on_approve?.kind === "custom_tool",
          })
          break
        case "custom_tool":
          // ActionPolicy already allowed this Custom tool.
          await queueAuthorizedCustomTool(action.tool_use_id)
          break
        case "question":
          // Ordinary input, not authority.
          await queueQuestion(action.question, action.items)
          break
      }
    }
    ```

    A direct `custom_tool` wait needs an application result but no human decision. A Question grants no authority and is answered with `user.question_answer`.
  </Step>

  <Step title="Read the frozen decision" titleSize="h2">
    Fetch `GET /v1/action-approvals/{id}` or use the overlapping authority carried by `action_approval.pending`.

    Render the context by kind:

    * A **connected ToolInvocation** can name the acting Principal, Agent version, Session, Run, Connection revision, ToolSource and Tool versions, schema hash, and outbound destination. It follows PermissionAssignment → ActionPolicy → ActionApproval.
    * A **Custom-tool ActionApproval** names the admitted Agent, logical Tool, `tool_use_id`, Session, Run, and optional surface coordinate. It has no Connection, ToolSource, provider request, PermissionAssignment in the Tool invocation path, or child-thread coordinate in `context_summary`.

    Both resource variants carry top-level `arguments_hash`, not argument values. For a Custom tool, read the exact arguments from the preceding `agent.tool_use` event and prove that the displayed payload matches:

    ```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
    const matches =
      (await canonicalArgumentsHash(recordedArguments)) ===
      approval.arguments_hash

    if (!matches) throw new Error("review payload does not match the frozen action")
    ```

    <Warning>
      Do not ask a reviewer to paste a provider token into the decision. Checkfu never returns a custodied Credential value in the ActionApproval resource or Session event. Your application must also keep its own secrets out of arbitrary Tool arguments, results, and reviewer instructions.
    </Warning>
  </Step>

  <Step title="Respond once" titleSize="h2">
    For a primary-thread ActionApproval, send the current `version` as `expected_version`. Retain one idempotency key for this logical response and reuse it only for an exact retry.

    <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-31" \
        --header "Idempotency-Key: approval-$APPROVAL_ID" \
        --header "Content-Type: application/json" \
        --data '{
          "expected_version": 1,
          "decision": "approve",
          "instructions": "Approved for this action only."
        }'
      ```

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

    Two reviewers racing on one item are serialized by `expected_version`; the loser re-reads and shows the durable outcome. A stale response returns `409 validation.conflict`. Re-read the approval instead of silently approving its new version or changed arguments. Responder authorization is checked separately and can return `403 policy.denied`.

    A ToolInvocation ActionApproval is the CapabilityGateway branch. Its CapabilityGateway approve response carries one deterministic, single-use proof token. Managed-tool, Custom-tool, browser, harness-tool, and Automation-step approvals carry no provider-effect proof, and every denial carries none. Idempotent replay does not extend expiry or make a consumed proof usable again.
  </Step>

  <Step title="Continue the correct path" titleSize="h2">
    Branch on the approval context.

    **Connected ToolInvocation through platform MCP:** Resolving ActionApproval records the decision; it does not dispatch the operation or automatically resume or create a Run. Retry the identical original `tools/call`, preserving its original ToolInvocationId and the returned `requestState` where applicable. Checkfu rechecks current Connection, grant, policy, approval, and other required authority before execution. A completed identical retry is refused as `already_executed` rather than dispatched again. A denial is not permission to execute elsewhere.

    **Connected ToolInvocation parked in a native Session:** On a route that supports native approval waits, Checkfu can wake the original parked call after recording the decision. It rechecks the original caller, acting Principal, Connection, Tool, policy and retained Skill inputs. The resumed call still needs fresh execution authorization before using its single-use approval proof. The original account, arguments, frozen context and expiry remain bound. Do not create a replacement call to work around a refusal. A missing, expired or erased original call prevents the wake; a recorded continuation does not prove provider completion or result delivery. Do not assume restart or duplicate recovery is supported on the selected route.

    **Custom tool:** PermissionAssignment is not part of the Tool-call path. After approval, wait for `run.action_authorized`, correlate it to the preceding `agent.tool_use`, execute the handler in your application, and post the exact result. ActionApproval itself does not execute the handler or start a new Run.
  </Step>
</Steps>

## Custom-tool approval continuation and proof custody

When `approval.context_summary.kind === "custom_tool"`, the preceding `agent.tool_use` is the frozen customer-handled call. After approval, `run.action_authorized` records the exact authorization. The same compute-closed Run stays parked until the application executes the handler and posts `user.custom_tool_result`; approval itself does not execute the Tool or start another Run.

Accepting the matching `user.custom_tool_result` continues the original waiting
Run under current authority with its retained context pins. It does not create
a new Run or resolve newer `latest` context. The result records the handler's
output for the original `tool_use_id`; retry with the same result payload and
idempotency key to recover that same accepted event. Follow the original Run's
recorded outcome to determine when its work settles. Input acceptance, an Agent
message, or `session.status_idle` with `stop_reason: "requires_action"` does not
establish completion. That idle event is an intermediate wait. A provider-effect
receipt remains separate from result acceptance and Run settlement.

```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,
      caused_by: { kind: "api", request_id: `tool-result-${toolUseId}` },
    },
  },
  { idempotencyKey: `tool-result-${toolUseId}` },
)
```

Authenticated server context supplies durable actor attribution. `GET /action-approvals/{id}` never reveals a CapabilityGateway proof. Preserve a CapabilityGateway approve response only long enough to present its proof to the immediate trusted effect; do not log it, put it in Session content, reuse it after expiry, or treat it as authority for another action. This proof rule applies only to the ToolInvocation branch; the Custom-tool result carries causal attribution, not a bearer credential.

## Tool confirmations

For CMA-shaped clients, a Tool wait can instead be answered through the Session's event endpoint, not the ActionApproval resource response route. Echo the exact `tool_use_id` from `agent.tool_use`.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
await respondToToolConfirmation(checkfu, {
  sessionId,
  toolUseId,
  result: "allow",
  actor: {
    requestId: `tool-confirmation-${toolUseId}`,
  },
  idempotencyKey: `tool-confirmation-${toolUseId}`,
})
```

Use `result: "deny"` and an optional nonempty `denyMessage` to refuse it. Checkfu still applies the frozen approval and responder-authority checks. An allowed Custom-tool wait then requires the matching `user.custom_tool_result` with the same Tool-use ID.

Authenticated server context supplies durable actor attribution for this event too; the client does not supply `authored_by`.

## Submit a Custom-tool result

Use the SDK interaction helper so the event contains only the currently permitted Tool result, request correlation, and optional scope. Authenticated server context supplies durable actor attribution; client event bytes do not supply `authored_by`.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
await submitCustomToolResult(checkfu, {
  sessionId,
  toolUseId,
  result,
  actor: {
    requestId: `custom-tool-result-${toolUseId}`,
  },
  idempotencyKey: `custom-tool-result-${toolUseId}`,
})
```

Keep application credentials inside the handler runtime and return only the bounded business result the Agent needs.

## Make the application effect safe

`toolUseId` is replay-stable for the exact recorded call, but a local receipt written **after** a remote provider call is not atomic with that provider. A crash after provider acceptance and before the local write can repeat the effect.

Use one of these patterns:

* pass a provider-supported idempotency key derived from `toolUseId`;
* durably reserve an effect intent before provider contact and reconcile its state; or
* use an outbox or effect ledger with pre-effect intent and post-effect settlement.

A timeout or lost response after provider dispatch is indeterminate. Reconcile it with the provider before retrying rather than assuming the effect failed.

## Reconciliation cases

| Situation | What to do |
| - | - |
| The approval is already resolved | Show the durable outcome instead of answering again |
| The approval expired | Let the Agent request a new action if current authority permits it |
| A connected Tool schema or Connection revision changed | Re-request; the old decision describes different provider work |
| A required connected PermissionAssignment or Connection was revoked | Do not work around the denial; restore authority through its owning control-plane flow |
| A Custom-tool policy now denies | Do not execute the customer handler |
| A provider result is indeterminate | Reconcile before retrying |

Use `GET /v1/action-approvals` for queue backfill and periodic reconciliation. Use Session events as the primary live signal so multiple review surfaces converge on the same durable decision.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.