ask tool is the core capability; ask_external
extends it to human actions that happen on an outside surface. This guide enables
the feature, asks in each answer format, answers from the CLI, and wires an
external callback end to end. For the model behind it, see
Interactions.
Enable interactions
Load theask tool module and the interactions HTTP router — the SSE inbox
stream plus the human answer door.
examples/interactions/enable_interactions.yaml
ask in scope can now pause and ask.
Ask a question
ask(question, answer_format=...) is called from inside a tool or agent run.
It records the question, blocks the caller, and returns an AskAnswer once a
person responds (or raises on timeout): answer is the typed answer below, beside
reply_to and params (see Block or park). One question declares one answer
format:
The first four are answered on the authenticated in-client surface. Example
ask calls (the tool input a caller passes):
"format": "email", and the sender may set its own
sentence for a value that breaks it:
wording, the callback page shows the
platform’s Enter a valid email address. Each surface’s checks and words are set out in
Shapes are caught inside the form.
A field left empty reads Enter an address first instead: required is the sentence for a
missing value, invalid for a broken shape.
An optional group_id threads related questions, and timeout (seconds)
overrides the configured default before the call raises.
Block or park: sync and async
By defaultask runs mode="sync": it blocks the caller until a person answers or the
timeout elapses, then returns {answer, reply_to, params} (AskAnswer), with answer
the typed answer (as above). The builtin ask tool’s result is that same object.
The answer comes back unchanged, with two fields beside it. reply_to is the message
the answering reply quoted, {message_id, status, text} — the same shape a conversation
turn reads (replies to an earlier
message) — kept apart from
the answer and never merged into its text; it is null when the reply quoted nothing or did
not come over a channel. params are the channel’s own string entries about the
answering tap or reply (for example the media_* entries of a photo reply), passed
through with no platform meaning; null when there are none, and on expiry. Each channel’s
plugin page names its entries.
On Telegram, a person answers the question “Run the nightly export now?” by replying to it
with a photo captioned “yes”. A synchronous ask returns:
params holds the photo’s other media_* entries too; a tool that parked on the same
question is resumed with the same three values.)
mode="async" instead parks the caller. The call records the question — and,
with a channel, delivers it — exactly as sync does, but returns a suspension
sentinel at once rather than blocking. A later answer, or the question’s expiry,
resumes the run out of band; the parked question is answered through the same
authenticated answer door or channel callback as any other, never inline.
An async park always carries a deadline. Pass expiry_at — a timezone-aware
moment:
expiry_atis required forasync— an ask withmode="async"and noexpiry_atraises.expiry_atandtimeoutare mutually exclusive — they bound the same wait two ways, and passing both raises.expiry_atis valid only withmode="async".
{interaction_id, ask_result}: ask_result is the {answer, reply_to, params} object a
synchronous asker reads, so a run parked on the builtin ask tool gets that same object as
the tool’s result. What a lapsed expiry_at does is the ask’s
on_expiry policy: on_expiry="kill" (the default) tears the whole run chain
down, while on_expiry="resume" resumes the continuation with a reserved interaction
expired marker in place of an answer, so the run takes its expiry branch — set it when
the run has an expiry path to run. On expiry ask_result still has that shape: its
answer is the expiry marker, and reply_to and params are null.
redis or postgres. The parked run’s continuation may resume on
a different worker than the one that parked it, so its checkpoint must live in
shared storage; an in-memory, sqlite, or checkpointing-disabled configuration
cannot survive that hop, and a park attempted on one raises loudly rather than
leaving a run nothing could resume.
Park several at once
A run can raise more than one asyncask before it suspends — a parallel
fan-out. Each ask is independent: it carries its own expiry_at, and its answer or
expiry can arrive in any order, from any surface, on any worker. The run stays parked
until the last of them resolves, then resumes once with every answer in hand. An
on_expiry="resume" ask that expires contributes the reserved interaction expired
marker to its own branch and its siblings’ answers ride the same resume; an
on_expiry="kill" ask that lapses instead tears the whole fan-out down. Nothing
resumes the run part-way — a partial set of answers buys no progress.
Agents park too
Async parking is not limited to one kind of run. An agent whose tool raisesmode="async" parks the whole agent run and resumes it on the answer or expiry,
exactly as any other parked run does. It needs the same durable, cross-worker checkpointer (redis or postgres) — an agent
run on an in-memory or sqlite checkpointer refuses the async ask loudly instead of
parking a run nothing could resume.
An agent park also needs the agents package’s own durable park index — a Redis
that reverses a parked interaction id back to the run that parked it, independent of
the checkpoint provider. Configure it with TAI_AGENTS_REDIS_URL (or fall back to
the shared TAI_DEFAULT_REDIS_URL), and install the package’s park extra
(pip install 'tai42-agents[park]'). With no park index configured, a park-capable
run refuses the async ask loudly before any state is written.
Parallel subagents each park in one step: when several nested subagents raise an
async ask together, their parks collapse into one suspend and the run resumes once
when the last resolves, every answer in hand — answers and expiries in any order.
When the agent was reached over a chat channel, its resumed final reply is posted
back into the originating thread, so a person who asked hours ago sees the answer
land in the same conversation. A parked tool turn (a preset or any other
tool target) reached over that channel delivers its resumed reply back the same way — mapped through the route’s
reply mapping — so a conversation target of any kind lands its late answer in the
originating thread.
Ask the caller instead of a person
Passto="caller" to address the ask to the run that called this one rather than
a person: it parks the asking run and hands the question up, and the calling run
answers by resuming it — no inbox, no channel, no callback. A caller ask is always
mode="async", may carry a structured payload the resolving run reads in place of
the question, and follows the same on_expiry policy. See asking the
caller for the door contract that resolves it.
The deadline and the retention window
expiry_at is the ask’s own deadline. It is independent of the checkpoint
retention — the horizon the paused run’s checkpointer keeps a thread before
reclaiming it: LLM_PROVIDER_CHECKPOINT_RETENTION_WAITING_MINUTES after the thread’s
last write (7 days by default) on both durable providers, redis and postgres
(see Checkpoints and retention).
Choose an expiry_at inside that window: a park
whose deadline lies beyond the retention horizon could lose its checkpoint
before the answer or expiry arrives, and the run refuses it loudly when it suspends
rather than parking a run its own storage would later strand. An agent run asks for one
more margin: the platform may still deliver an answer or expiry up to
INTERACTIONS_IDLE_TTL_SECONDS (1 day by default) after the deadline, so an agent’s
deadline must leave that much of the retention free (with the defaults, within 6 days). An ask with no
expiry_at at all is rejected too — a deadline that cannot be proven durable is
never parked.
See Interactions for the model.
An elicitation answered through ask
A tool’s ctx.elicit(...) run in process — with no MCP client to answer it — is answered
through ask. Its accepted result carries a _meta key tai42/reply_to
(ELICIT_META_REPLY_TO in the contract), the slot where the quoted message would sit.
Because such an elicitation is answered in the inbox and not on a channel, the key is
null there. The answer’s params are not carried. An elicitation answered by a real MCP
client carries no such key.
Show images and links with a question
Any question can carry media — images and links shown above the answer controls in the inbox — by passingmedia=[...] to ask. Media is
display-only: the person still answers through the answer_format. The canonical
use is a select that shows each choice:
{kind, url, caption?} object — an image (an absolute https
URL or a data:image/* URI), a document, video or audio file, or a link (an
absolute http(s) URL) — validated against the contract before the question is
stored:
examples/interactions/ask_with_media.yaml
ask tool using the same shape; media is an
array of the same objects:


A question with its media rendered in the Studio inbox, above the answer controls.
Answer from the CLI
A pending question surfaces on the paged list and the tail-only SSE stream — the Studio inbox, or the CLI. Print one page of the pending list, tail the stream live, or answer one:text
and select, a bool for confirm, an object for form:
examples/interactions/interactions_answer_help.sh
answer does not match schema at count: ...), and the caller stays
blocked.
External questions and the callback flow
Theexternal format is for actions a person completes on an outside surface —
signing a document, approving a request, paying. The asking tool blocks exactly as
for any other format; only the delivery channel differs. The external system
delivers the answer back through a public callback door, not the answer door.
The ask_external transformer extension packages the whole flow. It wraps a tool
that builds an external resource from a callback_url and returns the URL to
visit. Attach it in the manifest, binding an optional webhook verifier that
authenticates the signed callback:
examples/interactions/attach_ask_external.yaml
verifier is author-bound extension config — it is closed over at build
time and is never an LLM-facing parameter, so a calling agent can neither drop nor
forge the callback authentication. The wrapped tool keeps its own inputs; the
extension injects question / answer_schema / timeout and hides
callback_url (the platform supplies it).
At run time, calling the composed tool:
- builds the external resource, substituting the minted
{callback_url}into thelink(or handing it to a callable link that returns the final URL); - blocks the caller while the person acts on that surface;
- accepts the answer when the external system POSTs the signed payload to
POST /api/interactions/callback/{ticket}(verified first when a verifier is bound), or when a person completes the GET confirm page; - validates the payload against the question’s
schemawhen one is declared, and returns it to the blocked caller.
External questions (and any
channel-delivered question) require
INTERACTIONS_PUBLIC_BASE_URL — an https:// origin, since the callback URL is a
bearer capability — and Redis. Callback payloads arrive through an
unauthenticated door, so treat every delivered answer as untrusted input.Confirm through an external system
A confirmation ask is anexternal question whose outside surface is a resource the
external party acts on. The composed request_confirmation_ask_external tool opens
the ask, the party confirms on that surface, the external system signs a
confirmation webhook, and a bridge hook answers the blocked caller. Two per-question
locks make that flow safe to hand an LLM, and a preset makes the confirmation
un-forgeable by the agent. Everything below is what an operator deploys, in order.
The two per-question locks
An ask that merely “waits for a confirmation” is not request-bound. Two locks bind it, both declared per question:-
The verifier binding authenticates the signed callback. Under the composed
tool it is author-bound extension config (
verifier), closed over at build time and never a call argument, so a calling agent can neither drop nor forge the callback authentication. -
The const-pinned answer
schemaIS the request binding. The callback door validates the delivered payload against it before it claims the ticket, and a mismatch returns 400 without consuming the ticket. Pinreferenceanddecisionto consts so a webhook can only confirm the request the ask names:Under the composed tool this is theanswer_schemaargument; under a directaskcall it isschema=. An ask without it is not request-bound — any signed webhook the door accepts would claim the ticket, whatever request it confirmed.
The pinning preset is required, not optional
Every request-facing parameter —reference, return_url AND answer_schema — is
an agent-suppliable argument on the composed request_confirmation_ask_external
tool. An agent that reads an injected instruction can therefore point the ask at a
different reference, redirect the external party to an attacker page, or drop the
pin entirely (answer_schema defaults to None, leaving the ask claiming whatever
the bridge posts).
The fix is a preset that bakes all three as hidden fixed constants — removed from
the exposed schema, and rejected outright if a caller passes one. answer_schema’s
reference const is written from the SAME literal as reference, so the request
and the pin cannot disagree. Give agents the preset and never the raw composed
tool.
extensions key. An explicit "extensions": [] is
rejected with a 400 (omit the field for no extensions). decision is pinned to
"confirmed" so a declined or partial answer cannot claim the ticket as a
confirmation.
Manifest wiring
Three pieces compose the flow: thestripe verifier under the canonical
webhook_verifier_modules field for the webhook-verifier kind; the built-in
shared_secret verifier under lifecycle_modules (it authenticates the bridge
at the callback door, and loads only through the manifest — without it the
per-question binding fails closed); and ask_external under extensions_modules,
ATTACHED to request_confirmation through the extensions map. The attachment map
is the piece that actually composes the agent-facing tool — loading the extension
module without it gives the bare builder and no composed tool at all.
examples/interactions/confirm_through_external.yaml
stripe verifier ships in tai42-webhook-verifier-stripe (present on the
distribution image; elsewhere install it with pip install tai42-webhook-verifier-stripe). The request_confirmation, confirm_action and
reconcile_confirmations tools are the deployment’s own.
Operator prerequisites
Beyond the manifest, the deployment needs:- The external system’s API credential — read by the reconcile tool to list recent confirmations from the external system.
- A webhook endpoint configured on the external system, its API version pinned
explicitly where the provider versions its payloads, plus its signing secret in
the env var the topic binding names (
secret_env). INTERACTIONS_PUBLIC_BASE_URL— anhttps://origin. It is both the reachability requirement for the minted callback URL and the ground truth the bridge’s SSRF pin compares against.TAI_BRIDGE_CALLBACK_SECRET— one value read by BOTH the door’sshared_secretverifier and the bridge tool. It must be present on every process that answers (app and worker), rotated in a coordinated change across them, and note the read asymmetry: the door reads it to authenticate the bridge, the bridge reads it to sign. Missing on the door, the verifier raises and the door 500s; the bridge then sees a retryable 5xx and burns its whole retry ladder against it — an operator fix (set the secret), not a code one.- A configured versioned-document store. Without it no preset can be created at all — the binding itself cannot exist.
- A PUBLIC route row for
/universal_webhook/*on an access-control-enabled deployment. That door is unauthenticated by nature (the external system carries no API key) and is a non-/apipath the gate’s declared-public tier does not reach, so it needs the row. An unconfigured route 403s, and a 403 is on the bridge’s fail-fast list — i.e. confirmed-but-unanswered. The/api/interactions/callback/*door needs no row: it registersauthed=False, so the gate publics it straight from its declaration.
Bind, register, bake — in this order
Order matters: bind the verifier FIRST. Between registering the hook and binding the verifier, the topic is an open unauthenticated door straight into the bridge — ingress verification runs only when a binding exists. There is a second, stronger reason: a/trigger/{token} link cannot be minted on a verifier-bound
topic at all (the mint is refused 400), so binding first shuts that door before it
can be opened; bind late and any holder of hooks write can mint a link on the
still-unbound topic in the gap.
start_expr is not optional: hook dispatch passes the tool NOTHING without one, so
confirm_action would be called with no event and fail on every delivery. It is
deliberately narrow to keep the payload small — but it necessarily carries
metadata (holding tai_callback_url, a live answer capability). Treat hook traces
on a confirmations topic as holding a live ticket, and restrict read access
accordingly. The condition gates the hook so only confirmation.completed events
fire it; without it every event on the topic fires the bridge, which raises loudly
on the wrong type and floods the only failure surface this design has.
The hook fires under its bound execution_key’s identity. That key is identity
and liveness only — for a capability tool the authorization layer never runs a
per-tool check — so bind a key the operator owns and control the surface by
restricting who may WRITE hooks. Binding a key is mandatory: a hook with none is
refused before it fires.
The channel constraint
A confirmation ask can never be channel-forwarded —verifier is forbidden together
with channel, because a channel’s forward is unsigned and would 401 every reply.
Confirmation questions live on the inbox / API surfaces only.
The reconciliation schedule
The platform keeps every webhook event it accepts: a200 means the event is kept. A
hook run that fails or meets a restart is retried or held as failed (see Kept and
retried). A bare hook is still not a delivery
guarantee, because two cases stay beyond the platform: an event that never arrives
(the external system never sent it, or gave up on its delivery), and a hook run held
as failed, which waits for an operator. Deploy the recovery layer as part of deploying
confirmations, never as an optional extra.
reconcile_confirmations lists recent confirmations from the external system (a
time-window query plus a server-side narrowing to completed ones), selects the ones
that are confirmed AND carry metadata.tai_callback_url, and answers each through
the same pinned bridge path. It reads the external system rather than platform
state, so it recovers a webhook that never arrived and a confirmation whose hook run
is held as failed. Re-answering an already-answered ask is free.
Create the schedule through the authed schedules door:
cadence is the same typed shape on every backend (see Schedule a
tool). When no scheduling backend is installed the
door 501s — there is no schedule to create, and reconciliation runs only as a
manual authed tool call, so an event that never arrived is recovered only by that call.
The worker that runs the schedule needs three things the app already has: its own
manifest must load the confirmation tools module (a tool the worker never imported
cannot run by name); its Python environment must carry the confirmation tools and
the external system’s client; and its environment must carry the external system’s
API credential, TAI_BRIDGE_CALLBACK_SECRET and INTERACTIONS_PUBLIC_BASE_URL
with the callback door dialable FROM the worker.
Cadence and lookback are a coupled pair — document them together, never one
alone. A 15-minute cadence bounds recovery lag to well under any realistic ask
timeout, and overlapping runs stay safe because every answer is idempotent. It is
not free: at 15 minutes against a 26-hour lookback each confirmation is re-scanned
and re-POSTed on roughly 104 consecutive runs — ~104× the door traffic, external
list calls and log volume of a single delivery. Tightening the cadence multiplies
the overlap; shortening the lookback shrinks the recovery window. Answers are serial
and paced (an answer interval, ~1.2s by default) because a run is one worker address
against a door that limits per client address; a deployment that lowers the interval
to clear a bigger window must raise the interactions_callback family’s budget
(flood control on public doors)
in the same change.
Runbook — an outage longer than the lookback is not recovered automatically.
Each run sees only confirmations created inside lookback_hours of that run, so
confirmations from a longer outage have aged out of every subsequent window. After
any outage longer than the reconciliation lookback, run reconcile_confirmations
once by hand with a lookback_hours large enough to cover it.
The confused deputy, and the rules that close it
The external system’s signature is verified at the topic ingress. The interaction callback door verifies something else entirely — theX-TAI-Bridge-Secret header,
i.e. “this POST came from the bridge”. confirm_action holds that secret and answers
with whatever event dict it is invoked with. So the real trust boundary is:
anything in-process that can call confirm_action with a crafted event and a live
ticket can answer a confirmation ask — and the blast radius is one notch wider
still, because neither tool knows which question a callback URL belongs to. The
capability is “answer ANY live external interaction whose ticket it is handed”.
Confirmation asks are the protected case precisely because they bind the verifier
and pin the const schema; an ordinary unbound external ask has nothing that refuses
a forged answer.
There is no lever that keeps a registered tool off “every surface an LLM or API
caller can reach”: POST /api/tool-runs, POST /api/schedules,
POST /api/agents/{name}/run and the MCP edge are all run-any-registered-tool
doors at write, a capability tool carries no per-call authorization decision, and
the preset version doors are write too. The hard requirements, stated to what
actually enforces them:
- Restrict
writeon any deployment carrying a confirmations topic — the tool-runs, schedules, agent-runs and preset-version doors, and the MCP edge. ANY holder ofwritecanPOST /api/tool-runswith a craftedeventand forge an answer for any ticket they know — no external access, no signing secret, no hook — and can rewrite the binding through the preset version door.writeIS the ability to forge a confirmation answer. - Restrict
hookswrite — a separate, equally hard requirement. Hook register is an UPSERT by name andPOST /api/hooksis plainwrite, so ahooks-write holder can register a hook on a topic they name themselves — which has no verifier bound and so an unauthenticated ingress — settool="confirm_action", bake the forged event into the hook’s owntool_kwargs(author kwargs merge strongest), bind their OWN execution key, and fire it with one anonymous POST. The same upsert-by-name also lets them rewrite the real bridge hook’sstart_exprin place. Verifier binding fences DELIVERY, never hook authorship; only restrictinghookswrite does that. - Exclude four names from
user_toolsand every agent toolset:confirm_action,reconcile_confirmations,request_confirmationAND the un-preset composedrequest_confirmation_ask_external. Agents are given the pinned PRESET and nothing else. This is caller discipline with no enforcing lever — an agent’s toolset is what the run request names, not what the manifest says — so its backstop is rule 1 on the agent-runs door. - Treat tickets as secret. The bridge secret is required in addition to the
ticket, so a stolen ticket alone forges nothing — but a ticket plus a
writegrant does. Treat hook traces, tool-run records and logs on a confirmations topic as holding live answer capabilities and restrict read access accordingly. - Never unbind the verifier while the topic is live. The binding is the ONLY thing between the registered bridge hook and the open internet; unbinding turns that hook into an anonymous forge endpoint instantly. Retiring a confirmations topic means deleting the HOOK first.
reference and
decision the ask declared, or the door 400s it and leaves the ticket unconsumed.
That defeats an attacker who cannot choose the payload. It does NOT defeat an
attacker who writes the payload directly, because the pinned values are not secret
(the reference is visible to the external party and in the callback metadata).
Failure paths, stated honestly
The bridge retries transient door failures. Connection errors, HTTP 5xx, 408 and 429 retry with bounded exponential backoff (5 attempts, ~0.5s base, doubling, jittered); aRetry-After on a 429 is honoured in full up to a 90-second ceiling,
because the callback door’s flood
limiter is a fixed-window
counter whose Retry-After is the seconds left in that window. 400, 401, 403 and
404 fail fast — a schema mismatch, a wrong secret, a refused caller (an unpinned
access-control route answers exactly this) and an expired ticket are verdicts no
retry changes. Exhaustion raises.
Read the reconcile run summary as five disjoint outcomes:
selected == answered + already_answered + expired + rejected + len(failed).
answeredis a recovered confirmation;already_answeredis the healthy norm.expiredmeans the ticket no longer resolves — read it as exactly that, nothing narrower: the tool cannot distinguish an ask that DIED from one answered long ago whose ticket has since aged out of itsidle_ttl_seconds(24h default).rejectedis the door’s 400 and nothing else — the ask’s own schema pin refusing THIS payload — reported and never raised, so a permanently unanswerable confirmation does not fail every run. The remedy: an operator inspects the named confirmation; preset drift (a storedfixed_kwargsno longer matching the confirmation’s reference) is the usual cause.failedis everything that is not a verdict on one confirmation — transport failures, 5xx exhaustion, and the two refusals that read per-confirmation but are really deployment-wide breakage arriving one at a time: a 403 (no PUBLIC route row) and an SSRF-pin refusal (INTERACTIONS_PUBLIC_BASE_URLdisagreeing with every callback URL). All offailedis collected and raised at the END of the run.
idle_ttl_seconds (default 24h) while the reconciler’s default
lookback is 26h, so a healthy answered confirmation reports already_answered
for its first 24h in the window and expired for at most the last ~2h — a steady
expired count on a healthy deployment is that overhang, not a lost confirmation.
Abandoned and expired confirmations. A party who never acts blocks the ask until
its own timeout, then it raises — there is no other end state. An external
confirmation link expires on the external system’s own schedule, which can outlive
or undercut the ask’s timeout; authors must choose the ask timeout with the
link’s lifetime in mind. A webhook that lands after the interaction was pruned gets
the door’s uniform 404 and the bridge raises loudly on it (a typed error carrying
.status == 404) — the confirmation is recorded at the external system and the agent
never learns of it; reconciliation counts it expired rather than failing the run.
return_url is where the external party lands after acting and has NO platform
effect — it is baked into the preset only because it is a party-facing redirect and
an agent-suppliable one is a prompt-injection target. The bridge accepts ONLY a
decision of confirmed, rejecting any other value loudly.
A captured signed delivery is refused on replay inside the tolerance window. The
stripe verifier claims a seen-set entry keyed on the event.id, with a TTL anchored to
the signed timestamp that runs until the freshness window ends (t + tolerance_seconds),
so on the webhook ingress a
replayed signed delivery draws the idempotent already_seen 200 — nothing dispatched,
no duplicate hook fires. The interactions callback door verifies the signature but does
not consult that seen-set; there a replay is absorbed by ticket state, where an answered
ticket returns the idempotent already_answered 200 — so no double answer on either door.
The callback doors and the ticket-state oracle
For a verifier-bound confirmation question the doors give a caller without the bridge secret nothing to read: the GET door answers one constant 404 whether the ticket is unknown, expired, live or answered, and the POST door verifies BEFORE it reportsalready_answered, so a caller without the secret sees the same 401 either way. A
genuine provider retry still passes verification and gets its idempotent 200. One
residual stays: on the POST door a bound-ticket holder can still tell a resolvable
ticket (401) from a dead one (404), because the binding lives in the interaction
state and cannot be read before that state is — “this ticket existed recently”, not
“there is an unanswered confirmation worth attacking”.
One behaviour to know: an answered bound question whose verifier cannot RUN — an
unregistered verifier name, a malformed binding, or a verifier raising something
other than a verification failure — answers 500 where an unbound question would
answer the idempotent 200. That is correct (a door that cannot authenticate must not
report state) but a bridge sees a retryable 5xx and burns its whole ladder against
it, so the fix is an operator one: register the verifier module (and, for the
built-in, set the secret_env it reads).
Doctrine and residuals
The signed webhook is the only answer path from OUTSIDE; the author’sreturn_url
page is pure UX; the external party never sees the callback URL; and the callback
door authenticates the BRIDGE, not the external system. A confirmation is lost to
the agent only when both the webhook and reconciliation fail inside the ask’s own
timeout window — most realistically because reconciliation was never scheduled, or
the scheduler was down longer than both the ask and the lookback. Then the party
acted, the ask dies at its timeout, and the surface is the hook run held as failed
(tai hooks runs list --status failed, the operator notification and the
hooks_run_failed event; see Kept and retried),
or nothing at all when the event never arrived. The recovery layer is only real once
it is installed.
Deliver to a channel instead
Passingchannel="<name>" to ask hands delivery to a registered
channel plugin — Telegram, Slack, SMS/WhatsApp — instead
of the default inbox: the question mints a public callback ticket exactly like an
external ask, the plugin pushes it to the person, and the reply returns through
the same callback door. An optional recipient names the per-call address; an
unknown channel name or a delivery failure raises loudly before the run would wait
on an answer that can never arrive.
A form ask rides a channel only when that channel advertises
supports_form_delivery; a form sent to a channel without the flag raises loudly,
naming the channel, before anything leaves the run. Among the bundled channels:
- web renders a schema-driven form widget inline in the chat;
- Slack posts a message whose button opens a Block Kit modal;
- WhatsApp sends the form as an in-chat WhatsApp Flow;
- Telegram offers a button that opens the schema-rendered callback form page as an in-chat webview;
- Twilio (SMS and WhatsApp-via-Twilio) advertises no form support, so a form ask to it is refused.
schema must fall in the renderable
channel-deliverable subset —
scalar properties or an array of strings, optionally carrying
date constraints and
visibleWhen — validated when the
question is asked, before anything is stored; a ValueError names the offending
property. A form on the default inbox surface keeps full schema freedom.
See also
- Interactions — the loop, the doors, and the security model.
- Triggers and webhooks — the verifier that authenticates a signed callback.
- CLI reference — the full
tai interactionssurface.

