Skip to main content
Pause a run to ask a person a question and block until they answer. Interactions explains the model this configures. An interaction suspends the caller mid-run, records the question, and wakes the caller with the answer. The 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 the ask tool module and the interactions HTTP router — the SSE inbox stream plus the human answer door.
examples/interactions/enable_interactions.yaml
An agent or tool that has 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):
A form field may declare a shape, such as "format": "email", and the sender may set its own sentence for a value that breaks it:
On WhatsApp and the callback page the person sees Enter your full address under the field, inside the form, before the submit leaves it. Without 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 default ask 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_at is required for async — an ask with mode="async" and no expiry_at raises.
  • expiry_at and timeout are mutually exclusive — they bound the same wait two ways, and passing both raises. expiry_at is valid only with mode="async".
When the answer arrives, the run resumes on its own: a stored continuation runs under the asker’s identity, exactly once. The continuation is called with {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.
Async requires the ask to be raised from a resumable run — a resuming driver and an execution identity bound in scope; raised without one it fails loudly rather than silently blocking. An async park also requires the run to sit on a durable, cross-worker checkpointer — 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 async ask 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 raises mode="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

Pass to="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. Any question can carry media — images and links shown above the answer controls in the inbox — by passing media=[...] to ask. Media is display-only: the person still answers through the answer_format. The canonical use is a select that shows each choice:
Each item is a {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
An agent asks with the builtin ask tool using the same shape; media is an array of the same objects:
The Studio Interactions inbox rendering a question together with its attached images and links.The Studio Interactions inbox rendering a question together with its attached images and links.

A question with its media rendered in the Studio inbox, above the answer controls.

Media renders in the Studio inbox only — it is not forwarded to a channel delivery, which carries the question text. See Media on a question for the accepted url forms, the caps, and the privacy note.

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:
The answer value is JSON, matching the question’s format — a string for text and select, a bool for confirm, an object for form:
examples/interactions/interactions_answer_help.sh
The value is validated server-side against the question’s declared format and schema; a mismatch is rejected with a 400 that names the failing field (for example, answer does not match schema at count: ...), and the caller stays blocked.

External questions and the callback flow

The external 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
The 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:
  1. builds the external resource, substituting the minted {callback_url} into the link (or handing it to a callable link that returns the final URL);
  2. blocks the caller while the person acts on that surface;
  3. 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;
  4. validates the payload against the question’s schema when 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 an external 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 schema IS 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. Pin reference and decision to consts so a webhook can only confirm the request the ask names:
    Under the composed tool this is the answer_schema argument; under a direct ask call it is schema=. 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.
Note what is NOT there: an 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.
This preset JSON is the reviewed artifact. The platform never validates a schema’s shape, so a human reading this body is where the pin’s correctness actually lives — and it holds only for as long as nobody rewrites the stored body through POST /api/presets/{name}/versions, which is a plain write door that replaces fixed_kwargs wholesale. On a deployment that acts on confirmations, write can rewrite the binding itself.

Manifest wiring

Three pieces compose the flow: the stripe 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
The 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 — an https:// 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’s shared_secret verifier 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-/api path 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 registers authed=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.
The 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: a 200 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:
The 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 — the X-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:
  1. Restrict write on any deployment carrying a confirmations topic — the tool-runs, schedules, agent-runs and preset-version doors, and the MCP edge. ANY holder of write can POST /api/tool-runs with a crafted event and 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. write IS the ability to forge a confirmation answer.
  2. Restrict hooks write — a separate, equally hard requirement. Hook register is an UPSERT by name and POST /api/hooks is plain write, so a hooks-write holder can register a hook on a topic they name themselves — which has no verifier bound and so an unauthenticated ingress — set tool="confirm_action", bake the forged event into the hook’s own tool_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’s start_expr in place. Verifier binding fences DELIVERY, never hook authorship; only restricting hooks write does that.
  3. Exclude four names from user_tools and every agent toolset: confirm_action, reconcile_confirmations, request_confirmation AND the un-preset composed request_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.
  4. 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 write grant does. Treat hook traces, tool-run records and logs on a confirmations topic as holding live answer capabilities and restrict read access accordingly.
  5. 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.
The const-pinned schema is the control that survives a breach of rules 1–4, and its limit is worth stating: a forged answer must carry exactly the 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); a Retry-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).
  • answered is a recovered confirmation; already_answered is the healthy norm.
  • expired means 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 its idle_ttl_seconds (24h default).
  • rejected is 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 stored fixed_kwargs no longer matching the confirmation’s reference) is the usual cause.
  • failed is 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_URL disagreeing with every callback URL). All of failed is collected and raised at the END of the run.
TTL, lookback and cadence are three coupled numbers. The claimed-ticket TTL refreshes to 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.
One deployment-wide failure slips into expired. If the callback ROUTE stops being served at all — a dropped ingress rule, a proxy pointed at the wrong upstream, the door unmounted — the origin still matches INTERACTIONS_PUBLIC_BASE_URL, so every POST draws a route-level 404, every confirmation lands in expired, failed stays empty, and the run does NOT raise. Nothing in the response distinguishes it from an aged-out ticket; what does is the SHAPE of the summary — expired == selected on a run that should have answered something. Alert on that shape, not just on failed.
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 reports already_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’s return_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

Passing channel="<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.
The callback form page the GET callback door renders from the question’s schema is the generic surface behind those native renderings. A channel form’s 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