/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 asruntime.params. Shape comes from the configured tool’sinput_schemaoverride (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 asagent_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_awaitsrow (source_type: "app_tool",correlation_key= the step id) and returns its id asawait_idon the artifact. - The task moves to
waiting_for_inputand 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.
"resolves_await": true:
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, onextracted_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_idwas 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’sallowed_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 headerX-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:
{"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, withtask_id,agent_id,step_idandworkspace_idalways taken from the session — a bundle cannot spoof them.- Placeholders work in
urltoo (/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 a403, since it would reach a host the binding never allowlisted. "prune_empty_body_fields": truedrops 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.kindis one ofnone,bearer,oauth2_password,client_credentials. The last two read their secrets from the integration’soauth_config(username/password, orclient_id/client_secretand an optionalscope) and cache the bearer per integration, token URL and scope.- An
integration_idmust 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 byapp_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.
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 aslist_files) and the agent must havedocument_translation_enabled. Not billed, like the viewer’s own route.
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/capabilitycall for this one: it is performed entirely by the browser, so calling/capabilitywith 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/capabilityfor 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.
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 overpostMessage 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 (
/readon anhttp_callsop, and/run) are bounded process-wide. Over the bound a call waits briefly and then answers503, which is retryable — a single lookup can simply be retried. -
/run,/actionandhttp_callsreads are rate-limited per surface (5/s sustained, burst 20;429withRetry-After). None is metered beyond what the tool records: anhttp_callslookup records no usage, and a/runbills only what its tool records. -
An
http_callsop’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/readstill receives a non-2xx upstream’s body as its result. -
Op names are one namespace across
reads,writes,http_callsandtools; 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_urland 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 upstream401evicts 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_kindis validated when a binding is saved; a value other thanpostgresorrestis now a 400.
Expected errors
- Missing
configured_tool_id(not injected) — step cannot resolve the bundle. configured_tool_idthat 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.