# Approval-required actions

Pause an agent at the actions you choose, show a person exactly one decision, answer once, and follow the same run to completion.

An agent does not have to guess at a consequential action or missing context. It stops at a
durable **interaction**, your application brings a person in, and the same run continues after
the answer. This guide builds that application-side flow against the Platform API: permission
decisions and questions, disconnects, competing reviewers, expiry, and a runtime that suspends
while it waits.

- Declare the moments in `agent.yaml`: `approval_required_tools` for permission decisions, `enable_ask_user` for questions.
- When the run pauses, the session reports `awaiting_input: true`; list its pending interactions and show the person exactly one decision.
- Answer by the stable `interaction_key`. A decision is accepted once; a second answer sees the recorded one.
- Interactions are durable: they survive closed tabs, restarts, and a suspended runtime, and they expire on a deadline you can read.

> **Availability:** Durable approvals and questions are on in hosted Tanomis and operator-enabled when self-hosted. The examples below are the supported wire contract. See [What is available where](/reference/availability).

## Declare the moments

The deployed version opts into the interactions it can emit:

```yaml title="agent.yaml"
policy:
  approval_required_tools:
    - Bash
  enable_ask_user: true
```

`approval_required_tools` makes each listed harness tool wait for a `permission` decision;
`enable_ask_user` lets the harness ask a `question` and wait for a string answer. These are runtime
interactions, separate from `approval_thresholds` in the held scoped-token contract.

Use an explicit session and run so your application holds stable identifiers before an
interaction appears; see [Sessions and runs](/concepts/sessions-and-runs). The API enforces tenant
access but does not assign an interaction to one approver: enforce your own reviewer roles before
exposing Approve or Deny.

## Two commits to watch

The run remains `running` while it waits. `answered` means the control plane durably accepted one
answer; `delivered` means it handed that answer to the agent service or its durable replay journal.
Only the run's later terminal status tells you how the work ended.
Content-identical waits can share this review unit; the diagram shows the ordinary single-run path.

1. **Person → control plane.** The first valid answer moves the row from `pending` to `answered`
   and returns `202 Accepted`.
2. **Control plane → agent.** Tanomis restores a suspended session if needed, delivers the answer
   idempotently by `interaction_key`, and moves the row to `delivered` after a successful handoff.

A `202 Accepted` response moves the durable row to `answered`; it does not collapse these two
commits or prove that the agent received the answer. Neither commit approves the final action: a
delivered `approve` lets the agent continue toward a tool call, and the run's terminal status says
whether the work succeeded.

## 1. Discover the pending interaction

Keep the run's event stream open, and independently list durable pending rows. The row is the
recovery source after a disconnect or reload:

```bash
curl --silent --show-error --fail-with-body \
  "$TANOMIS_API/v1/sessions/$SESSION_ID/interactions?status=pending" \
  --header "Authorization: Bearer $TANOMIS_TOKEN" \
  --header "X-Organization-ID: $TANOMIS_ORG" \
  --header "X-Workspace-Tenant-ID: $TANOMIS_WORKSPACE"
```

```json title="A permission row"
{
  "interaction_key": "ihk_4f72",
  "run_id": "run_a12bc3",
  "kind": "permission",
  "status": "pending",
  "payload": {
    "tool_name": "Bash",
    "tool_input": {
      "command": "kubectl rollout restart deployment/api -n production"
    }
  },
  "created_at": "2026-09-04T08:31:12Z",
  "expires_at": "2026-09-04T09:31:12Z"
}
```

```json title="A question payload"
{
  "question": "Which region should receive the canary?",
  "header": "Choose a region",
  "options": [
    { "label": "eu", "description": "EU production" },
    { "label": "us", "description": "US production" }
  ],
  "multi_select": false
}
```

`interaction_key` is an opaque, session-scoped review address. Store it with the session and run
IDs exactly as returned and URL-encode it as one path segment; never derive it, use an event's
request ID, or reuse it in another session. The key identifies interaction **content**, so
content-identical asks in one session can share a row and one answer settles every matching ask
parked at that moment. A row therefore proves one **review unit**, not one tool execution. If policy needs a separate
decision for every repeated identical action, give each its own session and run.

## 2. Validate before showing a decision

| Check | Safe behaviour |
|---|---|
| Session and tenant | The session URL and selected tenant are the ones you intended. |
| `run_id` | Matches the run whose context you are showing. |
| `interaction_key` | The exact opaque key from the row. |
| `kind` | `permission` or `question`; reject anything else. |
| `status` | Still `pending`; a terminal row is not actionable. |
| `expires_at` | Still in the future; use the row's value, not an assumed TTL. |
| `payload` | Permission: show the tool and input. Question: show the prompt and options. |

An oversized payload is abridged to `tool_name` plus `"payload_omitted": true`. Do not turn missing
context into a blanket approval; recover context through your application or deny. Escape payload
text when rendering it: it is agent-produced review material, not trusted HTML.

## 3. Submit exactly one kind-specific answer

```text
POST /v1/sessions/{session_id}/interactions/{interaction_key}/answer
```

```json title="Permission"
{
  "kind": "permission",
  "answer": {
    "decision": "approve",
    "reason": "Reviewed change request CR-1842."
  }
}
```

```json title="Question"
{
  "kind": "question",
  "answer": "eu"
}
```

To refuse a permission, send `"decision": "deny"` with a useful reason. For a question, `answer` is the string
itself—not an object containing a second `answer` field. A multi-select answer is the chosen labels
as one comma-separated string. Send
`kind` so a mismatch fails before the answer is stored, and keep the encoded answer at or below
65,536 bytes. The API stores `answer` as opaque JSON and validates the row's `kind`, not the inner
shape, so validate that shape in your backend. The first `pending → answered` transition wins;
disable the controls after submitting, but expect another tab or reviewer to win the race.

## 4. Reconcile acceptance and ambiguity

On `202 Accepted`, show **Answer accepted**, not **Action approved**. Then list the session's
interactions and follow the same key until it settles. Use the same read-after-write when the POST
times out; never send a different answer because the client missed the response.

```bash
curl --silent --show-error --fail-with-body \
  "$TANOMIS_API/v1/sessions/$SESSION_ID/interactions" \
  --header "Authorization: Bearer $TANOMIS_TOKEN" \
  --header "X-Organization-ID: $TANOMIS_ORG" \
  --header "X-Workspace-Tenant-ID: $TANOMIS_WORKSPACE"
```

| POST or row result | Meaning | Next action |
|---|---|---|
| `202`, row `answered` | One answer is durably accepted; delivery is pending. | Poll with backoff and keep observing the same run. |
| row `delivered` | Tanomis handed the answer to the agent runtime or its replay journal. | Continue to the run's terminal status. |
| `409 interaction_already_answered` | Another caller won the first-answer race. | Read the row and use the recorded answer. |
| `409 interaction_terminal` | The row is `expired` or `cancelled`. | Retire the UI action and read the session and run. |
| `404` | The key or session is absent in the selected tenant; cross-tenant existence is hidden. | Recheck tenant context and stored identifiers. |
| `400` | The filter or top-level kind is invalid, or the answer is absent, malformed, or oversized. | Correct the request. |

The row records the answer and timestamps, but it does not expose an approver identity. If your audit needs the person
and business reason, authenticate them in your application and keep that record beside the
session, run, and interaction keys.

## 5. Let Tanomis attempt to restore the waiting runtime

Do not call `POST /resume` for an interaction wait. If Tanomis suspended the runtime while the
person was thinking, the delivery worker makes a bounded number of restore attempts; after that,
the row stays retryable and delivery waits until another supported request makes the session live.
`answered` can therefore persist; only `delivered` proves the handoff.

Keep the original event stream, reconnecting with its stored `Last-Event-ID` and bounded backoff,
and read the original run until it is `succeeded`, `failed`, or `cancelled`. A pending interaction
adds no run status: the run stays `running`, and a session read may show `awaiting_input: true`.
Do not start a replacement run. If a row stays `answered` beyond your threshold, keep the session,
run, and interaction keys and contact your platform operator.

Your completion record should pair the interaction key, kind, accepted answer, and
`answered_at`/`delivered_at`; the session and run IDs; the run's terminal status; and the terminal
event or application outcome you rely on.

## 6. Close expired and cancelled requests safely

After `expires_at` passes, retire the controls and refresh the row. Expiry and answer race safely:
whichever claims the pending row first stands. An `expired` row is no longer answerable; Tanomis
winds the waiting agent down with a synthetic denial or an empty answer, and the row stays
`expired`. Never present that synthetic value as a human decision.

A row becomes `cancelled` when its run or session ends before it is consumed, including an accepted
row whose session became terminal while delivery was queued: the human answer was stored but the agent did not receive it. For either terminal state, disable the controls, preserve the record, and read
the session and run before offering a new operation. A new operation is a new run; a fresh decision
on content-identical work belongs in a new session.

## Application checklist

- Create an explicit session and run; store both IDs before processing events.
- List `pending` rows independently of the event stream; deduplicate by session plus
  `interaction_key`.
- Validate tenant, run, kind, status, expiry, and payload before rendering the decision.
- Enforce your approver roles and escape agent-produced content.
- Send the exact kind-specific answer once.
- Treat `202` as acceptance; reconcile the row through `delivered`, `expired`, or `cancelled`.
- On timeout or `409`, read the winning row; never replace its answer.
- Let Tanomis restore a suspended wait; require `delivered`, then follow the same run to
  an authoritative terminal state.

## Where to go next

- [Human interactions in the Platform API](/api/interactions/list-interactions): the exact fields.
- [Answer approvals in the Console](/guides/run/answer-approvals-in-the-console): the same
  decision without writing code.
- [Sessions and runs](/concepts/sessions-and-runs#handle-a-run-waiting-for-a-person): the
  surrounding lifecycle.
