Skip to main content
Channel delivery contracts. A 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. 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
The interactions answer door rejected a forwarded answer on a status the shared ladder cannot resolve. The unresolvable statuses are 401/413/5xx or a transport fault. Raised by 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
Delivers one question to a human on a specific medium. A channel plugin registers an instance under a name (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
Push 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
Send a fire-and-forget message, or raise 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
One question handed to a channel for out-of-band delivery. 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
Raised by a 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
A permanent refusal of the input’s shape or content by the channel. The input is contract-valid but the medium cannot render it BY NATURE — e.g. a 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
One fire-and-forget message handed to a channel. A notification carries no interaction, no ticket, no 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
A pre-approved, named template a channel sends outside its freeform window. Some media only accept arbitrary text inside a bounded conversation window (e.g. WhatsApp’s 24-hour customer-service window); outside it, the sole accepted send is an operator-authored template referenced by 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 a link); None when 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: a QuickReplyButtonParam payload or a UrlButtonParam url suffix); the i-th entry parameterises the i-th button, at most TEMPLATE_BUTTONS_MAX. Empty when no button needs a runtime argument.
Attributes

Correlation

tai42_contract.channels.correlation.Correlation
The per-address record a channel keeps while ONE parked ask awaits a reply. When 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
Storage primitives ONLY for the one-pending-per-address correlation record — no policy. A channel that delivers 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
Reserve 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
Return the record held under 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
Drop any reservation held under 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
What the shared inbound-answer ladder decided for one inbound reply on a correlation key. A channel maps this to its own transport ack. Attributes

InboundAnswerResult

tai42_contract.channels.inbound.InboundAnswerResult
The result of one inbound-answer ladder run. 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
The context a bridged turn needs when a reply is not (or no longer) an answer. A channel hands one of these to 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
A tappable link action. Tapping OPENS 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
One titled section of a sectioned option list. 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
The runtime argument for one QUICK-REPLY button of a template’s buttons component. payload is the string the medium returns when the human taps the button. Frozen. Attributes

ReplyOption

tai42_contract.channels.options.ReplyOption
A tappable suggested reply. Tapping SUBMITS 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
The runtime argument for one URL button of a template’s buttons component. url_parameter is the dynamic suffix substituted into the button’s pre-approved URL. Frozen. Attributes tai42_contract.channels.options.check_footer
A footer is the short trailing line under an interactive message. None means none; a present value is non-blank and within NOTIFICATION_FOOTER_MAX_CHARS. Raises ValueError. Parameters

check_header

tai42_contract.channels.options.check_header
A header is a SINGLE display-media item shown above an interactive message. None means none; a present item is display media (image/document/video/audio), never a 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
The shared cross-field rules every option-carrying message carrier enforces. 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 media OR a location (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.
  • options XOR sections — one choice surface (flat buttons OR a sectioned list).
  • schema is exclusive with both options and sections — one interactive surface (a form’s fields OR a choice list), never both.
  • header and footer compose an interactive message, so each REQUIRES options or sections present.
  • template is the standalone out-of-window send: exclusive with every other content and interactive field (media, location, options, sections, schema; and transitively header/footer, which require options/sections).
Parameters

check_options

tai42_contract.channels.options.check_options
List-level caps on a flat interactive option list. None means none; a present list is non-empty and holds at most 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
List-level caps on a sectioned option list. None means none; a present list is non-empty, holds at most 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
Deliver 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, where on_sent fires AFTER each accepted send and BEFORE the next goes out. On the native deliver_ordered path the whole batch is sent inside that one call and on_sent fires per index only AFTER it returns — so a durable caller that needs per-send ledgering interleaved with the sends must NOT rely on on_sent there; it should ledger inline (as the conversations machine does) rather than through this helper.
Parameters