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 thetimeout, then the typed answer returns (or the call raises).asyncparks the caller.ask_userrecords — 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
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 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.
Parking several at once
A run can raise more than one asyncask_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 asyncask_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’sexpiry_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).
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
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) and an optionallabel(the text shown in its place) — 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.
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’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.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.
Delivering to a channel
By default a pending question surfaces on the authenticated stream — the Studio inbox. Passingchannel="<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 (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, and today’s behaviour) — the ask stays parked and the person is told what’s expected, so they can answer again in place. The optionalmismatch_noticereplaces 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 ignoresmismatch_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_noticeis ignored 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 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.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.
