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_user 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. To turn interactions on and answer one, follow Use interactions.

The loop

ask_user is the engine-agnostic author surface behind the contract’s AskUser 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 the typed answer returns (or the call raises).
  • async parks the caller. ask_user 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 it resolves — a human answers, or expiry_at passes with no answer — the run resumes on its own: a stored continuation runs under the asker’s identity (never the answerer’s), exactly once. On expiry the continuation receives a reserved interaction expired marker in place of an answer, so the run takes its expiry branch rather than a real answer. 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 checkpointerredis 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.

Parking several at once

A run can raise more than one async ask_user 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 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. A lone async ask is the one-ask case of this same path.

A flow or an agent parks

The resuming driver is engine-agnostic: a flow and an agent run are both drivers. An agent whose tool raises an async ask_user parks the whole agent run and resumes it on the answer or expiry exactly as a flow 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_user 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 a flow. 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 or flow turn 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 drives its expiry branch. The checkpoint retention is a different clock: the paused run’s checkpoint lives in the checkpointer’s own store, kept forever unless the deployment bounds it deliberately, after which a sweep reclaims the idle thread. The two must agree. If a paused run’s checkpointer evicts idle threads at a bounded retention horizon, 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. A keep-forever retention has no horizon and never refuses.

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, pays — 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) and an optional label (the text shown in its place) — 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.
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, date offers the two labelled choices this send provides, and the form reads as two steps. The same data and pages carry to every surface that renders the form.

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.
  • 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.

Delivering to a channel

By default a pending question surfaces on the authenticated stream — the Studio inbox. Passing channel="<name>" to ask_user 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 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); an enum — a non-empty list of strings — is allowed only on a string property; and required, when present, names only declared properties. A property without a scalar type — a nested object, an array, a bare anyOf/oneOf/$ref, a missing type — is refused with a ValueError naming the property, while an extra constraint keyword alongside a scalar type is not rendered but is enforced when the answer is validated. A form on the default inbox surface keeps full JSON-schema freedom; the subset binds only a channel-delivered form. 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.

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, and today’s behaviour) — the ask stays parked and the person is told what’s expected, so they can answer again in place. The optional mismatch_notice replaces the platform’s built-in retry line with your own text; a literal {reason} token in it is filled with the door’s rejection reason by a plain substitution (a notice without the token is sent verbatim). A channel that owns its own correction surface renders the door’s specific reason itself and ignores mismatch_notice.
  • 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 flow handles it. The ask then ends only by a real answer or its timeout, never by unmatched input. mismatch_notice is ignored 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 flow 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.

The doors

The interactions surface exposes six HTTP doors. Three are authenticated and three 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_user 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.