ask tool is the core capability: it
suspends the caller mid-run, records the question, and wakes the caller with the
answer.
This page explains the model — asking a person. An ask can also be addressed
to the run that called this one (to="caller"), which parks and hands the
question up rather than out to a human; that variant is asking the
caller. To turn interactions on and answer one, follow
Use interactions.
The loop
ask is the engine-agnostic author surface behind the contract’s Ask
protocol. Loading it is manifest-driven: opt in the interactions tool module and
the interactions HTTP router, exactly like any other capability.
Sync and async
A question also declares a wait discipline,mode:
-
sync(the default) blocks the caller. The loop above is the sync loop: the caller waits on the answer or thetimeout, then{answer, reply_to, params}returns — the typed answer, the message the answering reply quoted, and the channel’s params (or the call raises). The answer comes back unchanged, with two fields beside it.reply_tois 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 isnullwhen the reply quoted nothing or did not come over a channel.paramsare the channel’s own string entries about the answering tap or reply (for example themedia_*entries of a photo reply), passed through with no platform meaning;nullwhen there are none, and on expiry. Each channel’s plugin page names its entries. -
asyncparks the caller.askrecords — and, over a channel, delivers — the question exactly as sync does, but returns a suspension sentinel immediately instead of blocking. A later answer, or the question’s expiry, resumes the run out of band.
expiry_at, a timezone-aware moment. It is
required for async — a park with no deadline could never expire, so the ask
raises without it — and mutually exclusive with a sync timeout, which bounds
the same wait the other way. expiry_at is valid only with mode="async".
A parked question is answered out of band — through the ordinary authenticated
answer door or the channel callback, exactly as a sync question, never inline. When a
human answers, the run resumes on its own: a stored continuation runs under the
asker’s identity (never the answerer’s), exactly once. It is called with
{interaction_id, ask_result}, where ask_result is the {answer, reply_to, params}
object a synchronous asker reads.
What a lapsed expiry_at does is the park’s on_expiry policy:
kill(the default) tears the whole run chain down. The parked run — and every run linked above it — is failed, and a door that started it with a receiver is delivered a single failure. An unanswered deadline ends the work rather than resuming it.resumeresumes the continuation with a reserved interaction expired marker in place of an answer, so the run takes its expiry branch rather than a real answer. Choose it when the run has an expiry path to run. The marker arrives as theanswerofask_result, withreply_toandparamsnull.
interactions_ask_expired_unanswered
platform event a deployment can hook on.
Async requires the run to be resumable: it must be raised where a resuming driver
and a bound execution identity are in scope, or it raises loudly rather than
silently blocking.
Async also requires a durable, cross-worker checkpointer — redis or
postgres. The continuation may resume on a different worker than the one that
parked the run, so its checkpoint must live in shared storage; an in-memory,
sqlite, or checkpointing-disabled setup cannot cross that boundary, and an async
park on one raises loudly.
Withdrawing a parked ask
A parked ask can also be taken down on purpose: by the cancel door (POST /api/interactions/{id}/cancel, tai interactions cancel), by a door contract’s
cancel_expr or the cancel_parked tool, or by erasing the conversation thread, route or
person it belongs to. Each one tears the whole run chain down, exactly as an
on_expiry="kill" lapse does.
Every takedown, a lapse included, frees the channel’s hold on the person at once. A channel
that keeps one open question per person releases that person, so the next ask to them goes
out straight away instead of being refused until the old one lapses. The question already
sent stays on the medium. A late reply to it finds no open question and is treated like any
late reply: with a channel route bound it enters the conversation
as an ordinary message (a late WhatsApp form submit arrives as a message carrying the form’s
values).
What differs is the terminal the door that started the run receives. A run ends in one of
three terminals: succeeded (its result is delivered), failed (any non-success; a
conversation route sends the failing target’s reply when the failure carries one, else the
route’s reply_texts.error, else the built-in line) and withdrawn (taken down on purpose;
nothing is delivered). A deliberate takedown delivers withdrawn, so a conversation route
sends nothing and announces nothing: the person is told nothing. Among takedowns, only an
on_expiry="kill" lapse delivers failed.
A door receives its terminal through the delivery address it binds before the run starts
(set_park_completion, a tool plus an opaque context). The platform fires that tool with
the bound context merged with the fire’s own keys: thread_id, result, completion_id,
status and, only on a failed fire whose failure carries a target reply, reply. A bound
context that carries any of those five keys is refused when the address is bound, so a
context can never stand in for the fire’s own data. An address tool for a door that shows
text to a person accepts an optional reply and shows it in place of its fixed failure line.
Parking several at once
A run can raise more than one asyncask before it suspends — a parallel
fan-out that parks on all of them together. 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 buffers each resolution durably and resumes once, when
the last ask resolves — never once per answer, and never part-way on a partial
set. A mix of real answers and on_expiry="resume" expiries resolves the same way: an
expired ask contributes the reserved interaction expired marker to its own branch
while its siblings contribute their answers, and the single resume carries them all
together. An on_expiry="kill" ask that lapses is different — it tears the whole
fan-out down rather than resuming it. A lone async ask is the one-ask case of this
same path.
A run parks
The resuming driver is engine-agnostic: any run that parks is a driver, an agent run included. An agent whose tool raises an asyncask parks the whole agent run and
resumes it on the answer or expiry exactly as any other parked run resumes — the same
stored continuation, the same asker identity, the same once-only resume. Parking an agent run needs the
same durable, cross-worker checkpointer as any park (redis or postgres); an agent
run on an in-memory or sqlite checkpointer refuses the async ask loudly rather than
parking a run nothing could resume. It also needs the agents package’s own durable
park index — a configured Redis that reverses a parked interaction id back to the
run that parked it, independent of the checkpoint provider; a park-capable run with
no park index configured 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, all their parks collapse into one suspend, and the run
resumes once when the last of them resolves — answers and expiries arriving in
any order, from any surface, on any worker, exactly as parallel asks resolve on any
other parked run.
When a parked agent was reached over a chat channel, its resumed final reply is
posted back into the originating conversation thread, so the late answer lands where
the person is talking. A parked tool turn (a preset or any other tool target)
reached over that same 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 same
thread.
Deadlines and retention
An async ask’sexpiry_at is the deadline — the moment the question stops
mattering, which fires its on_expiry policy. The checkpoint retention is a
different clock: the paused run’s checkpoint lives in the checkpointer’s own store for the
waiting retention after its last write (7 days by
default), after which it is reclaimed. The two must agree: an async ask whose expiry_at lies
beyond that horizon would lose its paused graph before the answer or expiry ever arrives — so
the park is refused loudly when it suspends, naming the interaction, its expiry_at, and
the horizon it exceeded.
The park’s own records keep one more clock, the retention horizon. Every key an async park
writes (its state, its group’s stream and count, its run and thread indexes and its callback
ticket) lives until expiry_at plus INTERACTIONS_IDLE_TTL_SECONDS (24 hours by default).
The expiry reaper runs every INTERACTIONS_EXPIRY_REAPER_INTERVAL_SECONDS (30 seconds by
default) and resolves a lapsed park whenever it reaches it inside that horizon, so a late or
stalled reaper still applies on_expiry. Both answer doors stay open for the same window: the
callback ticket lives as long as the park, and an answer is taken for as long as the park is
still pending. A park the reaper reaches only after its horizon has passed can no longer be
resolved: it is logged at ERROR and stated once as an interactions_ask_expiry_abandoned
platform event carrying interaction_id and expiry_at.
No on_expiry action runs and nothing is delivered to the door.
An agent run holds its asks to a tighter deadline. The platform may still deliver an ask’s
answer or expiry up to that retention horizon after the deadline, so an
agent parks only an ask whose deadline leaves the whole horizon
before its checkpoint’s retention runs out (with the defaults, within 6 days). An ask past
that point is refused loudly before anything is parked, and a resume that finds the
checkpoint gone ends the run as a failed terminal.
Answer formats
A question declares one of five answer formats. Four are answered on an authenticated in-client surface — the caller waits while a person responds through the answer door:text— free text.confirm— a yes/no acknowledgement.select— a choice from options.form— a typed payload validated against a declared schema. Answered in-client through the answer door, or — over a channel that supports forms — rendered as that channel’s own form surface (see the channel capability matrix for what each renders).
external: the human acts on an outside surface — signs a
document, approves a request, confirms an action — and the external system delivers the answer
back through a public callback door. The asking tool blocks exactly as it does for
any other format; only the delivery channel differs.
Prefilling, per-send options and pages
Aform ask can refine its schema per send with two optional keys, both generic to
every consumer and channel:
data— a per-send overlay on the schema.data.valuesprefills top-level properties with known answers (keyed by property name), so the person confirms or edits rather than typing from scratch.data.optionssupplies per-send choice lists (keyed by property name) that replace a string property’senumfor this one send — each option avalue(what the answer carries), an optionallabel(the text shown in its place), and an optionaldescription(a second line drawn with the label) — so a variant needs no republished form.pages— an ordered list of pages, each atitleand the property names it groups, splitting a longer form into steps. Every top-level property appears exactly once across the pages; an absentpagesis a single page.
description
on the property draws as a hint under the field’s title label, so a short label keeps
the rest of the text (a time, a price, a note) on a second line. A field description
and an option description are optional and carry content, so a surface whose control
has no native second-line slot draws the text in the nearest text affordance by that
surface’s documented rule — never silently dropped. The platform sets no length bound;
each channel enforces its own medium’s limit in its own code.
Both are validated against the schema before the ask is sent: an unknown property, a
value that fails its property’s schema, options on a non-string property, or a page
that misses, repeats, or names an unknown field raises loudly, naming the field. A
channel that cannot honor per-send data refuses at delivery — naming the field —
rather than dropping it silently.
count arrives filled in under a count field that shows its
description as a hint, date offers the two labelled choices this send provides —
each with its own second line — and the form reads as two steps. The same data and
pages carry to every surface that renders the form.
Multiple choice and radio
Besides a scalar, a form property may be an array of strings — a multiple-choice field whose answer is the list of chosen values. Declare it as anarray whose items are a string, optionally with an items.enum (or a per-send
data.options list) for the choices:
string with an enum. How a choice list renders is
the surface’s call, decided by the option count against one shared, documented
threshold — RADIO_OPTION_THRESHOLD (5): a list at or under the threshold
renders as a radio group (for a single-choice string+enum) or a checkbox group
(for an array-of-strings), a longer one as a dropdown or multi-select. Every surface
draws the same short list the same way. A per-send data.options
list replaces the choices for one send, for an array-of-strings field exactly as for
a scalar enum.
Date constraints
A date property — astring with format date or date-time — may carry
platform date-constraint keys, validated when the form is built and enforced on the
answer at every door:
minDate/maxDate— inclusiveYYYY-MM-DDbounds.unavailableDates— a list of excluded days, each aYYYY-MM-DDdate or a lowercase weekday name (monday…sunday) excluding that weekday.
rangeStart and carries the span bounds
minDays / maxDays (inclusive day counts); each field’s answer stays the plain date
string its type declares:
date fields may have a time: a string with format
time that names its end in timeOf. The time stays its own field and its own
HH:MM or HH:MM:SS answer; nothing is recombined.
end on or after start) and the inclusive
span from the two submitted values — identically whether a surface drew them as two
inputs or as one native range control mapped to the two fields, so nothing is ever
recombined:
- Two
dateends are ordered by date, and the span counts the days. When both fall on the same date and each has a time, they are then ordered by those times: an end time before the start time is refused on the end time’s field (The end time must be on or after the start time). When either time is absent, the dates alone decide. - A mixed pair (one
dateend and onedate-timeend, either way round) is ordered by each value’s written date, and its span counts the days. - Two
date-timeends are ordered by their full instants, so an end on the same day but before the start is refused. Both instants are read in the START’s offset, and the span is the inclusive count of calendar days between them in that offset, so a range in order always spans at least one day. Each answer stays the string the end user wrote, so with different offsets the END’s written date may be earlier than the START’s while the range is in order.
date-time ends: the widget and Studio leave those to the answer door, and
the callback page skips them (their end input carries data-range-instant), so the
answer door alone checks them.
Wherever a form is drawn or read back, each time is listed right after its date,
whatever order the schema declares them in: on the page, in a range control (each time
under its date), and in a review readback. In WhatsApp, where the two dates merge into one
range picker, both times come after the picker: the start time, then the end time. A time
and its date sit on the same page.
A nonsensical constraint — a date key on a non-date property, maxDate
before minDate, a rangeStart naming an undeclared or non-date property, minDays
above maxDays, a span key on a field with no rangeStart, a timeOf on a field that
is not format: time, a timeOf naming something other than a date end of a range,
a timeOf on a range whose other end is date-time (a time without an offset cannot be
ordered against it), a second time for the same date, a time whose visibleWhen
differs from its date’s, or a time on another page than its date — is refused loudly
when the form is built, naming the property. This holds on every door that carries a
form: an ask over a channel, an ask answered in the inbox, a notify_user form and a
conversation form part. Each key renders as the matching native control where a
surface has one (see the capability table); where a
surface cannot bound its date control, the bound is still enforced on submit.
Conditional fields
A property may carry avisibleWhen predicate making its visibility depend on
another field’s value — {"field": <other property>, "equals"|"in"|"notEmpty": …},
exactly one operator per predicate:
equals is visible when the named field equals the value, in when it is one of a
non-empty list, notEmpty (which must be true) when it is present and not an empty
string / list / object. The named field must be another declared property.
The effective-answer rule. Whether a field is part of the answer is decided by its
predicate evaluated on the submitted values, at the single answer facet, so it
holds on every answer door — authenticated, callback, channel-forwarded, and the
expiry path alike. A field hidden by that evaluation is removed from the answer
the consumer receives: it is not required, not validated, and a value submitted for it
— filled before it was hidden, or filled on a surface that degraded to drawing every
field — is dropped, never an error.
Display elements and a review page
A page may carry ordered display blocks beside its input fields — aheading, a
body, or an image — through its optional display list, and may be a terminal
review page:
heading/body block carries text; an image block carries a src and an
optional alt (the text a surface that cannot draw the image shows instead). A block
is either static (its text/src given here) or declares a slot — a name a
reaction’s display update fills while the form is
open (a computed total is a display slot, nothing special). A slot name is unique
across the form.
A page is an input page (kind: "input", the default) or a review page
(kind: "review"): a terminal summary step that carries no input fields (an empty
fields). A surface renders a review page as a readback of the entered values
alongside its display blocks and the confirm/submit control.
Media on a question
A question can carry optional media — images and links shown WITH it in the inbox, above the answer controls. Media is display-only: the person still answers through the question’sanswer_format, and the media never becomes part
of the answer. It is orthogonal to the answer format — valid with every one — and
renders with the question both while it is pending and after it is answered.


The Interactions inbox rendering a question's media — images and links shown above the answer controls.
MediaItem — a kind, a url, an optional caption, and (for a
document) an optional filename:
kind— one of five. The file kindsimage,document,video, andaudiocarry a fetchable body and render as the matching bubble (animageinline, the others as the matching file element); alinkrenders as a labelled anchor the person clicks through. In the Studio inbox adocumentis a download link labelled by itsfilenameorcaption, andaudio/videoplay in a native player only from the deployment’s own/api/interactions/media/<id>reference (a player has no referrer control); an item the inbox cannot show reads “Media item N could not be shown.” in its place, and the question stays answerable.url— the source. A file kind is an absolutehttpsURL or a same-origin/api/interactions/media/<id>reference to media the deployment serves by id;imageadditionally admits an inlinedata:image/*URI (that inline form is image-only — adata:URI on any other kind is refused). Alinkis an absolutehttp(s)URL. Remote file media is https-only: the inbox content-security policy admitshttps:,data:, and same-origin sources but nothttp:, so anhttp:file source would be a record that could never render — it is rejected up front, not silently dropped. Anchors are not governed by that policy, so anhttp:link stays valid.caption— optional accessibility text: an image’s alt text, a file’s label, or a link’s display label.filename— optional suggested display name for adocumentdownload; it is meaningful only on adocumentitem and is refused on any other kind.
Caps
Media and the question text are bounded by the contract. These are wire-contract properties — they bound the ask (and notify) request a tool submits, not a replay — not operator settings:MEDIA_MAX_ITEMS is a loose platform abuse guard on the item count, not a
per-channel limit: WhatsApp, for one, delivers each image as its own message, so
the guard — not a native per-message envelope — bounds how many items an ask or
notify may carry. The byte caps
bound the submitted request, not a replay: an accepted data:image/* image is
stored by reference — the record keeps a same-origin /api/interactions/media/<id>
url and the bytes are served from that route for the question’s lifetime. The
pending inbox is read as a paged list through GET /api/interactions, and the SSE
stream is tail-only, so no historical backlog is replayed whole on (re)connect — a
reconnecting client resumes recent events by Last-Event-ID
instead.
Limits
- Display-only. Media rides the question, never the answer. The person’s reply carries no media.
- Forwarded to channels that render it. A question always persists to the
inbox, where its media renders. When the same question is also delivered to a
channel, its media rides the delivery too — the channel
renders it alongside the question. It is a pure enhancement, not structure: a
channel that renders only text simply ignores it (no capability flag, never a
refused send). The channel copy carries an absolute served url, so a stored
data:image/*image reaches the medium from the deployment’s own/api/interactions/media/<id>route rather than as inline bytes — a channel ask that submits adata:image therefore needs the deployment’s public base URL configured, exactly as a media notification does. - Privacy. A remote
httpsimage is fetched directly by the reader’s browser, which discloses the reader’s IP (and request timing) to the image host. An agent that must avoid that discloses nothing by submitting adata:image/*URI — the deployment stores it by reference and serves the bytes from its own/api/interactions/media/<id>route, so the reader’s browser only ever contacts the deployment. - Validated before anything is stored. Invalid media — an empty list, a
non-
http(s)link, an image url that is neitherhttps, adata:image/*URI, nor a same-origin/api/interactions/media/<id>reference, an over-cap url, data URI, caption, or total, a blank caption, or more thanMEDIA_MAX_ITEMSitems — raises loudly when the question is built, before any state is written.
External questions
Anexternal question requires a link. A string link is a template carrying the
literal {callback_url} placeholder; a callable link receives the callback URL,
builds the external resource, and returns its final URL. The returned answer is the
payload the callback delivered — a JSON POST body, or the query params of a
GET confirm flow — validated against the question’s schema when one is declared.
ask_external packages this as a transformer extension:
it wraps a tool that builds an external resource and drives the whole flow. The
wrapped tool takes a callback_url parameter and returns the URL to visit.
A verifier binds a registered webhook verifier
to the callback so a signed server-to-server answer is authenticated before it is
recorded. It is the same {"verifier": <name>, "config": {...}} binding a hook topic
uses (the contract’s TopicVerifierBinding), whether it is passed to ask(verifier=...)
or bound as ask_external’s author config:
ValueError) and when
the manifest loads (ask_external’s config); a stored binding that does not validate
answers every callback 500 and records nothing.
The callback URL and its pin
Every callback URL the platform mints has one shape:{INTERACTIONS_PUBLIC_BASE_URL}/api/interactions/callback/{ticket}. A plugin reads the
prefix from tai42_app.interactions.callback_url_prefix() (None when no public base is
configured). Every component that POSTs to a stored callback URL — the platform’s own
inbound answer ladder, the web channel’s answer and reaction doors, a tool that answers a
question it opened — first pins it with tai42_kit.interactions.pin_callback_url(url, prefix=...): only the exact minted shape passes (same scheme, host and effective port,
no userinfo, query or fragment, one ticket segment), and anything else raises
CallbackUrlRefusedError with a reason (host_mismatch, path_mismatch,
malformed_url) and the target host — never the URL, which carries the ticket. A
deployment that changes INTERACTIONS_PUBLIC_BASE_URL while questions are open refuses
their callbacks loudly.
Delivering to a channel
By default a pending question surfaces on the authenticated stream — the Studio inbox. Passingchannel="<name>" to ask hands delivery to a registered
channel plugin (Telegram, Slack, SMS/WhatsApp) instead: the question mints a
public callback ticket exactly like an external ask, the plugin pushes it to
the person on that medium, and the reply comes back through the same callback
door. The asking tool blocks exactly as before — only the delivery leg changes.
An unknown channel name, or a delivery failure, raises loudly before the run
would wait on an answer that can never arrive.
A channel’s deliver returns the address the question went to and its message ids, and
the question is recorded in the participant’s conversation thread as a send
record when the channel has a bridge route for
that identity. The record ends failed when every attempt was refused, outcome_unknown
when an attempt timed out and none succeeded, with a WARNING that the participant may have
the question twice when an attempt timed out before one succeeded, and delivered once
the participant’s reply over the channel lands. For a sensitive question the record keeps
that the reply came, when, and its message id, never the reply’s body, so the transcript,
the record views, message search and a quote of that reply show no body. An operator’s
answer through the answer door, or a caller resume, leaves the record’s delivery state as it
was and shows no reply on it.
The platform never hands a channel a question whose deadline has already passed: the
delivery fails with a non-retryable error (“already timed out … nothing was sent”), the
question is pruned and the interactions_delivery_failed event fires — checked again after
every retry backoff, so a deadline that passes mid-retry is refused before the next send.
A reply that arrives after the question is already gone or expired is not lost:
with a channel route bound it enters the conversation as an ordinary inbound
message; with no bound route it is logged and dropped.
A channel may render a select question natively — WhatsApp, for instance,
shows the options as tappable reply buttons or a list, and past the medium’s
option cap it falls back to a numbered text prompt. How the options render is the
channel’s choice; the answer contract is unchanged. However the person
responds — tapping a control or typing — the answer is the option text,
exactly as a typed reply would deliver it, and it validates against the
question’s options the same way.
A text question may also carry options as suggested replies — a tappable
shortlist offered alongside the free-text box. Unlike a select’s options they
constrain nothing (a text answer still accepts any string): a tap simply submits the
option’s own text as the free-text answer. A channel that advertises interactive
support renders them however it chooses; one that cannot falls back to the plain
prompt.
A form question rides a channel only when that channel advertises
supports_form_delivery; a form sent to a channel without the flag raises loudly,
naming it, before anything is sent. The person fills it on that channel’s own form
surface — the channel capability matrix
records what each renders — and the typed answer returns through the callback door,
validated against the schema exactly as the in-client door validates it.
Because a channel form is answered on the server-rendered callback page, its
schema must fall in a renderable subset, checked when the question is asked —
before anything is stored. The root is {"type": "object"} with a non-empty
properties map; every property is a scalar (string, boolean, integer, or
number) or an array whose items are strings (a
multiple-choice field); an enum — a non-empty list of
strings — is allowed on a string property or on an array’s string items; and
required, when present, names only declared properties. A property that is neither a
scalar nor an array-of-strings — a nested object, an array of non-strings, a bare
anyOf/oneOf/$ref, a missing type — is refused with a ValueError naming the
property, while an extra constraint keyword alongside a renderable type is not
rendered but is enforced when the answer is validated. A date property may also carry
the date-constraint keys, and any property a
visibleWhen predicate; both are validated here and enforced
on the answer. A form on the default inbox surface keeps full JSON-schema freedom; the
subset binds only a channel-delivered form.
A string property may also carry a format of date, time, date-time or
email; any other format value, or a format on a non-string property, is refused,
naming the property. The answer door validates the value’s shape: date is YYYY-MM-DD (a
real calendar date), time is HH:MM or HH:MM:SS, date-time is an RFC 3339
timestamp (a mandatory Z or ±HH:MM offset), and email is one @, a dot in the
domain, and no whitespace (^[^@\s]+@[^@\s]+\.[^@\s]+$). A format alongside an enum is
allowed — the enum’s choice control is rendered and the format is still validated on
the answer.
Each surface renders a format string as its medium’s native control where one
exists, and as a plain text input where none does. Where the shape is checked is set out in
Shapes are caught inside the form; the answer door
always checks it again:
¹ The callback page renders the Telegram web-app button, the external-link door, and
the web channel — each answers on the same server-rendered form page. A
date prefill
must be YYYY-MM-DD and a time prefill HH:MM; a prefill a native control cannot
display (a time carrying seconds on Slack, say) is refused when the form is built.
Shapes are caught inside the form
A field’s declared shape (required, pattern, format, minLength/maxLength,
minimum/maximum, minItems/maxItems, the date constraints) is checked inside the form
wherever the surface can, so the person sees a plain sentence under the field before the
submit leaves it. Whatever a surface does not check itself, the answer door checks on submit,
and it checks every shape again in any case: it refuses a broken answer with the platform’s
sentence for the first failing field, naming that field. Where each shape is checked:
The sentence the person reads depends on the surface:
- WhatsApp, the callback page, the web widget and Studio show the sentence the sender set
as
wording.form.fields[<name>].invalid: their own words, in the end user’s language, used for every shape error on that field but never for a missing required value. A missing required value reads the sender’swording.form.fields[<name>].requiredinstead, on WhatsApp (drawn by the data endpoint, for a value carried from an earlier screen; the control on the current screen checks its own), the callback page, the web widget and Studio (both through the Studio SDK); without it each surface shows its own word, and where the browser’s or the vendor’s own native check runs, that check’s own words show. Without theinvalidsentence, WhatsApp shows its own short word for the broken shape, the callback page shows the platform’s sentence naming the bound (Enter at least 10 characters), and the web widget and Studio, which check the form with the Studio SDK, show its own words (must be at least 10 character(s)); an unavailable day on the callback page shows the page’s own line. WhatsApp caps theinvalidandrequiredsentences at 30 characters on a text field or text area and 80 on a calendar or choice control; a longer one is refused at deliver, naming the field. WhatsApp’s line above a refused form is the sender’swording.form.error_linewhen set, else its own Label: sentence list. - Slack pins the door’s sentence for the failing field under it (Enter a valid email
address), wrapped by the sender’s
wording.answer.rejectedwhen set.

The callback page showing the platform's sentence under a field whose value is not an email address.
minimum/maximum (or their exclusive forms) that no value lies within (for an
integer, no whole number, as in minimum: 1.2, maximum: 1.8), a minLength above
maxLength and a minItems above maxItems are refused when the form is asked, naming the
property, on every channel and for a form answered in the in-app inbox.
A form whose schema breaks the JSON Schema rules is refused when the form is asked, naming the
property and where in it the value sits, on every channel and for a form answered in the
in-app inbox. That covers a bound that is not a number, a negative minLength, a pattern
or patternProperties key that does not compile (one too large or too deeply nested to
compile included), patternProperties keys that cannot be combined into the one expression
additionalProperties checks, and a number that is not finite (Infinity, NaN) anywhere in
the schema. A free or external ask’s schema is held to the same rules. A $schema that is
not a URI string is refused, at the root or on any subschema; a subschema naming another
dialect is held to that dialect’s rules.
References follow the same rules. A $ref or $dynamicRef must resolve to a valid subschema
inside the schema itself (#, a #/… pointer, a #anchor, or an absolute reference under the
schema’s own root $id), or into a JSON Schema meta-schema by its URI. A reference naming any
other schema is refused, since no schema is ever fetched, and so is a cycle of references that
never descends into the answer. A #/… pointer indexes an array only with 0 or a whole
number without a leading zero (-1, +1 and 01 are refused). A pydantic model’s
$defs/$ref resolve this way.
A schema is one resource. A $id below the root, or an anchor name declared twice, is refused.
So is a root $id (or draft-04 id) that is the URI of a JSON Schema meta-schema, with or
without a trailing #, since the schema would take that meta-schema’s place; this includes a
meta-schema pasted as it is. When the root has a $id, a $dynamicAnchor below the root is
refused if a reference into a meta-schema would jump back into the schema at it: its name is
one the part of the meta-schema the reference leads to (or any part that part refers to) uses
for its own $dynamicRef (meta in the draft 2020-12 meta-schema and its core, applicator,
content and unevaluated vocabularies), or the name the reference itself points at, such as
https://json-schema.org/draft/2020-12/schema#meta. A reference to a part that never refers
to the name is accepted: draft-07, the 2020-12 validation vocabulary, or a pointer such as
https://json-schema.org/draft/2020-12/meta/core#/$defs/anchorString.
A form may not name a top-level property or a display slot __proto__: JavaScript reads that
name as an object’s prototype, so a form drawn in a browser cannot keep it. Such a form is
refused wherever it enters: when it is asked (on every channel and for a form answered in the
in-app inbox), when it is sent in a notification, and when it is carried in a conversation
answer part. It is the only reserved name; constructor, toString and other names are
allowed.
When you don’t need to wait for the answer, send the form as a
notification instead: notify_user with a
schema delivers the same schema-rendered form and returns immediately — no ticket,
no expiry, no validation loop — and the submission enters the conversation as an
ordinary message from the person. The ask is for answers the caller blocks on; the
ask-less form is for submissions that are welcome whenever they arrive.
For channel authors
A channel plugin reads the platform’s rules from their owners instead of copying them:- Identity hint — a channel may declare a
ClassVar identity_hint: str: one plain sentence telling an operator what to enter as a conversation route’sour_identityon this channel (which address of the medium clients message). It is read withgetattr(channel, "identity_hint", None); absent means no hint. A declared value must be a non-blank string, and registering a channel that declares anything else is refused. The route form andtai channels list(GET /api/channels) show it. - Callback-link formats —
tai42_contract.interactions.CALLBACK_LINK_FORMATS(confirm,external) are completed by opening the callback link alone; every other format expects a reply the channel correlates. - Reaction events — a channel routes a form reaction to
tai42_app.interactions.reactas a typedFormReactionEvent:FieldChangedEvent(field=...),PageAdvancedEvent(page=...)orSubmittedEvent(). The HTTP react doors take the same event as JSON; an unknownkindor an extra key is refused400. - Form vocabulary —
tai42_kit.interactionsexports thevisibleWhenoperators and evaluator (evaluate_visible_when,hidden_fields),RADIO_OPTION_THRESHOLD,DATE_CONSTRAINT_KEYSandFORM_WEEKDAYS, so a channel shows, hides and draws a form exactly as the answer check enforces it. - Inbound media faults —
tai42_kit.net.media_faults: catchMEDIA_FETCH_AND_INGEST_FAULTS, re-raise whenmedia_fault_is_transient(exc)(so the vendor redelivers), else reject withmedia_rejection_reason(exc);inbound_media_kind_for_mime(mime)maps a vendor mime type to the media kind. - Entry params —
tai42_kit.conversations.entry_params.bound_entry_params(params, mode=..., door=...)beforeaccept: a door that answers a waiting caller passesREFUSE(the bound error is the caller’s), a webhook door whose vendor redelivers a5xxpassesDROP(the set is dropped, logged atWARNINGand counted, and the turn proceeds).put_entry_paramdrops one over-cap value the same way. - Form shapes —
tai42_app.interactions.form_shape_errors(question, values, fields=..., partial=...)returns oneFormShapeError(field,kind,params,message) per failing field, judged on the answer door’s own schema;fieldslimits it to one step andpartialleaves unfilled fields alone.SHAPE_KEYWORDS,FormShapeKindandFormShapeErrorlive intai42_contract.interactions, and the per-format regexes intai42_kit.interactions.FORMAT_PATTERNS, so a channel draws and checks a shape exactly as the door does. - Deadlines — no expired-delivery check in
deliver: the dispatcher never hands one.
Form capabilities per channel
Beyond the scalar fields andformat controls above, a form’s richer capabilities —
multiple choice and radio,
date constraints, conditional fields,
display blocks and a review page, and
reacting while open — each draw on every surface that
can render them, and degrade by a documented rule (never silently) where a surface
cannot. A capability a surface cannot draw either renders by the stated degrade or,
where there is no honest approximation, refuses loudly at the ask door.
The documented degrade rules:
- Slack has no date-control bounds.
minDate/maxDateandunavailableDatesare not drawable on its datepicker, so the author’s constraint is enforced on submit (a per-field error keyed to the field), and re-rendered reactively where the form also reacts. The author declares the constraint once; the platform guarantees enforcement, even where the control cannot visibly prevent an out-of-range pick. - A native HTML date input cannot disable arbitrary individual days. It draws the
minDate/maxDatebounds it can and enforcesunavailableDateson submit; the same enforcement guarantee holds. - Slack has no native conditional field. The platform re-renders the modal (a view update) to show or hide the field — this uses the channel’s reactive transport, never the consumer’s reaction handler, so it is independent of whether the form reacts. Where a surface offers neither a client-side conditional nor a reactive transport, the documented degrade shows every field and the predicate still gates submit through the effective-answer rule.
- A surface that cannot draw an image degrades a display block to its
alt/body text, never dropping it. - SMS has no form surface. A form question (and a reacting form) to an SMS channel is refused loudly at the ask door, before anything is sent.
When a channel reply doesn’t match
On a chat medium the person can type anything, so a channel-delivered ask may receive a reply the answer door rejects (a400 — the reply doesn’t fit the question’s
format). What happens next is the ask’s on_mismatch policy, set per ask by the
tool author:
retry(the default) — the ask stays parked and the person is told what’s expected, so they can answer again in place. The sender’swording.answer.rejectedreplaces the platform’s built-in retry line with its own text, andwording.answer.rejected_finalthe line sent when a refusal also closes the question. A{reason}token in either is filled with the door’s rejection reason by a plain substitution, and is empty when the door gave no reason. WhatsApp’s re-sent form reads the sender’swording.form.error_linein that place when set. A channel that owns its own correction surface renders the door’s specific reason itself.bridge— an unmatched reply is treated as a digression: the ask stays parked with no notice, and the reply is handed to the conversation as a fresh routed turn so the route’s target handles it. The ask then ends only by a real answer or its timeout, never by unmatched input.wording.answer.rejectedis not sent under this policy — a digression never notifies.
Enrichment on the answer
When the person answers by tapping a control the channel offered, the tap may carry channel-specific context back with the answer — the tapped option’s author-setid, a template button payload, a referral. A channel surfaces it as opaque
params on the answer (for example Slack forwards a tapped reply’s id as
params.reply_id; WhatsApp surfaces its tap enrichment on the bridge path
instead), which the asking run reads beside the answer. It is the
answer-path counterpart of the same enrichment a
bridged conversation turn carries: bounded as
transport, carrying no trust or meaning the platform attaches — a plain typed
answer carries none.
Forms that react while open
A form can react while the person is filling it — recompute a value, replace a field’s choices, show a per-field error, or fill a display slot — by naming a reaction handler at ask time. The handler is a registered tool the consumer owns; the platform runs it and validates what it returns, naming no consumer concept. Two fields turn a form into a reacting one, both optional and defaulting to today’s static form:reaction_tool— the name of a registered tool run whenever a declared trigger fires. It runs throughrun_toolunder the park’s rebound identity and state context (the same the continuation uses), so a reacting form must bemode="async"withanswer_format="form"— the request raises loudly otherwise.reactions— declares WHEN it reacts, as plain renderer-readable form description. Three event kinds, each absent by default:field_changed— the input fields whose change triggers a reaction;page_advanced— the page titles whose advance triggers one;submitted— whether the submission is checked by a reaction before it is accepted.
reaction_tool with no trigger, or a reactions block with no handler, is refused
— the two are halves of one declaration.
The handler contract
The handler receives exactly three things: theinteraction_id, the event (its
kind and the field or page it concerns — the contract’s FormReactionEvent, received
as its JSON form), and the partial values filled so far.
It returns a form update — validated against this form’s own schema before it
reaches the surface:
A computed total is not a special case — it is a
display slot the reaction fills.
A reaction calls its handler with three arguments (interaction_id, event, values)
and reads its answer as a form update. A handler whose own arguments or answer have
another shape is adapted by its preset: an input_schema that
declares the three arguments, an input_map
that turns them into the handler’s own arguments, and an output_map that returns the
form update. A base tool that takes a preset’s input_schema as its payload argument
needs no input map. A saved flow is one: it takes its inputs as
flow_graph_kwargs and answers with its run envelope (status, result,
missing_results), which is not a form update. So a flow used as a reaction_tool
carries a schema and an output map on its preset: an input_schema for
interaction_id, event and values, which reach the flow as its
flow_graph_kwargs, and an output_map that picks the update out of the envelope —
.result.<surface>, where <surface> is the result surface that holds it. Without the
map the reaction is refused for its unknown keys, and the person sees the surface’s
error.
Reaction-fed choices
A reaction may replace a field’s choice list while the form is open (the slots for a date just picked, say). Such a field is named inreactions.choices. Because its
choices are no longer the ones the form was sent with, the platform’s submit check
validates its type only, never membership in the send-time list — membership is the
consumer’s, checked at the submitted event. A form declaring any choices field
therefore must also set submitted: true, or the request is refused loudly: an
unvetted reaction-fed value always meets a consumer submit check.
A unique page title
Apage_advanced trigger keys a page by its title, so a title shared by two pages
names an ambiguous advance. A page_advanced trigger on a form whose pages repeat a
title is refused at the ask door — give each page a unique title.
The deadline, and failing loudly
The handler is bounded by a deadline (INTERACTIONS_REACTION_DEADLINE_SECONDS, default
10) at the single reaction chokepoint. A handler that raises, times out, or returns
an update naming a field or slot the form does not declare surfaces loudly — the
person sees the surface’s error and the server logs it — never a stale or silently
dropped value. A reaction is stateless for the durable record: it never records an
answer, never resolves the interaction, and never rewrites the stored request. The
half-filled form lives on the surface until submission; the submitted event is where
the consumer accepts or refuses the whole form. The server runs the submitted check
on every answer door before it records — the authenticated answer door, the channel
callback, and a caller resume alike — so a surface that runs it first only shows errors
early and can never bypass it: the handler’s per-field errors keep the form open, and only
a clean run records the answer.
How a reaction reaches the handler, per surface
A reaction travels to the one chokepoint by the same door that answers the form on that surface, by that surface’s own correlation — the reaction door inherits the answer door’s model and adds nothing:- Web chat — the web plugin’s own session-authenticated, same-origin door, a sibling of its answer door; the ticket is kept server-side and never reaches the browser, and that door forwards the event server-side to the interaction’s react door.
- Ticket page (the external-link page, and the webview a messaging channel opens) — the ticket in the page’s address is the capability, so the page posts to the ticket’s react door.
- In-app (
channel=None) — the authenticated react door, a sibling of the authenticated answer door, under the same correlation and audience gate. - A messaging channel — the vendor request authentication the channel already relies on inbound (WhatsApp’s signed, encrypted data-endpoint request and the per-send flow token bound to the one interaction; Slack’s signed request and view metadata).
- SMS — cannot react; a reacting form is refused at the ask door.
Security
The reaction is reachable by whoever may answer the form on that surface, by that surface’s own correlation, and by nothing weaker — it inherits the answer door’s model of that surface and adds nothing of its own. Only the handler the asker named at ask time runs, under the asker’s rebound identity; the caller chooses neither the tool nor the identity and supplies only this form’s field values. A reaction is a read-and-compute step — what the handler reaches is what that identity reaches, exactly as the continuation does, and effects belong to the continuation after the answer, not to the reaction. What comes back is only the validated form update, bounded by the same size and depth caps and request limits the answer door has.WhatsApp data endpoint (operator setup)
A reacting form, or one carrying a shape only the data endpoint can check, is published on WhatsApp as a native Flow with a data endpoint Meta calls as the person fills it. The endpoint decrypts each request, so before such a form can be published the operator provisions the vendor’s key material (loud failure when any is missing — such a form never publishes silently inert):- generate a business RSA-2048 key pair;
- set the private key as the secret
CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY(withCHANNEL_WHATSAPP_FLOW_PRIVATE_KEY_PASSPHRASEif the PEM is passphrase-protected) — it is never committed, and a fork configures its own; - set
CHANNEL_WHATSAPP_FLOW_ENDPOINT_URIto the public HTTPS URL of the plugin’s data endpoint ({public base}/api/channels/whatsapp/flow-data); - register the matching public key for the sending number — through the plugin’s
admin-only
POST /api/channels/whatsapp/flow-encryptionroute on the running server, which derives it from the private key and registers it, so the operator copies no key material by hand.
The doors
The interactions surface exposes nine HTTP doors. Five are authenticated and four are public:
Publicness is an access-control configuration decision,
not a property of the route code.
Reading the stream
GET /api/interactions/stream is a tail-only SSE feed: on connect the server
starts from the events-stream tail and sends only live add/answered/removed
frames from that point on. It carries no historical backlog — a freshly opened
client seeds its pending list from the paged GET /api/interactions door, then
overlays the live tail.
Every event frame carries an SSE id: — the events-stream message-id, a
monotonic cursor. A client that reconnects sends the last id it saw back as the
standard Last-Event-ID request header (a ?last_event_id= query parameter is
accepted as a fallback for a proxy that strips the header), and the server resumes
the tail after that id. So an event that landed during a brief disconnect — a
card’s answered frame included — is delivered on reconnect instead of being lost
in the gap: a card the client was tracking converges to answered rather than
vanishing.
The resume window is the events stream’s own bounded recent-events retention. When
the id is present but cannot cover the gap — malformed, or already aged out of that
window — the server falls back to the tail end and logs the fallback, so a
genuinely lost resume is never a silent skip; the client’s connect-time refetch of
the paged pending base reconciles the rest. An absent id is a normal fresh
connect, not a lost resume: the server tails from the end and the same paged base
seeds the pending state, so this case is intentionally not logged.
Security in brief
- The ticket is the capability. It is a high-entropy bearer token that expires
on its TTL; single use is enforced by an answered-state guard, and a provider
retry against a live ticket gets an idempotent
200 already_answered. - GET never mutates. Link scanners prefetch URLs, so the GET door serves a byte-constant confirm page — or, for a channel-delivered form, a page rendered from the stored schema — whose form POSTs back; the human’s submit performs the claim.
- Uniform 404 and no reflected input. Unknown, expired, and pruned tickets return the same response; callback pages interpolate no request-derived value.
- Signature verification runs in core. Bind a webhook verifier to an external question and the POST door verifies the raw body before the answer is accepted — failing closed.
External questions require
INTERACTIONS_PUBLIC_BASE_URL (an https:// origin,
since the callback URL is a bearer capability) and Redis 7.0+. Callback payloads
arrive through an unauthenticated door, so every stream consumer must treat them
as untrusted.ask across the
answer formats and wires an ask_external callback end to end. See
notifications for the ask-less form — the
non-blocking counterpart to a form ask — the
HTTP API reference for the interaction routes, and the
CLI reference for tai interactions.
