Skip to main content
An interaction pauses a run to ask a person a question and blocks until they answer or the question times out. The 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 the timeout, 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_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.
  • async parks the caller. ask records — 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.
An async park always carries a deadline: 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.
  • resume resumes 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 the answer of ask_result, with reply_to and params null.
Either way the expiry is stated once as an 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 async ask 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 async ask 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’s expiry_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).
The fifth format is 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

A form 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.values prefills top-level properties with known answers (keyed by property name), so the person confirms or edits rather than typing from scratch. data.options supplies per-send choice lists (keyed by property name) that replace a string property’s enum for this one send — each option a value (what the answer carries), an optional label (the text shown in its place), and an optional description (a second line drawn with the label) — so a variant needs no republished form.
  • pages — an ordered list of pages, each a title and the property names it groups, splitting a longer form into steps. Every top-level property appears exactly once across the pages; an absent pages is a single page.
A field can carry a second line of its own: the standard JSON-Schema 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.
The prefilled 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 an array whose items are a string, optionally with an items.enum (or a per-send data.options list) for the choices:
A single-choice field stays a 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 — a string 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 — inclusive YYYY-MM-DD bounds.
  • unavailableDates — a list of excluded days, each a YYYY-MM-DD date or a lowercase weekday name (monday … sunday) excluding that weekday.
A date range is two ordinary date fields, never one combined value. The end field names its start field in rangeStart and carries the span bounds minDays / maxDays (inclusive day counts); each field’s answer stays the plain date string its type declares:
Each end of a range of two 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.
The answer door enforces the ordering (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 date ends 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 date end and one date-time end, either way round) is ordered by each value’s written date, and its span counts the days.
  • Two date-time ends 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.
The web widget, Studio and the callback page check the order inside the form as well, except for two 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 a visibleWhen 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 — a heading, a body, or an image — through its optional display list, and may be a terminal review page:
A 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’s answer_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 showing a question with its attached images and links rendered above the answer controls.The Interactions inbox showing a question with its attached images and links rendered above the answer controls.

The Interactions inbox rendering a question's media — images and links shown above the answer controls.

Each item is a MediaItem — a kind, a url, an optional caption, and (for a document) an optional filename:
  • kind — one of five. The file kinds image, document, video, and audio carry a fetchable body and render as the matching bubble (an image inline, the others as the matching file element); a link renders as a labelled anchor the person clicks through. In the Studio inbox a document is a download link labelled by its filename or caption, and audio/video play 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 absolute https URL or a same-origin /api/interactions/media/<id> reference to media the deployment serves by id; image additionally admits an inline data:image/* URI (that inline form is image-only — a data: URI on any other kind is refused). A link is an absolute http(s) URL. Remote file media is https-only: the inbox content-security policy admits https:, data:, and same-origin sources but not http:, so an http: 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 an http: 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 a document download; it is meaningful only on a document item 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 a data: image therefore needs the deployment’s public base URL configured, exactly as a media notification does.
  • Privacy. A remote https image 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 a data: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 neither https, a data: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 than MEDIA_MAX_ITEMS items — raises loudly when the question is built, before any state is written.

External questions

An external 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:
A binding of any other shape is refused when the ask is made (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. Passing channel="<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’s wording.form.fields[<name>].required instead, 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 the invalid sentence, 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 the invalid and required sentences 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’s wording.form.error_line when 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.rejected when set.
A callback form with an Email address field holding alice@ and the sentence Enter a valid email address under it.

The callback page showing the platform's sentence under a field whose value is not an email address.

A number’s 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’s our_identity on this channel (which address of the medium clients message). It is read with getattr(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 and tai 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.react as a typed FormReactionEvent: FieldChangedEvent(field=...), PageAdvancedEvent(page=...) or SubmittedEvent(). The HTTP react doors take the same event as JSON; an unknown kind or an extra key is refused 400.
  • Form vocabulary — tai42_kit.interactions exports the visibleWhen operators and evaluator (evaluate_visible_when, hidden_fields), RADIO_OPTION_THRESHOLD, DATE_CONSTRAINT_KEYS and FORM_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: catch MEDIA_FETCH_AND_INGEST_FAULTS, re-raise when media_fault_is_transient(exc) (so the vendor redelivers), else reject with media_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=...) before accept: a door that answers a waiting caller passes REFUSE (the bound error is the caller’s), a webhook door whose vendor redelivers a 5xx passes DROP (the set is dropped, logged at WARNING and counted, and the turn proceeds). put_entry_param drops one over-cap value the same way.
  • Form shapes — tai42_app.interactions.form_shape_errors(question, values, fields=..., partial=...) returns one FormShapeError (field, kind, params, message) per failing field, judged on the answer door’s own schema; fields limits it to one step and partial leaves unfilled fields alone. SHAPE_KEYWORDS, FormShapeKind and FormShapeError live in tai42_contract.interactions, and the per-format regexes in tai42_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 and format 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/maxDate and unavailableDates are 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/maxDate bounds it can and enforces unavailableDates on 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.
Per the effective-answer rule and the single answer facet, a degraded surface that draws a hidden field changes no outcome: the value is dropped on submit. The web chat draws every capability above — it is a full form surface, not a degraded one.

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 (a 400 — 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’s wording.answer.rejected replaces the platform’s built-in retry line with its own text, and wording.answer.rejected_final the 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’s wording.form.error_line in 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.rejected is not sent under this policy — a digression never notifies.
Both policies keep the ask alive; they differ only in whether an off-format reply re-prompts the person or is routed onward as a new turn.

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-set id, 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 through run_tool under the park’s rebound identity and state context (the same the continuation uses), so a reacting form must be mode="async" with answer_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.
A 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: the interaction_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 in reactions.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

A page_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.
A surface advertises the reaction capability only when it really routes a reaction end-to-end to that one chokepoint.

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 (with CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY_PASSPHRASE if the PEM is passphrase-protected) — it is never committed, and a fork configures its own;
  • set CHANNEL_WHATSAPP_FLOW_ENDPOINT_URI to 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-encryption route on the running server, which derives it from the private key and registers it, so the operator copies no key material by hand.
A static WhatsApp form whose shapes the controls check themselves never touches this key material. See the channel-whatsapp plugin page for the full operator steps.

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.
The use-interactions guide walks 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.