Skip to main content
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)

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.
  • 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:
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. 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: 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

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:
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: 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.
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. 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.