---
title: Approvals
description: Put a human in front of a tool call, and settle it from your own UI.
sidebar:
  order: 3
---

Some tool calls should not happen because a model decided they should — issuing a refund, sending an email, deleting a record. For those, the run pauses and asks.

An approval is a pair of events on the session log, so it works the same whether the person deciding is watching a live stream or opening the session an hour later.

## The two events

When a gated call comes up, the run appends `approval_requested` and waits:

| Field | What it holds |
| --- | --- |
| `call_id` | The call being decided |
| `server` | Which server the tool belongs to |
| `tool` | The tool name |
| `arguments` | What the model wants to call it with |
| `args_hash` | The identity a re-fire is checked against |

`arguments` is carried on the request event on purpose: whoever decides can see what they are deciding without reading back through the log to find the `tool_call` it follows.

Settling appends `approval_resolved`, carrying the `decision` (`allow` or `deny`), an optional `deny_message` explaining the refusal to the model, and `resolved_by`.

## Building the UI

1. **Watch for the request**

    A client already tailing the session stream sees `approval_requested`
    like any other event. Render it from the event's own fields.

2. **Show what is actually being asked**

    Render `arguments` verbatim. The whole value of an approval gate is that
    a person sees the real call, not a summary of it.

3. **Settle it**

    Post the decision to the `call_id` the request named.

    ```http
    POST /v1/sessions/{session_id}/approvals/{call_id}
    ```

    ```json
    { "decision": "deny", "message": "Refunds over $500 need a manager." }
    ```

    On `allow`, the call fires and its `tool_result` follows. On `deny`, the
    model receives the denial — with your message if you set one — and the
    turn continues from there.

`message` belongs to `deny` alone. Sending one alongside `{"decision": "allow"}` is rejected rather than ignored, so an allow cannot quietly carry steering text the model never sees.

Decide promptly. The broker holding the call reads your decision on its next tick and answers the engine in line; past that hold, the run requeues and re-fires the call instead — which is what `args_hash` exists to check.

## Denial is not failure

A denied call is a normal outcome, not an error. The model gets a tool result saying so and carries on, which is why `deny_message` is worth writing: "not approved" leaves the model guessing, while "refunds over $500 need a manager" tells it what to do next.

:::note
Because the decision lands on the log, a session's audit trail is complete without a separate log to correlate: who resolved what, with which arguments, in the same ordered stream as the work itself.
:::
