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

# App Tool

> Renders an **interactive UI artifact** in the task feed: a versioned HTML/JS bundle loaded from a CDN inside a sandboxed iframe.

Renders an **interactive UI artifact** in the task feed: a versioned HTML/JS bundle loaded from a CDN inside a sandboxed iframe. Runtime reads/writes go through `/api/app-runtime`, gated by the configured tool's allowlist. Display name **"App Tool"**. Off by default.

## When to use it (vs `custom_interface` vs `python_code_tool` vs `generate_file`)

| Need | Tool |
| - | - |
| Interactive mini-app in the task feed (filters, actions, persisted edits) | `app_tool` |
| Read-only HTML view of prior step data (cards, tables, citations) | `custom_interface` |
| Compute in Python; feed shows **markdown**, not HTML | `python_code_tool` |
| A downloadable static file, including an `.html` document | `generate_file` |

`generate_file` with `format: html` produces a **file**, not a live feed widget. `python_code_tool` cannot "signal HTML" — its contract is markdown. For a task-feed scorecard the user can click, this is the tool.

## Authentication and enablement

Configured tool: each instance has a bundle + data-source binding + action allowlist. The LLM does not pick the bundle. Off by default; add and configure per agent.

## Inputs

* `configured_tool_id` — injected by the worker from context (`x-exclude-from-llm`). Never pass a display name here.
* `params` — bundle-specific payload forwarded as `runtime.params`. Shape comes from the configured tool's `input_schema` override (e.g. `{report_id: 59137}`).
* `key_definitions` — the agent's key-detail definitions, injected by the worker (`x-exclude-from-llm`, `x-variable-type: agent_key_definitions`). Used only when the artifact parks the task; see [Holding the task for a reviewer](#holding-the-task-for-a-reviewer-await_review).
* `summary_instructions` — the agent's summary template, injected by the worker (`x-exclude-from-llm`, `x-variable-type: agent_summary_instructions`). Same use.

## Output

An artifact descriptor (CDN bundle URL, scoped session token, initial data). The frontend mounts the iframe; edits persist via App Runtime and show up as `agent_action` feed events.

## What the mint returns

`POST /api/app-tools/sessions {step_id}` mints the 15-minute session and returns
the **complete live descriptor** of the artifact: `bundle_url`, `bundle_sha`,
`bundle_id`, `binding_id`, `configured_tool_id`, `action_schema`, `theme`,
`default_fullscreen`, `await_review`, the live `await_id` / `await_pending`,
and `runtime` — the `verbs` and envelope `versions` this backend serves, and
the `capabilities` this deployment serves **and** the binding grants, so a
bundle never negotiates onto a capability that would answer `501` or `403`. The host merges it with the feed snapshot in one place, preferring the
live value; a bundle receives the same values on `init` (`schema`, `theme`,
`runtime`) so it can negotiate instead of guessing.

## Display settings

Both live in the configured tool's config (`bindings.app_tool`), are read live on every mount, and default to off:

* `"default_fullscreen": true` — opens the surface full screen on first render. It takes the same automatic path the review form uses, so it yields to an item the reviewer has already pinned, and a reviewer's own expand/collapse always wins afterwards. A task status change can no longer steal a full screen the reviewer chose.
* `"await_review": true` — holds the task on the artifact; see below.

## Holding the task for a reviewer (`await_review`)

By default the step completes as soon as the artifact is built and the agent
continues immediately — the iframe is something the user *may* open, not
something the task waits on.

Set `"await_review": true` in the configured tool's config to make the artifact a
blocking review instead:

* The tool registers a `task_awaits` row (`source_type: "app_tool"`,
  `correlation_key` = the step id) and returns its id as `await_id` on the
  artifact.
* The task moves to `waiting_for_input` and the agent loop stops. Nothing
  advances until the await is released, so the task can sit parked across
  sessions — the reviewer can close the tab and come back.
* The feed auto-opens the artifact for a reviewer while the await is pending.

**Something must release it, or the task parks forever.** Release comes from an
action in the same binding carrying `"resolves_await": true`:

```jsonc theme={null}
{ "name": "approve_invoice",
  "writes": ["save_header", "approve_all_lines"],
  "feed_event": "invoice_approved",
  "resolves_await": true }
```

The flag is per-action, and the action name carries no meaning — so a binding can
offer several actions where only the final one resumes the agent (that is how a
multi-stage approval is built: earlier stages write and record a feed event, and
the task stays parked). The await is completed and its resume event enqueued in
one transaction, and it is located from the verified session claims, so a bundle
can never resolve another task's await.

Opt-in per configured tool: omitting `await_review` leaves the artifact carrying
no `await_id`, which is exactly how every app tool written before this behaves.

### Key details are filled at the pause

An artifact that parks the task also carries the agent's key-detail values, on
`extracted_data`, which the worker persists to `agent_key_values`. Without this a
parked task would carry no values until the run ended — after the point a review
queue or a data-access policy needs them to order and filter the file.

It runs the same summary pass `review_form` runs, on the same terms:

* Only when the artifact actually parked the task (an `await_id` was issued) and
  the agent has key definitions or a summary template. Agents with neither pay no
  extra LLM call.
* Best-effort. A generation failure is logged and the artifact is returned
  intact, with the task still parked — it never turns a working pause into a
  failed step.

## Runtime verbs

Every call carries the artifact's short-lived session JWT and is gated by the
binding's `allowed_operations`. The bundle only ever names an **op**; the
binding decides the table, the URL, the tool.

| Verb | Runs | Returns | Feed rows |
| - | - | - | - |
| `POST /read` | one `reads` op, **or one `http_calls` op** | `{kind:"rows", …}` or `{kind:"http", status, …}` | none |
| `POST /write` | one `writes` op, one row | rows affected | one per call |
| `POST /action` | an action's writes, then its `http_calls` | `{ok, results, responses}` | one per call |
| `POST /run` | one `tools` op (a configured tool, or an admitted tool) | the tool's output | one per call |
| `POST /capability` | a platform feature (files, PDF split, page translation) | `{result}` | none |

Every `/read` reply carries `kind` — `rows` for a database read (`columns`,
`rows`, `row_count`) or `http` for a lookup (`status`, `body` or `text`) — so a
bundle never has to infer the shape from which op it sent.

### Response envelope

The shapes above are what every deployed bundle relies on, and they do not
change. A bundle that wants **one shape on every verb** asks for it per request
with the header `X-Nanonets-App-Runtime: v2`:

| Outcome | v2 reply |
| - | - |
| success | `{ "ok": true, "kind": "rows" \| "http" \| "write" \| "action" \| "run" \| <capability>, "result": … }` |
| failure | `{ "ok": false, "error": { "status": <http status>, "message": "…" } }` |

`result` is exactly the object the verb returns without the header (for
`/action`, `{results, responses}`; for a capability, its own shape). Per
request rather than per binding so a live bundle can migrate one call at a
time. The versions a backend serves are advertised on the mint (`runtime.versions`).

### Status codes

| Status | Meaning | Verbs |
| - | - | - |
| 200 | Done. For `/read` on an `http_calls` op, and for a `/run` whose tool reported an error, the outcome is in the body (`status`, `error`). | all |
| 400 | Malformed request: bad JSON, a `{{key}}` the payload never supplied, bad capability params. | all |
| 401 | Missing or expired session token. | all |
| 403 | The binding does not allow this op, column or integration; a URL value moved the host. | read write action run capability |
| 413 | Body over the cap (256 KiB, every verb). | all |
| 429 | This surface is calling faster than 5/s sustained (burst 20). `Retry-After` says how long to wait. | read (`http_calls`) run |
| 501 | The deployment lacks the backing service (no HTTP gateway, no python executor, no translation engine). | all |
| 503 | The lookup bulkhead is full; retry the single call. | read run |

### Lookups: `http_calls` on `/read`

A lookup against a REST upstream — an account search, a status check — is a
read, so it is served by `/read`. Declare the call once in the binding:

```jsonc theme={null}
{ "http_calls": [{
    "op": "lookup_account",
    "method": "POST",
    "url": "https://crm.example.com/accounts/search",
    "integration_id": "…",
    "auth": { "kind": "client_credentials",
              "token_url": "https://login.example.com/oauth2/token" },
    "body_template": { "name": "{{name}}", "email": "{{email}}" },
    "prune_empty_body_fields": true
} ] }
```

and call it with `{"op": "lookup_account", "payload": {"name": "…"}}`. The
response is `{kind: "http", status, body}` (or `text` for a non-JSON reply) —
the upstream's own status and body, returned with HTTP 200 **even when the
upstream answered 4xx/5xx** — check `status` in the body. Only a pre-flight failure (URL blocked, token
acquisition, transport) is a non-2xx from the runtime itself. A `reads` op of
the same name takes precedence, so a lookup can never shadow a database read.
Lookups record no step and no feed row, so a bundle may fire them as a
reviewer types — within the per-surface rate below. Every request on the
runtime is capped at 256 KiB.

### `http_calls` templates

* `{{key}}` placeholders resolve from the request payload, with `task_id`,
  `agent_id`, `step_id` and `workspace_id` always taken from the session — a
  bundle cannot spoof them.
* Placeholders work in `url` too (`/accounts/{{account_number}}`). Values are
  escaped for their position — path-escaped before the `?` so one value is always one path segment, query-escaped after it so a value cannot append parameters.
* A placeholder whose key is **absent** from the payload is an error (`400`). A
  key that is **present but empty** is allowed in the query string (`?q=` means
  "no filter", which is what a bundle sends for a search box nobody has typed
  in) but rejected in the path, where an empty segment would address a different
  endpoint.
* A placeholder may sit in the **host** (`https://{{tenant}}.example.com/...`)
  for multi-tenant upstreams. The value fills the label the template authored
  and nothing more: anything that would re-delimit the authority — userinfo, a
  port the template does not have, a `/`, `?`, `#` — is refused with a `403`,
  since it would reach a host the binding never allowlisted.
* `"prune_empty_body_fields": true` drops any body key whose value is a
  placeholder that resolved to nothing, at any depth (including inside objects
  nested in arrays; array elements themselves are never removed, since an index
  carries position). Off by default, so existing bindings keep sending empty
  strings.
* `auth.kind` is one of `none`, `bearer`, `oauth2_password`,
  `client_credentials`. The last two read their secrets from the integration's
  `oauth_config` (`username`/`password`, or `client_id`/`client_secret` and an
  optional `scope`) and cache the bearer per integration, token URL and scope.
* An `integration_id` must belong to the session's workspace; anything else
  is refused with 403.

### Actions return the upstream reply

`/action` still returns `results` (rows affected per write, status code per
http op, `await_resolved`), and now also `responses`: each http op's
`{status, body|text}`. For an http op read `responses[op]`; `results[op]` is
kept only so existing bundles keep working and carries nothing `responses`
does not. A non-2xx upstream fails the action — an export that was refused is
not recorded as done.

### Seeding many rows

There is no batch write verb. A surface that needs many rows written before a
reviewer sees it should seed them in its **producer** instead — the python
configured tool named by `app_tool.producer`, which runs server-side while the
artifact is being built. That costs the bundle zero round trips, and the
producer already runs where a step row, retries and billing exist.

`/write` remains one row per call, for the edits a reviewer actually makes.

### Running a configured tool: `/run`

A bundle can also **run a tool** it has been granted. The binding's
`allowed_operations.tools[]` maps an op name to a tool plus the input keys the
bundle may supply; anything not listed is dropped, and the session's
`task_id`/`agent_id`/`step_id`/`workspace_id` always win. `tool` names either a
**configured tool's display name** (resolved within the session's own agent and
workspace; its stored bindings — code, file name, query, integration — are never
taken from the request) or, for a tool with nothing to configure, the **base
tool name** directly.

Which executor runs the tool follows from its base tool, so the binding format
never changes when a new one is added:

| Base tool | Runs on | Notes |
| - | - | - |
| `python_code_tool` | the python executor | Configured tool only. Bounded at 900s. A script that raises returns `200` with `error` set. |
| `csv_lookup_tool` | the worker's tool broker, 60s | Configured tool. Pinned to `matching_strategy: exact`; the LLM strategy is unreachable from a bundle. Supply `match_criteria` and `return_columns`; `file_name` comes from the binding and may not be listed in `params` (403). |
| `postgres_execute_query`, `mssql_execute_query` | the worker's tool broker, 60s | Configured tool. One read-only statement: `SELECT` or `WITH`, no second statement, no data-modifying statement (including `SELECT … INTO`). Credentials resolve from the tool's bound integration in this workspace. The `query` is bound on the configured tool and may not be listed in `params` (403). |
| `translate_text` | the worker's tool broker, 60s | Base tool, named directly. `{texts: [...], target_language}` → `{translations: [{text, translated}], translated_count}`; see [Translate Text](/docs/tools/translate_text). Needs the agent's `document_translation_enabled`. |
| `structured_data_extraction` | the worker's tool broker, 9m | Configured tool. Re-reads one document — for a reviewer's "parse this again" control. Only `file_url` and `sheets` may be listed in `params`; the instructions, output schema, validations, model and cost settings all come from the configured tool and are `403` in `params`. A `file_url` must be a managed-storage URL (what `list_files` / `file_preview` / a read op hands you), not an arbitrary URL. A tool that binds `pause_on_validation` is `403`. Billed per page. |
| anything else | — | `501`. |

The lookup tools are available wherever the deployment has a usable broker
address (`sandbox.broker.public_url`, the same one CodeAct sandboxes use);
elsewhere they answer `501`. A broker that cannot be reached is `502`; a lookup
that outlives its deadline is `504` (the worker cancels it at the same moment).
The deadline is 60s for every tool but `structured_data_extraction`, which reads
a whole document and gets 9 minutes. That budget comes from measured production runs: median \~24 s, about 1 in 80 exceeding 4 minutes, none observed past 10 — so 9 covers the long tail of hard, many-page documents without holding a broker slot indefinitely. At most 2 such calls per surface run at once; a third answers `429` with `Retry-After`.

```jsonc theme={null}
{ "tools": [
    { "op": "revalidate", "tool": "Revalidate PO",  "params": ["edits"] },
    { "op": "translate",  "tool": "translate_text", "params": ["texts", "target_language"], "record": false }
] }
```

By default every run records one step and one feed card: a lookup is a
completed child step of the app artifact step with a standard tool card (it is
billed and audited there, but kept out of the agent's own history); a python
run is an `app_tool_run` step. An op with `"record": false` leaves **no card**,
and a lookup that billed nothing (CSV, SQL) leaves no step row either; a billed
one (`translate_text`) keeps its step as the billing anchor. Use it for lookups
a surface fires repeatedly, and still **batch**: translate a form's fields in
one call (`texts` takes up to 200 strings), not one call per field.

A run **bills what its tool records**: `translate_text` records one unit per
string translated and that lands on the run step at the tool's own rate, and
`structured_data_extraction` records the pages it read at its per-page rate — so
a re-parse control costs real money per click, unlike the others. The python,
CSV and SQL tools record nothing and are free, as before.

Each surface may call `/run`, `/action` and `/read` on an `http_calls` op at
**5 per second sustained, 20 in a burst**; beyond that the runtime answers
`429` with `Retry-After`. Still `await` every call and disable the control that triggered
it while one is in flight. Denials, timeouts and oversized payloads are
non-2xx; a tool that reports an error (a script that raised, a CSV not yet
indexed, translation disabled on the agent) returns `200` with `error` set. A
tool whose backing service the deployment lacks is `501`.

### Capabilities

`/capability` serves platform features on the session's **own task** that no
binding can express — they read the platform's files and caches, not a
customer integration:

* `file_preview`, `list_files`, `split_document`.
* `get_translation` — `{file_id, page, target_language, granularity}`: the
  cached translation of one document page, for overlays; the same rows the
  review viewer's Show Translation paints from, so paging back is free. The
  file must belong to the session's own task (the same scope as `list_files`)
  and the agent must have `document_translation_enabled`. Not billed, like the
  viewer's own route.

Translating free-standing text is **not** a capability: it is the
`translate_text` tool, reached through `/run` above, so it is gated by the
binding's `tools` list and priced by the tool registry like every other tool.

A binding may also carry `"capabilities": [...]`. The capabilities listed
above are grandfathered — reachable with the list absent, because every app
tool authored before the list existed relies on them. Anything added since
must be named in the list to be reachable; an unknown name is refused when the
binding is saved.

Three capabilities require a grant today:

* `document_selection` — lets the host hand your bundle the text a reviewer
  highlighted in the document viewer. There is **no** `/capability` call for
  this one: it is performed entirely by the browser, so calling `/capability`
  with this name is refused. Grant it, then use the bridge messages below.
* `next_review_task` — lets your bundle ask the host to open the next document
  awaiting review. Also refused by `/capability`: the host performs it, using
  the reviewer's own session rather than the app-session token. Handing a
  document over **claims** it for that reviewer for 20 minutes, so two
  reviewers asking together are given different documents, and **assigns** it
  to them; a document already assigned to a reviewer sorts ahead of unassigned
  ones for them. Approving or rejecting releases the claim; an abandoned review
  releases it on expiry. Where the agent names a priority field, documents with
  the higher value come first.
* `abort_review_task` — lets your bundle ask the host to put the document back
  in the queue when the reviewer cannot finish it. Refused by `/capability` for
  the same reason: the host performs it with the reviewer's own session, which
  is what proves they are the one holding the document. Releasing it drops the
  claim and the assignment that came with it, so the document goes to whoever
  asks next.

Because all three are performed by the browser host, they appear in
`runtime.capabilities` (so your bundle can tell whether it was granted them)
but are **not** callable through `/capability`. To tell the two apart without
guessing, read `runtime.host_capabilities`: it is the subset of
`runtime.capabilities` the host performs over the bridge messages below.
Everything in `capabilities` that is *not* in `host_capabilities` may be
POSTed to `/capability`. Older backends omit `host_capabilities`; treat a
missing field as an empty list.

## Browser bridge messages

These travel over `postMessage` between your bundle and the host page, not
over HTTP. Each is ignored unless the binding grants the matching capability.

| Message (guest → host) | Payload | Requires |
| - | - | - |
| `set_selection_mode` | `{enabled: boolean}` | `document_selection` |
| `set_translation` | `{show: boolean}` | `get_translation` |
| `request_next_review` | none | `next_review_task` |
| `open_citation` | `{citation_ids?: number[], label?: string, source_page?: number, file_name?: string}` | — |

| Message (host → guest) | Payload |
| - | - |
| `document_selection` | `{text, page, label?}` — the reviewer's highlighted text |

`open_citation` opens the document at a citation. Supply either
`citation_ids` or a `source_page`; `label` (≤200 chars) names which item of a
repeated field the citation belongs to, and `file_name` picks the document —
when it is omitted the host falls back to the document already open.

`host_state` (host → guest) reports `selectionMode`, which is the level the
host is **actually** capturing at, not merely what you requested: the host
lowers it while the reviewer is using the highlighter tool. Re-send
`set_selection_mode` when it comes back if you still want capture.

## Limits and side effects

* An upstream reply larger than **1 MiB** is discarded rather than truncated:
  half a JSON document is unparseable, so the op answers with a short
  "response exceeded" note instead of partial bytes.

* Concurrent lookups (`/read` on an `http_calls` op, and `/run`) are bounded
  process-wide. Over the bound a call waits briefly and then answers `503`,
  which is retryable — a single lookup can simply be retried.

* `/run`, `/action` and `http_calls` reads are rate-limited per surface (5/s
  sustained, burst 20; `429` with `Retry-After`). None is metered beyond what
  the tool records: an `http_calls` lookup records no usage, and a `/run`
  bills only what its tool records.

* An `http_calls` op's **host is literal**: `{{placeholders}}` are accepted in
  the path and query only, and a binding with a placeholder in the host is
  refused when saved. Upstream and token-endpoint error bodies are not echoed
  back; a `/read` still receives a non-2xx upstream's body as its result.

* Op names are one namespace across `reads`, `writes`, `http_calls` and
  `tools`; a name declared twice is refused when the binding is saved.

* Iframe is sandboxed; every runtime call must match the binding allowlist.

* Writes and actions go through `/api/app-runtime` (and the db/http gateways
  behind it), not the worker.

* Outbound URLs — including `token_url` and a rendered path — are checked
  against private, loopback and metadata ranges before dialing.

* OAuth2 bearers (`oauth2_password`, `client_credentials`) are cached per
  integration, token URL, scope and credential; a rotated secret mints a new
  token, and an upstream `401` evicts the cached token and retries once.

* The binding is re-read at most every 5 seconds per surface, and immediately
  after it is saved through the API, so an edit applies on the next call.

* `binding_kind` is validated when a binding is saved; a value other than
  `postgres` or `rest` is now a 400.

## Expected errors

* Missing `configured_tool_id` (not injected) — step cannot resolve the bundle.
* `configured_tool_id` that is not a UUID — validation error.
* Runtime op not on the allowlist, or an integration outside the workspace — `403`.
* A verb whose backing service is not wired on the deployment — `501`.


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