ask_user 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_user tool module and the interactions HTTP router — the SSE inbox
stream plus the human answer door.
examples/interactions/enable_interactions.yaml
ask_user in scope can now pause and ask.
Ask a question
ask_user(question, answer_format=...) is called from inside a tool or agent run.
It records the question, blocks the caller, and returns the typed answer once a
person responds (or raises on timeout). One question declares one answer
format:
The first four are answered on the authenticated in-client surface. Example
ask_user calls (the tool input a caller passes):
group_id threads related questions, and timeout (seconds)
overrides the configured default before the call raises.
Block or park: sync and async
By defaultask_user runs mode="sync": it blocks the caller until a person
answers or the timeout elapses, then returns the typed answer (as above).
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".
expiry_at passes with no answer, the run resumes
on its own: a stored continuation runs under the asker’s identity, exactly
once. On expiry the continuation receives a reserved interaction expired marker in
place of an answer, so the run takes its expiry branch. 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 asyncask_user 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
ask that expires contributes the reserved interaction expired marker to its own
branch; its siblings’ answers ride the same resume. Nothing resumes the run part-way
— a partial set of answers buys no progress.
Agents park too
Async parking is not flow-only. An agent whose tool raisesmode="async" parks the
whole agent run and resumes it on the answer or expiry, exactly as a flow 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 or flow turn 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.
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. It is unbounded by default, and which setting bounds it depends on
the durable provider: the postgres checkpointer takes a
retention window in days, and the redis one
its native idle TTL.
Choose an expiry_at inside that window: a park
whose deadline lies beyond a bounded 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. Under a bounded
horizon an ask with no expiry_at at all is rejected — a deadline that cannot be
proven durable is never parked.
See Interactions for the model.
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_user. 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) or a link (an absolute http(s) URL) — validated
against the contract before the question is stored:
examples/interactions/ask_with_media.yaml
ask_user 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.Charge through Stripe
A payment ask is anexternal question whose outside surface is a Stripe Checkout
page. The composed create_stripe_checkout_ask_external tool opens the ask, the
payer pays on Stripe’s hosted page, Stripe signs a checkout.session.completed
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 money 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 Stripe payment” is not amount-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 amount binding. The callback door validates the delivered payload against it before it claims the ticket, and a mismatch returns 400 without consuming the ticket. Pinamount_totalandcurrencyto consts:Under the composed tool this is theanswer_schemaargument; under a directask_usercall it isschema=. An ask without it is not amount-bound.
status is deliberately NOT pinned: the answer builder hard-codes "paid", so a
status const would constrain nothing and would read like a control that is not
really there.
The money-pinning preset is required, not optional
Every money and payer-facing parameter —amount, currency, product_name,
success_url, cancel_url AND answer_schema — is an agent-suppliable argument
on the composed create_stripe_checkout_ask_external tool. An agent that reads an
injected instruction can therefore charge what it likes, redirect a backing-out
payer 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 six as hidden fixed constants — removed from
the exposed schema, and rejected outright if a caller passes one. amount_total’s
const is written from the SAME literal as amount, and currency’s const from
the same literal as currency, so the price and the pin cannot disagree (both
lowercase). 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). currency and its const
are lowercase — the builder rejects anything else and Stripe answers lowercase, so
an uppercase pin would 400 every real payment.
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 create_stripe_checkout 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/charge_through_stripe.yaml
pip install tai42-tools-stripe.
Operator prerequisites
Beyond the manifest, a payments deployment needs:STRIPE_SECRET_KEY— a secret key OR a restricted key.- A dashboard webhook endpoint with its API version pinned explicitly, 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 money pin itself cannot exist.
- A PUBLIC route row for
/universal_webhook/*on an access-control-enabled deployment. That door is unauthenticated by nature (Stripe 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. paid-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 payments topic in the gap.
expr is not optional: hook dispatch passes the tool NOTHING without one, so
confirm_stripe_payment 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) and
customer_email. Treat hook traces on a payments topic as holding a live ticket
plus a customer address, and restrict read access accordingly. The condition
gates the hook so only checkout.session.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 payment ask can never be channel-forwarded —verifier is forbidden together
with channel, because a channel’s forward is unsigned and would 401 every reply.
Payment questions live on the inbox / API surfaces only.
The livemode rule
The tools reject a session whoselivemode disagrees with the configured key’s
mode, over four prefixes — sk_live_/rk_live_ and sk_test_/rk_test_, with
anything else raising. A test-mode webhook endpoint must never point at a topic
carrying the production payments hook. Test keys are held far more widely than
live keys, and a test-mode session satisfies a const pin exactly as a real one
would. This assert catches a deployment mistake, not an attacker — an attacker who
reaches the bridge authors the event and writes livemode: true.
The reconciliation schedule
The webhook path is single-delivery: the ingress ACKs 200 before the hook runs, hook failures are only logged, and Stripe never re-delivers. A bare hook is therefore not a fulfillment guarantee — deploy the recovery layer as part of deploying payments, not as an optional extra. A deployment without it has exactly the old single-delivery failure story and nothing more.reconcile_stripe_payments lists recent Checkout Sessions from Stripe
(created[gte] plus a server-side status=complete narrowing), selects the ones
that are paid AND carry metadata.tai_callback_url, and answers each through the
same pinned bridge path. It reads Stripe rather than platform state, so it recovers
a crashed hook, an app restart mid-dispatch, and a webhook that never arrived.
Re-answering an already-answered ask is free.
Create the schedule through the authed schedules door:
schedule_kwargs is passed through to the installed backend’s scheduling tool, so
its exact keys are the backend’s. 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 the fulfillment story is back to single delivery.
The worker that runs the schedule needs three things the app already has: its own
manifest must load the stripe tools module (a tool the worker never imported
cannot run by name); its Python environment must carry tai42-tools-stripe
(free on the distribution image and where app and worker share one venv, a
separate worker image must add it); and its environment must carry
STRIPE_SECRET_KEY, TAI_BRIDGE_CALLBACK_SECRET and INTERACTIONS_PUBLIC_BASE_URL
with the callback door dialable FROM the worker. The worker’s key must be the SAME
MODE as the app’s, or the livemode assert makes every run raise.
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 the default 26-hour lookback each paid session is
re-scanned and re-POSTed on roughly 104 consecutive runs — ~104× the door
traffic, Stripe 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 (STRIPE_RECONCILE_ANSWER_INTERVAL_SECONDS,
default 1.2s) 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 sessions created inside lookback_hours of that run, so
sessions from a longer outage have aged out of every subsequent window. After any
outage longer than the reconciliation lookback, run reconcile_stripe_payments
once by hand with a lookback_hours large enough to cover it (the tool accepts
1–168 and raises outside that).
The confused deputy, and the rules that close it
Stripe’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_stripe_payment 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_stripe_payment with a crafted event
and a live ticket can answer a payment 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”.
Payment 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 payments 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 Stripe access, no signing secret, no hook — and can rewrite the money pin through the preset version door.writeIS the ability to forge a payment 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_stripe_payment", 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 payments hook’sexprin place. Verifier binding fences DELIVERY, never hook authorship; only restrictinghookswrite does that. - Exclude four names from
user_toolsand every agent toolset:confirm_stripe_payment,reconcile_stripe_payments,create_stripe_checkoutAND the un-preset composedcreate_stripe_checkout_ask_external. Agents are given the money-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 payments 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 payments topic means deleting the HOOK first.
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 payment;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 session does not fail every run. The remedy: an operator inspects the named session; preset drift (a storedfixed_kwargsno longer matching the session’s amount) is the usual cause.failedis everything that is not a verdict on one session — transport failures, 5xx exhaustion, and the three refusals that read per-session but are really deployment-wide breakage arriving one session at a time: a 403 (no PUBLIC route row), a livemode mismatch (the wrong-mode key on the whole process) 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 session 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 lost money.
Abandoned and expired payments. A payer who never pays blocks the ask until its
own timeout, then it raises — there is no other end state. A Stripe Checkout link
expires on Stripe’s own schedule (~24h by default), 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 money is captured at Stripe and the agent never learns of it;
reconciliation counts it expired rather than failing the run. cancel_url is
where a backing-out payer lands and has NO platform effect — it is baked into the
preset only because it is a payer-facing redirect and an agent-suppliable one is a
prompt-injection target. payment_status has three values — paid, unpaid, and
no_payment_required (a zero-total session) — and the bridge accepts ONLY paid,
rejecting the other two 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 payment 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 payment 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’ssuccess_url
page is pure UX; the payer never sees the callback URL; and the callback door
authenticates the BRIDGE, not Stripe. A payment 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 customer paid, the ask dies at
its timeout, and the surface is the hook-failure log line plus its monitoring
span. The recovery layer is only real once it is installed.
Deliver to a channel instead
Passingchannel="<name>" to ask_user 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 only — 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.

