> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tai42.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Deliver a structured event to a conversation thread as a turn

> Run a structured event as a turn on an EXISTING thread of ``route_name``.

The thread must already exist: an event enters a conversation as a turn and never mints a
thread. Address it by the ``address`` its listing shows or by its ``thread_id`` (exactly
one). The turn runs the route's TOOL target AS the route's execution key — an
agent-target route is refused (``409``) since an event carries no rendered text. The
event is IDEMPOTENT on ``event_id``: a redelivery returns the original turn's
``message_id`` (``202``) and runs no second turn.

The answer is delivered by the TARGET route's door — a channel route texts the thread's
address, an api route POSTs the route's signed callback (a ``wait_seconds`` body field,
clamped to ``sync_wait_max_seconds``, returns a finished turn's answer inline in the
``200`` and suppresses the callback). Default is ``202 {message_id, thread_id}``.

This is a TRUSTED-integration door: an authorized writer may address ANY existing thread
of the route by ``thread_id`` (a channel participant's included), so a deployment grants its
write action to service principals, not to low-trust API keys.



## OpenAPI

````yaml /openapi.json post /api/conversations/{route_name}/events
openapi: 3.1.0
info:
  description: The operator HTTP surface served under /api/*.
  title: tai42-skeleton API
  version: 14.0.2
servers: []
security: []
paths:
  /api/conversations/{route_name}/events:
    post:
      tags:
        - conversations
      summary: Deliver a structured event to a conversation thread as a turn
      description: >-
        Run a structured event as a turn on an EXISTING thread of
        ``route_name``.


        The thread must already exist: an event enters a conversation as a turn
        and never mints a

        thread. Address it by the ``address`` its listing shows or by its
        ``thread_id`` (exactly

        one). The turn runs the route's TOOL target AS the route's execution key
        — an

        agent-target route is refused (``409``) since an event carries no
        rendered text. The

        event is IDEMPOTENT on ``event_id``: a redelivery returns the original
        turn's

        ``message_id`` (``202``) and runs no second turn.


        The answer is delivered by the TARGET route's door — a channel route
        texts the thread's

        address, an api route POSTs the route's signed callback (a
        ``wait_seconds`` body field,

        clamped to ``sync_wait_max_seconds``, returns a finished turn's answer
        inline in the

        ``200`` and suppresses the callback). Default is ``202 {message_id,
        thread_id}``.


        This is a TRUSTED-integration door: an authorized writer may address ANY
        existing thread

        of the route by ``thread_id`` (a channel participant's included), so a
        deployment grants its

        write action to service principals, not to low-trust API keys.
      operationId: post_api_conversations_route_name_events
      parameters:
        - in: path
          name: route_name
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConversationEventSubmission'
        required: true
      responses:
        '200':
          content:
            application/json:
              schema:
                properties:
                  data:
                    $ref: '#/components/schemas/ConversationTurnAck'
                required:
                  - data
                type: object
          description: Success.
        '202':
          content:
            application/json:
              schema:
                properties:
                  data:
                    $ref: '#/components/schemas/ConversationTurnAck'
                required:
                  - data
                type: object
          description: Success.
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Malformed request.
        '401':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Missing or invalid api key.
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Resource not found.
        '409':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Conflict with the current resource state.
        '429':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Error.
        '501':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          description: Error.
        '503':
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: '#/components/schemas/ReloadingError'
                  - $ref: '#/components/schemas/Error'
          description: >-
            The server is applying a config reload, or a dependency this route
            needs is temporarily unavailable; retry shortly.
          headers:
            Retry-After:
              description: >-
                Seconds to wait before retrying; carried by the reloading
                answer.
              schema:
                type: integer
      security:
        - ApiKeyAuth: []
components:
  schemas:
    ConversationEventSubmission:
      additionalProperties: false
      description: >-
        The inbound body of the event door ``POST
        /api/conversations/{route_name}/events``.


        An :class:`ConversationEvent` addressed to an EXISTING thread by EXACTLY
        ONE (non-blank)

        of ``address`` (the thread's client address) or ``thread_id`` (the id
        the monitoring

        listing exposes). ``wait_seconds`` bounds a sync-wait window exactly as

        :attr:`ConversationMessage.wait_seconds`. There is NO callback field —
        an event's answer

        is delivered against the target thread's route. Frozen.
      properties:
        address:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Address
        event:
          $ref: '#/components/schemas/ConversationEvent'
        thread_id:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Thread Id
        wait_seconds:
          default: 0
          description: >-
            Bounded sync-wait window (seconds); 0 = async 202. The door clamps
            to its runtime cap.
          minimum: 0
          title: Wait Seconds
          type: integer
      required:
        - event
      title: ConversationEventSubmission
      type: object
    ConversationTurnAck:
      description: >-
        The ack a message/event submission returns: the accepted turn's
        ``message_id``

        and its ``thread_id``. ``answer`` is present on every inline-waited turn
        that finished

        in time (a 200) — including a silent one, which carries the silent
        marker (status

        ``silent``, no answer text, dumped ``exclude_none``); it is absent only
        on the default

        deferred 202, whose turn produced no outcome yet.
      properties:
        answer:
          anyOf:
            - $ref: '#/components/schemas/ConversationAnswer'
            - type: 'null'
          default: null
        message_id:
          title: Message Id
          type: string
        thread_id:
          title: Thread Id
          type: string
      required:
        - message_id
        - thread_id
      title: ConversationTurnAck
      type: object
    Error:
      properties:
        code:
          description: >-
            Stable machine-readable reason a client keys a dedicated error state
            on, present on refusals that opt in (e.g. a 501 not-configured
            refusal). Optional: absent when the error carries only a
            human-readable message.
          type: string
        error:
          type: string
      required:
        - error
      type: object
    ReloadingError:
      properties:
        error:
          const: reloading — the server is applying a config reload; retry shortly
          type: string
        reloading:
          const: true
          type: boolean
      required:
        - error
        - reloading
      type: object
    ConversationEvent:
      additionalProperties: false
      description: >-
        A structured event delivered to an existing thread as a turn.


        ``event_id`` (non-blank after trim, ≤ ``EVENT_ID_MAX_CHARS``) is the
        idempotency key;

        ``kind`` is an identifier-like label matching ``EVENT_KIND_RE``;
        ``payload`` is opaque,

        untrusted data bounded as pure transport by
        :func:`validate_bounded_object`. Frozen.
      properties:
        event_id:
          title: Event Id
          type: string
        kind:
          title: Kind
          type: string
        payload:
          additionalProperties: true
          title: Payload
          type: object
      required:
        - event_id
        - kind
      title: ConversationEvent
      type: object
    ConversationAnswer:
      description: >-
        The outcome of one conversation turn — the body POSTed (HMAC-signed) to
        a
            ``door=api`` row's ``callback_url`` AND the bounded sync-wait payload.

            ``message_id`` correlates it to the ``202``/``200`` the door returned. On
            ``status="error"`` the ``answer`` is generic client-safe text, never an internal
            detail. On ``status="silent"`` the turn produced no reply and ``answer`` is absent.

            ``parts`` is the ordered list of :class:`AnswerPart` messages the turn produced when a
            single joined string would lose something — more than one message, or one message
            carrying media/options/a template. It is order-significant; when it is present ``answer``
            equals the parts' NON-BLANK MESSAGE texts joined with ``"

        "`` (:func:`joined_answer_text`),
            so every consumer of ``answer`` (api-door callbacks, sync waits, transcripts) keeps seeing
            the whole text with zero migration while a parts-aware consumer reads ``parts`` and delivers
            each as its own message (with its media/options). A single PLAIN-TEXT answer carries
            ``parts=None`` (the joined ``answer`` says everything); a richer or multi-message answer
            carries the parts. An ALL-MEDIA answer (every part media-only) joins to the EMPTY string, so
            on ``answered``/``error`` a blank ``answer`` is admissible ONLY when ``parts`` carry the
            content — the media rides ``parts``. Frozen.
            
      properties:
        answer:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Answer
        message_id:
          minLength: 1
          title: Message Id
          type: string
        parts:
          anyOf:
            - items:
                $ref: '#/components/schemas/AnswerPart'
              type: array
            - type: 'null'
          default: null
          title: Parts
        status:
          enum:
            - answered
            - error
            - silent
          title: Status
          type: string
        thread_id:
          minLength: 1
          title: Thread Id
          type: string
      required:
        - message_id
        - thread_id
        - status
      title: ConversationAnswer
      type: object
    AnswerPart:
      additionalProperties: false
      description: >-
        One message of an ordered multi-message answer — the platform's rich
        part shape.


        It mirrors :class:`ChannelNotification`'s CONTENT surface exactly
        (``message`` plus the

        optional ``media`` / ``location`` / ``template`` / ``options`` /
        ``sections`` / ``header``

        / ``footer`` / ``schema`` richer-send forms and their validators), MINUS
        the per-delivery

        routing fields (``recipient`` / ``sender_identity``) — those stay on the
        single delivery,

        never per part. The delivery machine sends each part as its own
        ``ChannelNotification``,

        in order, chunking the ``message`` text at the channel width and
        carrying the part's

        richer forms alongside it, exactly as a single notification does today.
        Every

        cross-field rule (content-only blank message, ``options`` XOR
        ``sections``, ``schema``

        excludes both, ``header``/``footer`` require a choice surface,
        ``template`` standalone) is

        the SHARED
        :func:`~tai42_contract.channels.check_interactive_composition`, so an
        authored

        part and a delivered notification can never diverge.


        A part is a NEW authoring surface, so it is STRICT FROM BIRTH: unknown
        keys are refused

        (``extra="forbid"``) rather than silently dropped. A tool route authors
        parts as a JSON

        array whose elements are each EITHER a plain string (shorthand for a
        text-only part) or a

        part object — both normalize to THIS one model. Frozen.


        ``message`` mirrors :class:`ChannelNotification.message`'s
        blank-vs-media RULE: non-blank BY

        DEFAULT, EXCEPT it may be blank for a MEDIA-ONLY part — a caption-less
        image with no text

        carrier. The admissible states are "``message`` non-blank" OR "blank
        ``message`` WITH non-empty

        ``media``"; a blank ``message`` and no media has nothing to deliver and
        is refused. ``options``

        REQUIRE a non-blank ``message`` (a tappable choice needs a prompt), and
        a ``template`` rides a

        non-blank ``message`` too — so a media-only part carries only ``media``.
        Unlike

        ``ChannelNotification`` (always constructed in code), a part is authored
        as JSON where the

        ``message`` key may be ABSENT, so it defaults to ``""`` — a media-only
        part is just ``{"media": …}``.
      properties:
        data:
          anyOf:
            - $ref: '#/components/schemas/FormData'
            - type: 'null'
          default: null
        footer:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Footer
        header:
          anyOf:
            - $ref: '#/components/schemas/MediaItem'
            - type: 'null'
          default: null
        location:
          anyOf:
            - $ref: '#/components/schemas/LocationElement'
            - type: 'null'
          default: null
        media:
          anyOf:
            - items:
                $ref: '#/components/schemas/MediaItem'
              type: array
            - type: 'null'
          default: null
          title: Media
        message:
          default: ''
          title: Message
          type: string
        options:
          anyOf:
            - items:
                discriminator:
                  mapping:
                    link:
                      $ref: '#/components/schemas/LinkOption'
                    reply:
                      $ref: '#/components/schemas/ReplyOption'
                  propertyName: kind
                oneOf:
                  - $ref: '#/components/schemas/ReplyOption'
                  - $ref: '#/components/schemas/LinkOption'
              type: array
            - type: 'null'
          default: null
          title: Options
        pages:
          anyOf:
            - items:
                $ref: '#/components/schemas/FormPage'
              type: array
            - type: 'null'
          default: null
          title: Pages
        schema:
          anyOf:
            - additionalProperties: true
              type: object
            - type: 'null'
          default: null
          title: Schema
        sections:
          anyOf:
            - items:
                $ref: '#/components/schemas/OptionSection'
              type: array
            - type: 'null'
          default: null
          title: Sections
        template:
          anyOf:
            - $ref: '#/components/schemas/ChannelTemplate'
            - type: 'null'
          default: null
      title: AnswerPart
      type: object
    FormData:
      description: >-
        Per-send data layered over a form's published schema for ONE send.


        ``values`` prefills top-level properties — each entry keyed by property
        name,

        its value shown filled in and validated against that property's schema.

        ``options`` supplies a per-send choice list for a property whose schema
        is a

        string (or an array of strings), keyed by property name: the list
        REPLACES that

        property's ``enum`` for this send only (labels shown, values submitted).
        The

        model holds only the shape; the cross-check against the schema (unknown

        property, a value that fails its schema, options on a non-string
        property, an

        empty list) is done once by the interaction request. Frozen.
      properties:
        options:
          additionalProperties:
            items:
              $ref: '#/components/schemas/FormOption'
            type: array
          default: {}
          title: Options
          type: object
        values:
          additionalProperties: true
          default: {}
          title: Values
          type: object
      title: FormData
      type: object
    MediaItem:
      description: >-
        One media item shown WITH a message — a display element, and inbound the
        shape a participant's

        sent media takes.


        ``kind`` selects how it renders: an ``image`` inline, a
        ``document``/``video``/``audio`` as

        the matching file bubble, a ``link`` as a labelled anchor. ``url`` is
        the source. A file

        kind (``image``/``document``/``video``/``audio``) must be an absolute
        ``https`` URL, a

        same-origin ``{MEDIA_ROUTE_PREFIX}{id}`` reference to media the skeleton
        serves by id, or an

        absolute ``http(s)`` served reference of that same
        ``{MEDIA_ROUTE_PREFIX}{id}`` path a

        channel send mints from ``public_base_url`` (remote file media is
        https-only: the inbox CSP

        ``img-src`` admits ``https:``/``data:`` and same-origin but not
        ``http:``, so an ``http:``

        remote source would be an unrenderable record); ``image`` ADDITIONALLY
        admits an inline

        ``data:image/*`` URI — that inline form is image-only, a ``data:`` URI
        on any other file

        kind is refused. A ``link`` must be an absolute ``http(s)`` URL (anchors
        are not governed by

        ``img-src``; the human clicks through). A remote url names a host
        directly — an ASCII DNS

        name, dotted-quad IPv4, or bracketed IPv6 (IDN callers supply punycode);
        an embedded

        ``user@`` credential form is rejected as it spoofs the authority — and
        is always a single

        line — raw whitespace and control/format characters are rejected.
        ``caption`` is the

        accessibility text — the image's alt text, a file's label, or the link's
        display label.

        ``filename`` is the document's suggested display name; it is meaningful
        ONLY for a

        ``document`` item and is refused on every other kind.
      properties:
        caption:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Caption
        filename:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Filename
        kind:
          $ref: '#/components/schemas/MediaKind'
        url:
          title: Url
          type: string
      required:
        - kind
        - url
      title: MediaItem
      type: object
    LocationElement:
      description: >-
        A geographic point shared on a message — the one shape used BOTH ways:
        an outbound place a

        flow shares and the inbound location a participant sent.


        ``latitude``/``longitude`` are WGS84 decimal degrees, bounded to their
        valid ranges

        (latitude -90..90, longitude -180..180). ``name`` is an optional place
        label and ``address``

        an optional street address, each a single-line non-blank string within
        its cap when present

        (raw whitespace and control/format characters are rejected, as on a
        media url — an embedded

        newline or bidi spoof would corrupt the rendered pin label). A channel
        that cannot render a

        map renders the coordinates (and any name/address) as text. Frozen.
      properties:
        address:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Address
        latitude:
          title: Latitude
          type: number
        longitude:
          title: Longitude
          type: number
        name:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Name
      required:
        - latitude
        - longitude
      title: LocationElement
      type: object
    LinkOption:
      description: >-
        A tappable link action. Tapping OPENS ``url`` (an absolute ``http(s)``
        URL) in the human's

        browser — NO message is submitted, distinct from a :class:`ReplyOption`.
        ``label`` is the

        button text. The URL-button / call-to-action case. Frozen.
      properties:
        kind:
          const: link
          default: link
          title: Kind
          type: string
        label:
          title: Label
          type: string
        url:
          title: Url
          type: string
      required:
        - label
        - url
      title: LinkOption
      type: object
    ReplyOption:
      description: >-
        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 as today. Bounded by
        ``OPTION_ID_MAX_CHARS`` and a

        single-line non-blank label — the strictest carrier's rule. Frozen.
      properties:
        description:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Description
        id:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Id
        kind:
          const: reply
          default: reply
          title: Kind
          type: string
        text:
          title: Text
          type: string
      required:
        - text
      title: ReplyOption
      type: object
    FormPage:
      description: >-
        One step of a stepped form: ``title`` heads the step and ``fields``
        names the

        top-level properties shown on it. Across a form's ``pages`` every
        property

        appears exactly once (the interaction request enforces the coverage);
        absent

        ``pages`` means one page. Frozen.
      properties:
        fields:
          items:
            type: string
          title: Fields
          type: array
        title:
          title: Title
          type: string
      required:
        - title
        - fields
      title: FormPage
      type: object
    OptionSection:
      description: >-
        One titled section of a sectioned option list. ``title`` is the section
        header; ``rows`` are

        its entries — a sectioned list holds :class:`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.
      properties:
        rows:
          items:
            $ref: '#/components/schemas/ReplyOption'
          title: Rows
          type: array
        title:
          title: Title
          type: string
      required:
        - title
        - rows
      title: OptionSection
      type: object
    ChannelTemplate:
      description: >-
        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
          (:data:`TemplateButtonParam`: a :class:`QuickReplyButtonParam` payload or a
          :class:`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.
      properties:
        body_parameters:
          items:
            type: string
          title: Body Parameters
          type: array
        buttons:
          items:
            discriminator:
              mapping:
                quick_reply:
                  $ref: '#/components/schemas/QuickReplyButtonParam'
                url:
                  $ref: '#/components/schemas/UrlButtonParam'
              propertyName: kind
            oneOf:
              - $ref: '#/components/schemas/QuickReplyButtonParam'
              - $ref: '#/components/schemas/UrlButtonParam'
          title: Buttons
          type: array
        header_media:
          anyOf:
            - $ref: '#/components/schemas/MediaItem'
            - type: 'null'
          default: null
        language:
          title: Language
          type: string
        name:
          title: Name
          type: string
      required:
        - name
        - language
      title: ChannelTemplate
      type: object
    FormOption:
      description: >-
        One per-send choice for a form field: ``value`` is the string submitted
        as

        the answer, ``label`` (when set) is shown to the human in its place. A
        per-send

        option list REPLACES a property's schema ``enum`` for ONE send — the
        published

        form is unchanged, so a variant needs no re-publish. Frozen.
      properties:
        label:
          anyOf:
            - type: string
            - type: 'null'
          default: null
          title: Label
        value:
          title: Value
          type: string
      required:
        - value
      title: FormOption
      type: object
    MediaKind:
      enum:
        - image
        - link
        - document
        - video
        - audio
      title: MediaKind
      type: string
    QuickReplyButtonParam:
      description: >-
        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.
      properties:
        kind:
          const: quick_reply
          default: quick_reply
          title: Kind
          type: string
        payload:
          title: Payload
          type: string
      required:
        - payload
      title: QuickReplyButtonParam
      type: object
    UrlButtonParam:
      description: >-
        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.
      properties:
        kind:
          const: url
          default: url
          title: Kind
          type: string
        url_parameter:
          title: Url Parameter
          type: string
      required:
        - url_parameter
      title: UrlButtonParam
      type: object
  securitySchemes:
    ApiKeyAuth:
      in: header
      name: x-api-key
      type: apiKey

````