Channel pushes an ask question to a human on a specific
medium (Telegram, Slack, SMS, …) and bridges the human’s reply back into the
interactions store by forwarding it to the delivery’s public callback_url.
Channels are registered on the app handle (tai42_app.channels) by channel
plugins and looked up by name when ask is called with channel=....
Delivery either returns None (success) or raises
ChannelDeliveryError (any failure) — never a bool.
A channel also sends fire-and-forget notifications: notify pushes one
ChannelNotification to a human with no interaction, no ticket, and no
reply path, under the same loud-failure rule; it returns the per-message ids the
medium assigned the send (empty when the medium exposes none), never a bool.
NOTIFICATION_FOOTER_MAX_CHARS
tai42_contract.channels.options.NOTIFICATION_FOOTER_MAX_CHARS
NOTIFICATION_SECTIONS_MAX
tai42_contract.channels.options.NOTIFICATION_SECTIONS_MAX
OPTION_ID_MAX_CHARS
tai42_contract.channels.options.OPTION_ID_MAX_CHARS
TEMPLATE_BUTTONS_MAX
tai42_contract.channels.templates.TEMPLATE_BUTTONS_MAX
TEMPLATE_PARAM_MAX_CHARS
tai42_contract.channels.templates.TEMPLATE_PARAM_MAX_CHARS
AnswerForwardError
tai42_contract.channels.errors.AnswerForwardError
AppChannels.handle_inbound_answer WITHOUT releasing the
correlation, so the channel’s transport-level retry (the provider’s webhook
redelivery) re-runs the ladder and the answer is never silently lost. A channel
lets it propagate out of its inbound webhook so the provider redelivers.
Channel
tai42_contract.channels.protocol.Channel
tai42_app.channels.register); ask resolves it by name and calls
deliver after the interaction is persisted and its callback ticket is
minted. A channel never reaches the interactions store directly: the
human’s reply travels back through the delivery’s public callback_url.
A channel MAY advertise richer support with six OPTIONAL, class-level
capability flags — supports_media_notifications,
supports_template_notifications, supports_interactive_notifications,
supports_location_notifications, supports_form_notifications (all
five for notify) and supports_form_delivery (for deliver) — set
as plain class
attributes. They are a documented convention, NOT Protocol members: a
channel that supports the richer form sets the matching attribute to
True; a channel that omits it advertises no support (absent =
False). Because they are not part of the Protocol, a text-only channel
that declares none is still a valid Channel (both structurally and under
runtime isinstance). The ask/notify helpers read them defensively with
getattr(channel, "<flag>", False) and refuse the matching richer send to
a channel that does not advertise the flag: notify_user refuses a media,
template, options, sections, location or schema notification, and the
ask helper
refuses a form delivery, to a channel without the flag — so a channel
that reads only the plain fields can never silently drop the extra content.
A channel that does not advertise supports_form_delivery never receives
a form delivery, and one that does not advertise
supports_form_notifications never receives a schema notification.
A form channel MAY also declare one OPTIONAL method, validate_form_schema,
following the same convention as the capability flags — a documented member,
NOT a Protocol method, so declaring it never tightens the runtime structural
check. ask reads it defensively with
getattr(channel, "validate_form_schema", None) right after the generic
channel-deliverable subset check and, when present, calls
channel.validate_form_schema(schema, question) at ask-time, BEFORE any
state is written. It enforces the channel’s OWN ask-time-knowable form limits
(reserved property names, per-medium Block Kit / Flow caps, question-text caps)
over the schema AND the question text — limits the generic subset does not
know — raising ValueError naming the offending property/limit on a
violation, so a question or schema the channel could never render is refused up
front instead of persisting a question that only fails at delivery. A channel
that omits it advertises no extra ask-time limits; its delivery path still
refuses an unrenderable question or schema (a permanent
ChannelInputError). The SAME hook is reused for a form notification:
the notify helper calls channel.validate_form_schema(schema, message) with
the notification’s message as the question argument before the send, so
one declared method covers both the ask and the notify form surfaces.
A channel MAY also declare one OPTIONAL method, deliver_ordered(notifications),
for a NATIVE in-order batch (a bulk API, a transactional transcript append) — the
same documented-member convention as validate_form_schema and the capability
flags, NOT a Protocol method (so declaring it never tightens the runtime structural
check and a channel that omits it stays a valid Channel). It takes a
Sequence[ChannelNotification] and returns list[list[str]] — the per-message
ids in send order, one list per notification — sending strictly in order and never
reordering, skipping or parallelising; the FIRST failure raises
ChannelDeliveryError / ChannelInputError, with the accepted ids
named in the exception message (as the WhatsApp body-then-media send does). A caller
reaches the default sequential behaviour through notify_in_order, which
dispatches to deliver_ordered when declared and otherwise loops notify; a
channel that declares neither still delivers a batch one notify at a time.
Members
deliver
tai42_contract.channels.protocol.Channel.deliver
delivery to the medium, or raise ChannelDeliveryError.
Send the question to the resolved recipient — delivery.recipient
when set (after checking it against the plugin’s operator allowlist),
else the plugin’s operator-configured default — and arrange for
the reply to reach delivery.callback_url — either a tappable link
carrying the URL, or an inbound-route correlation the plugin stores.
Any delivery failure — an unreachable or rejecting medium, a recipient
outside the operator allowlist, a required credential or recipient not
configured, a bad send response — raises
ChannelDeliveryError; a plain return is the only success
signal. One send attempt only: retrying is the caller’s decision, never
an implicit loop here — the raised error’s retryable and
retry_after drive that decision, so a fault the medium can recover
from is classified rather than blind-retried here.
Parameters
notify
tai42_contract.channels.protocol.Channel.notify
ChannelDeliveryError.
No interaction, no ticket, no callback, no reply. Any delivery failure raises
ChannelDeliveryError; a permanent refusal of the input’s shape or
content (an input the medium cannot render BY NATURE) raises
ChannelInputError instead — retrying it cannot succeed. A return means
the medium ACCEPTED the message —
not that a human saw it — and yields the per-message ids it assigned this send
(several when the medium splits a long message, empty when it exposes no id),
which later correlate an out-of-band delivery receipt back to this send. One
send attempt only, no retry. A channel that cannot notify raises
NotImplementedError.
Parameters
ChannelDelivery
tai42_contract.channels.delivery.ChannelDelivery
callback_url is the public /api/interactions/callback/{ticket}
answer sink; the channel arranges for the human’s reply to reach it.
recipient is the OPTIONAL caller-requested address (chat id, phone
number, …): the channel plugin validates it against its operator-set
allowlist and refuses to send to an unlisted address; when omitted the
plugin sends to its operator-configured default recipient. It is an
address only, never a secret or credential.
media is OPTIONAL display media the channel renders alongside the
question (reusing MediaItem, the same shape and list-level caps the
ask REQUEST carries); a present list is non-empty. It is a pure enhancement,
NOT structure: a channel that renders only text simply ignores it and shows
the question, so it rides no capability flag and is never refused for a
channel that cannot render it. options is REQUIRED for select (the
answer set) and OPTIONAL for text as SUGGESTED REPLIES — a tapped option
submits its own text as the free-text answer; every other format carries none.
Attributes
ChannelDeliveryError
tai42_contract.channels.errors.ChannelDeliveryError
Channel when delivering a question fails.
Every failure mode — an unreachable medium API, a rejected send, a missing
credential, a misconfigured recipient — raises this single typed error.
deliver NEVER returns a bool and NEVER silently drops a message: an
undeliverable question is a loud failure, so the only success signal is a
plain return.
retryable classifies the failure for the caller’s retry decision: True
means transient (a medium 5xx, a rate limit, a transport fault or timeout)
and a fresh attempt may land. It defaults to False — a rejected recipient, a
bad credential, and any unrecognised fault fail on the first try rather than
being blind-retried. retry_after is the seconds the medium asked the
caller to wait (an HTTP Retry-After, say); meaningful only when
retryable is True.
Attributes
ChannelInputError
tai42_contract.channels.errors.ChannelInputError
data: image URL to a channel that sends only public https sources.
Retrying the same input can never succeed, so this is distinct from
ChannelDeliveryError (a delivery failure the caller may retry): the
operation door maps it to the client-error (400) class, never the retryable
503 a transient delivery failure earns.
ChannelNotification
tai42_contract.channels.notification.ChannelNotification
callback_url and
no deadline: the channel sends the message and nothing travels back.
recipient is the OPTIONAL caller-requested address (chat id, phone
number, …): the channel plugin validates it against its operator-set
allowlist and refuses to send to an unlisted address; when omitted the
plugin sends to its operator-configured default recipient. It is an
address only, never a secret or credential.
sender_identity is the OPTIONAL address to send FROM when the channel fronts
several operator identities: an internal routing control set by the sending side,
never caller-supplied, and an address only — never a secret.
message is the human-readable text, non-blank BY DEFAULT — EXCEPT it may be the empty
string "" for a CONTENT-ONLY send: a caption-less bubble that is just media or a
location, with no text carrier. The admissible states are “message non-blank” OR
“blank message WITH non-empty media OR a location”; a blank message with no
such content has nothing to deliver and is refused. (message stays REQUIRED — a
content-only sender passes "" explicitly — because every caller constructs it in code
with the text in hand.) An interactive surface (options, sections or schema)
REQUIRES a non-blank message — a choice or a form needs a prompt — so a content-only
send carries none; a template likewise rides a non-blank message.
The OPTIONAL richer-send forms reuse the same message as the human-readable
equivalent. media is display media the channel sends alongside the message (reusing
MediaItem, image/document/video/audio/link); a present list is non-empty.
location is a shared geographic point (LocationElement). template sends a
pre-approved ChannelTemplate for out-of-window delivery. options is a flat
list of tappable Option entries — a ReplyOption (a tap submits its text
as a visitor message) or a LinkOption (a tap opens its url) — at most
NOTIFICATION_OPTIONS_MAX. sections is the sectioned alternative: titled
OptionSection groups of reply rows (rows summed across sections stay within
NOTIFICATION_OPTIONS_MAX). header is a single display-media header and footer a
short trailing line, each composing an interactive message (they REQUIRE options or
sections).
The composition rules (check_interactive_composition): options XOR
sections (one choice surface); schema excludes both (one interactive surface); a
template is the standalone out-of-window send, MUTUALLY EXCLUSIVE with every other
content and interactive field; options/sections and schema MAY each combine
with media and location. A channel that does not advertise the matching capability
flag (supports_media_notifications / supports_location_notifications /
supports_template_notifications / supports_interactive_notifications /
supports_form_notifications, the OPTIONAL class-attribute convention documented on
Channel) never receives the matching field.
schema is the form answer schema for an ASK-LESS FORM: the channel renders
message as the form’s prompt and schema as the fillable form, and the
participant’s submission enters the conversation as a participant message — no interaction,
no ticket, no callback, the same inbound path a tapped option takes. A present
schema is a non-empty dict; its deep shape is the sender’s shared
channel-deliverable subset walk (the same split ChannelDelivery keeps),
never re-checked here. schema REQUIRES a non-blank message — a form needs
a prompt — and is MUTUALLY EXCLUSIVE with template and with options (one
message carries ONE interactive surface); it MAY combine with media. It rides
the supports_form_notifications capability flag, and a form channel’s OPTIONAL
validate_form_schema(schema, question) hook (see Channel) is reused
at notify time with this message as the question argument, so the
channel’s own form limits refuse an unrenderable form before the send. Some channels
also constrain WHEN a form may be sent: WhatsApp delivers a notify-form only inside
the provider’s customer-service window, and an out-of-window send fails loudly at the
channel — never silently downgraded.
Attributes
ChannelTemplate
tai42_contract.channels.templates.ChannelTemplate
name in an approved language — both required and non-blank.
The template’s runtime arguments are its NAMED components (never one flat positional list):
header_media— the media argument for a media HEADER component (a display item: image/document/video/audio, never alink);Nonewhen the template has no media header (or a static/text header needing no argument).body_parameters— the POSITIONAL body-text values substituted into the body’s placeholders in order; empty when the body has no placeholders. Typed values (currency, date-time) ride as their pre-formatted STRING here — the contract does not model the type.buttons— the POSITIONAL per-button arguments of the buttons component (TemplateButtonParam: aQuickReplyButtonParampayload or aUrlButtonParamurl suffix); the i-th entry parameterises the i-th button, at mostTEMPLATE_BUTTONS_MAX. Empty when no button needs a runtime argument.
Correlation
tai42_contract.channels.correlation.Correlation
ask is delivered on a medium whose reply arrives as a fresh
inbound message (not a tap on a signed link), the channel stores this record
against a channel-computed correlation key and, when the participant’s next reply
lands on that key, forwards it to callback_url (the delivery’s public
answer sink). interaction_id identifies the parked ask (carried into
operator alerts, never re-derived); ttl_deadline is the tz-aware instant
past which the pending ask is stale and the key may be reclaimed. One pending
ask per address: a channel reserves the key before delivering and drops it
once the reply is forwarded, withdrawn or expired.
Attributes
CorrelationStore
tai42_contract.channels.correlation.CorrelationStore
ask questions whose replies arrive as fresh
inbound messages keeps a Correlation per waiting address so the
participant’s next reply resolves the right parked ask. This port is the minimal
set/get/release surface over that store; the LADDER that interprets a
forwarded answer’s outcome (forward, retry-in-place, bridge) lives in core and
reads this port — it is not the store’s concern.
key is an OPAQUE channel-computed correlation key: it absorbs the divergent
per-channel shapes — a Twilio number pair, a Slack thread_ts, a Telegram
ForceReply message_id, a WhatsApp address — collapsing them to one string
the store never interprets. A channel with no correlated replies (a link-tap
or an external-answer medium) simply does not provide a store.
This is a STANDALONE optional port, NOT an extension of Channel: a
channel implements it separately (or not at all), and the core handler is
handed one explicitly rather than reaching it off the channel instance.
Members
set_correlation
tai42_contract.channels.correlation.CorrelationStore.set_correlation
key for entry with a ttl_seconds expiry, NX.
Returns True when the key was free and is now held; False when the key is
already held — the one-pending-per-address guarantee, so a second parked
ask for the same address never silently overwrites the first. ttl_seconds
bounds how long the reservation survives without a reply.
Parameters
get_correlation
tai42_contract.channels.correlation.CorrelationStore.get_correlation
key, or None when none is held.
A non-destructive peek: it neither drops nor refreshes the reservation, so
the handler can inspect the pending ask and decide the outcome before
releasing.
Parameters
release_correlation
tai42_contract.channels.correlation.CorrelationStore.release_correlation
key, idempotently.
A no-op when the key is already free (expired, forwarded, or never held),
so releasing twice — or racing an expiry — is never an error.
Parameters
InboundAnswerOutcome
tai42_contract.channels.inbound.InboundAnswerOutcome
InboundAnswerResult
tai42_contract.channels.inbound.InboundAnswerResult
outcome is the ladder’s decision. retry_reason and retry_field carry the
door’s OWN (already length-bounded) rejection message and the failing field name so a
channel that OWNS its correction surface (a re-opened WhatsApp Flow, a Slack modal’s
inline Block-Kit error) can render the door’s SPECIFIC message rather than a generic
line. Both are populated when the door rejected the answer’s content — on
InboundAnswerOutcome.RETRY_KEPT (either notice_owner variant) and on a
hard-mismatch InboundAnswerOutcome.BRIDGED — and are None on every other
outcome (no correlation, a clean forward, a gone-ask 404 bridge). A channel that
renders no correction of its own simply ignores them and maps outcome.
Attributes
InboundBridge
tai42_contract.channels.inbound.InboundBridge
AppChannels.handle_inbound_answer alongside
the correlation key and answer value. channel_id is the registered channel
name; our_identity and client_address are the conversation’s two addresses
(the operator identity the turn answers from, and the participant’s attested address /
thread); cap_key is the party the per-address turn cap holds accountable;
provider_message_id dedupes a provider redelivery at the conversation seam;
bridge_text is the channel’s faithful rendering of the participant’s message for a
bridged turn.
owns_retry_notice lets a channel OWN the participant-facing correction message on a
retryable rejection. The default (False) is that the ladder sends the generic
“that didn’t match, try again” notice on InboundAnswerOutcome.RETRY_KEPT.
When True, the channel’s correction surface IS a re-ask the channel renders off
RETRY_KEPT (a re-opened WhatsApp Flow, a Slack modal’s inline Block-Kit error), so
the ladder SKIPS its notice to avoid double-messaging — it still keeps the
correlation and still emits the operator event (tagged notice_owner="channel").
It applies ONLY to the retryable path: on a hard mismatch (a closed ask) the
channel’s re-ask surface is moot, so the ladder always sends the final “question is
closed” notice regardless of this flag.
params is the OPTIONAL opaque channel enrichment this inbound reply carries — the
ANSWER-path counterpart of a conversation entry’s params: a tapped reply id, a template
button payload, a referral, the reply-to context the participant quoted. The ladder threads it BOTH
ways with the same seam symmetry — forwarded to the ask’s callback door alongside the answer
(landing on params, read by
the asking flow beside answer) AND, when the reply is instead BRIDGED as a fresh turn,
passed to accept as its params — so enrichment is never dropped on either arm. The
SAME transport vocabulary (validate_entry_params) bounds
it; the platform attaches no meaning and NO TRUST. None means no enrichment.
Attributes
LinkOption
tai42_contract.channels.options.LinkOption
url (an absolute http(s) URL) in the human’s
browser — NO message is submitted, distinct from a ReplyOption. label is the
button text. The URL-button / call-to-action case. Frozen.
Attributes
Option
tai42_contract.channels.options.Option
OptionSection
tai42_contract.channels.options.OptionSection
title is the section header; rows are
its entries — a sectioned list holds ReplyOption rows ONLY (a tapped row submits its
text; a link action is a button, never a list row). A present rows is non-empty. Frozen.
Attributes
QuickReplyButtonParam
tai42_contract.channels.templates.QuickReplyButtonParam
payload is the string the medium returns when the human taps the button. Frozen.
Attributes
ReplyOption
tai42_contract.channels.options.ReplyOption
text as the participant’s next inbound message —
the quick-reply / list-row case, where the option’s own text becomes the turn. description
is an OPTIONAL secondary line a sectioned-list row renders under its text; a channel that
renders flat buttons (no descriptions) ignores it.
id is an OPTIONAL author-set stable identifier for the button/list row. When set, a channel
sends it verbatim on the wire and the participant’s tap echoes it back (a channel surfaces the echoed
id to the inbound turn as opaque enrichment — e.g. Slack forwards it as params.reply_id);
when None the channel mints its own id. Bounded by OPTION_ID_MAX_CHARS and a
single-line non-blank label — the strictest carrier’s rule. Frozen.
Attributes
TemplateButtonParam
tai42_contract.channels.templates.TemplateButtonParam
UrlButtonParam
tai42_contract.channels.templates.UrlButtonParam
url_parameter is the dynamic suffix substituted into the button’s pre-approved URL.
Frozen.
Attributes
check_footer
tai42_contract.channels.options.check_footer
NOTIFICATION_FOOTER_MAX_CHARS.
Raises ValueError.
Parameters
check_header
tai42_contract.channels.options.check_header
link (an
anchor is content, not a header). The item’s own url/kind shape is MediaItem’s
concern. Raises ValueError.
Parameters
check_interactive_composition
tai42_contract.channels.composition.check_interactive_composition
ChannelNotification and AnswerPart share these,
so the two can never drift. noun names the carrier for the raised messages
("notification" / "part"). Rules:
- message is non-blank BY DEFAULT, EXCEPT blank for a CONTENT-ONLY send — a blank message
carried by non-empty
mediaOR alocation(a caption-less image / a bare pin). - an interactive surface (
options,sections,schema) REQUIRES a non-blank message — a choice or a form needs a prompt — so a content-only send carries none of them. optionsXORsections— one choice surface (flat buttons OR a sectioned list).schemais exclusive with bothoptionsandsections— one interactive surface (a form’s fields OR a choice list), never both.headerandfootercompose an interactive message, so each REQUIRESoptionsorsectionspresent.templateis the standalone out-of-window send: exclusive with every other content and interactive field (media,location,options,sections,schema; and transitivelyheader/footer, which requireoptions/sections).
check_options
tai42_contract.channels.options.check_options
NOTIFICATION_OPTIONS_MAX entries. Each option’s own shape
(reply text / link label+url bounds) is the ReplyOption/LinkOption concern.
Raises ValueError.
Parameters
check_sections
tai42_contract.channels.options.check_sections
NOTIFICATION_SECTIONS_MAX sections, and its rows summed across every section
stay within NOTIFICATION_OPTIONS_MAX (one message never fans out an unbounded tap set).
Raises ValueError.
Parameters
notify_in_order
tai42_contract.channels.protocol.notify_in_order
notifications to channel STRICTLY in order, returning the per-message ids of each.
One list[str] per notification, in the same order.
The default sequential in-order primitive every channel “inherits” by a caller using
this helper — the place a Protocol default can actually reach a structural
implementer. When the channel declares the OPTIONAL deliver_ordered member (read
defensively with getattr, the platform’s established convention for optional
channel abilities) the batch is handed to it as a native ordered send; otherwise each
notification is delivered with one awaited notify before the next goes out. Either
way delivery is never reordered, never parallelised and never skipped, and it STOPS at
the first raise (ChannelDeliveryError / ChannelInputError) — the
caller learns how far the sequence got from on_sent (and, for a native batch, from
the exception naming the accepted ids).
on_sent(index, ids) is the progress hook a caller uses to record each accepted send
(a send ledger, say); it fires once per notification in send order. It is intentionally
the ONLY progress seam — a caller that needs work BETWEEN sends (a per-send lease refresh)
drives notify itself rather than routing through this helper, which cannot express a
pre-send hook.
Two honesty caveats a durable caller must weigh before relying on on_sent:
- This helper is NOT yet wired into the conversations delivery machine (which chunks and
ledgers each send inline in
tai42_skeleton.conversations.delivery). It is the documented in-order primitive, not the code path a durable ordered answer currently flows through. - Per-send timing holds ONLY on the sequential (
notify-loop) path, whereon_sentfires AFTER each accepted send and BEFORE the next goes out. On the nativedeliver_orderedpath the whole batch is sent inside that one call andon_sentfires per index only AFTER it returns — so a durable caller that needs per-send ledgering interleaved with the sends must NOT rely onon_sentthere; it should ledger inline (as the conversations machine does) rather than through this helper.

