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

# Author a channel

> Build a channel plugin.

Author a channel plugin.

A channel delivers a pending [interaction](/concepts/interactions) question to a person on a specific medium (Telegram, Slack, SMS/WhatsApp) and bridges the reply back through the public callback door. It implements the `Channel` protocol — a `deliver` method for a question that expects an answer, and a `notify` method for a fire-and-forget message. Neither returns a bool: both return `None` on success or raise `ChannelDeliveryError` on any failure. An undeliverable question must not read as a silent pass, because the asking tool is blocked waiting on an answer that would never come.

## Implement deliver

`deliver` receives a frozen `ChannelDelivery`: the question text, the answer format, the options for a select, the optional caller-requested `recipient`, the public `callback_url` that accepts the answer, and the deadline. The plugin sends the question to its medium and hands the human a way to answer. Credentials and the operator's own recipient addresses are the plugin's settings, resolved from the environment — never tool parameters, so no secret or address ever rides an LLM-visible value.

The `recipient` is an address only (a chat id, a phone number), never a secret. When the caller supplies one, validate it against the operator's allowlist and refuse an unlisted address — fail closed, send nothing. When the caller supplies none, send to the operator-configured default.

```python examples/channel/echo_channel.py theme={null}
"""A minimal fictional channel: deliver a question to a chat API over HTTPS.

``deliver`` sends the question and its answer path to the medium and returns
``None``; ``notify`` sends a fire-and-forget message the same way. Every failure
raises ``ChannelDeliveryError`` — neither method returns a bool, so an
undeliverable question cannot read as a silent pass while the asking tool blocks
on an answer that would never arrive. Credentials and the recipient come from the
environment, never from tool parameters, so no secret or address rides an
LLM-visible value.

A ``confirm``/``external`` question (Tier 1) is answered at the callback door
itself, so it ships the ``callback_url`` as a tappable link and needs no reply on
the medium. A ``text``/``select`` question (Tier 2) is answered by typing, so a
real channel would deliver a reply affordance and correlate the typed answer back
to the ``callback_url`` through its own inbound route; this example keeps to the
link shape for both.
"""

import os

import httpx
from tai42_contract.app import tai42_app
from tai42_contract.channels import ChannelDelivery, ChannelDeliveryError, ChannelNotification


class EchoChannel:
    """Delivers to a fictional chat API as a message carrying an answer link."""

    async def _send(self, text: str, recipient: str | None, context: str) -> None:
        token = os.environ["CHANNEL_ECHO_TOKEN"]
        # The caller-requested recipient is validated against the operator
        # allowlist; an unset one falls back to the operator default. Both come
        # from the environment, never from a tool parameter.
        chat_id = self._resolve_recipient(recipient)
        try:
            async with httpx.AsyncClient() as client:
                response = await client.post(
                    "https://chat.example.com/api/send",
                    json={"chat": chat_id, "text": text},
                    headers={"Authorization": f"Bearer {token}"},
                )
                response.raise_for_status()
        except httpx.HTTPError as exc:
            raise ChannelDeliveryError(f"echo channel delivery failed for {context}: {exc}") from exc

    @staticmethod
    def _resolve_recipient(recipient: str | None) -> str:
        if recipient is None:
            return os.environ["CHANNEL_ECHO_DEFAULT_RECIPIENT"]
        allowed = os.environ.get("CHANNEL_ECHO_ALLOWED_RECIPIENTS", "").split(",")
        if recipient not in {entry.strip() for entry in allowed if entry.strip()}:
            raise ChannelDeliveryError(
                f"recipient {recipient!r} is not on CHANNEL_ECHO_ALLOWED_RECIPIENTS; refusing to send"
            )
        return recipient

    async def deliver(self, delivery: ChannelDelivery) -> None:
        text = f"{delivery.question}\nAnswer here: {delivery.callback_url}"
        await self._send(text, delivery.recipient, f"interaction {delivery.interaction_id}")

    async def notify(self, notification: ChannelNotification) -> None:
        await self._send(notification.message, notification.recipient, "notification")


tai42_app.channels.register("echo", EchoChannel())
```

Every failure path raises `ChannelDeliveryError` (or lets the underlying error propagate as one) — a send that fails must surface loudly so the platform prunes the question instead of blocking forever. Send once: no medium API offers an idempotency key, so a blind retry risks a double-send.

## Two answer tiers

The answer format decides how the human answers, and so how much the channel must do:

* **Tier 1 — `confirm` / `external`:** the answer is recorded at the callback door itself (a confirm is a tap, an external is a structured POST), neither a value a human types. Deliver the `callback_url` as a tappable link and you are done — no inbound route, no correlation state.
* **Tier 2 — `text` / `select`:** the human types the answer on the medium. The channel accepts that typed reply on its own inbound route, correlates it to the pending question, and forwards it to the stored `callback_url`.

`form` has no single-reply mapping and is rejected before delivery.

## Implement notify

`notify` receives a `ChannelNotification` — just a message and an optional recipient. It sends one fire-and-forget message with no interaction, no ticket, no callback, and no reply path, under the same loud-failure rule. A channel that cannot notify raises `NotImplementedError`.

## Register through the handle

Register the channel under a name through the handle, as the last line of the example does. Registering a name already taken raises loudly — a silent overwrite could swap a channel out from under a live ask.

## Load it

The host loads a channel by naming its import-only module under `channel_modules`; a tool then targets it by name: `ask_user(question, channel="echo")`. With no channel named, the question surfaces in the Studio inbox as usual.

```yaml manifest.yml theme={null}
channel_modules:
  - your_package
```

## Inbound replies

A Tier-2 channel registers its own public inbound route, verifies each delivery is genuinely from the provider — failing closed on a missing or mismatched credential — correlates the reply to the pending question, and forwards the answer to the stored `callback_url`. The shipped channel packages show the full pattern per provider.

## Shipped implementations

Provider channels ship as their own packages — Telegram, Slack, and Twilio (SMS/WhatsApp); see the [ecosystem catalog](/reference/catalog).

## See also

* [Interactions](/concepts/interactions) — the human-in-the-loop model a channel delivers for.
* [Python SDK reference](/reference/python-sdk) — the `Channel` protocol.
