---
title: Approvals and forms
description: Approve tool calls and answer forms from your own UI.
---

Some decisions are not the model's to make: issuing a refund, sending an email, choosing between two plans the customer has to pay for. Subako gives an agent two ways to wait for a person, and both are a pair of events on the session's log — so they work the same whether the person is watching live or opens the session an hour later.

| | Approval | Form |
| --- | --- | --- |
| Asked by | A tool whose policy is **Ask first** | The agent itself, when its version lets it **ask people** |
| The person decides | Whether this call runs | The answers to up to four questions |
| Events | `approval_requested` → `approval_resolved` | `input_requested` → `input_resolved` |
| Settled at | `POST /v1/sessions/{id}/approvals/{call_id}` | `POST /v1/sessions/{id}/inputs/{call_id}` |
| Nobody answers in 24 hours | Denied, with "approval timed out" | Expired; the model is told nobody answered |

Both routes take the session token or a key with **Sessions** › **Manage**. The [console](/console/sessions#approving-a-tool-call) settles them the same way.

## Approvals

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

| Field | |
| --- | --- |
| `call_id` | The call being decided |
| `server` | The grant it belongs to, such as `mcp:orders`, `fn:billing`, or `sandbox:build` |
| `tool` | The tool's name |
| `arguments` | What the model wants to call it with |
| `args_hash` | What a call fired again is checked against |

Show the person `arguments` as they are: the point of an approval is that a person sees the real call. Then settle it:

```bash
curl -X POST https://api.us.cloud.subako.ai/v1/sessions/$SESSION_ID/approvals/$CALL_ID \
  -H "Authorization: Bearer $SUBAKO_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"decision": "deny", "message": "Refunds over $500 need a manager."}'
```

On `allow` the call runs and its `tool_result` follows. On `deny` the model is told — with your `message` when you give one — and the run continues. A `message` belongs to `deny` alone; sending one with `allow` is refused, so an approval cannot carry text the model never sees.

The answer is the `approval_resolved` event. A call with nothing pending answers 404, and one already settled 409.

A denial is a normal outcome, not an error, which is why the message is worth writing: "not approved" leaves the model guessing; "refunds over $500 need a manager" tells it what to do next.

## Forms

An agent version that may **ask people** (`builtin.ask_user` in the [agent config](/reference/agent-config#builtin)) gives the model a tool to put a form. The run appends `input_requested`:

```json
{
  "type": "input_requested",
  "call_id": "call_8f2a",
  "request": {
    "questions": [
      {
        "id": "day",
        "prompt": "Which day works for you?",
        "required": true,
        "widget": { "type": "select", "data": { "options": [{ "label": "Tue" }, { "label": "Wed" }], "multiple": false, "allow_other": false } }
      },
      { "id": "seating", "prompt": "Any seating preference?", "required": false, "widget": { "type": "text", "data": { "multiline": false } } }
    ]
  }
}
```

| Widget | Draws | Its answer |
| --- | --- | --- |
| `select` | Options to pick, one or (`multiple`) several, maybe with a free-text **Other** (`allow_other`) | An array of the chosen labels |
| `text` | A text field, one line or `multiline` | A string |
| `confirm` | Yes or no | A boolean |
| `custom` | A widget of your own app's, by its `id`, with the `props` the agent filled in | Whatever its `answer` schema says |

Answer every question once:

```bash
curl -X POST https://api.us.cloud.subako.ai/v1/sessions/$SESSION_ID/inputs/$CALL_ID \
  -H "Authorization: Bearer $SUBAKO_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "submitted",
    "data": {
      "answers": [
        { "question_id": "day", "outcome": { "type": "answered", "data": { "value": ["Tue"] } } },
        { "question_id": "seating", "outcome": { "type": "skipped" } }
      ]
    }
  }'
```

- `answered` carries the value. `skipped` is for a question that is not required.
- `unsupported` says your client cannot draw that widget — a custom one it does not know, say. It is always allowed, and the model is told to ask another way.
- `{"type": "declined", "data": {"message": "Not now."}}` dismisses the whole form; the model reads that the person chose not to answer, with your message.

The answer is the `input_resolved` event. An answer that does not fit a question is refused with the reason, and the form stays open.

![The console showing a form above the composer](/blume-assets/content/docs/_images/en/session-form.webp)

### Custom widgets

A custom widget is declared in the agent version, with the schemas of what the agent fills in and what an answer holds:

```json
{
  "builtin": {
    "ask_user": {
      "widgets": [
        { "type": "select" },
        { "type": "confirm" },
        {
          "type": "custom",
          "id": "seat_picker",
          "description": "Pick seats on the venue map.",
          "props": { "type": "object", "properties": { "venue": { "type": "string" } } },
          "answer": { "type": "array", "items": { "type": "string" } }
        }
      ]
    }
  }
}
```

The model reads `description` to choose the widget, and fills in `props`; your app draws it and answers with a value that matches `answer`.
