# Channels

`tnsai-channels` connects agents to messaging platforms. An adapter turns each platform's messages into a `UnifiedMessage`. `MessageRouter` passes the message to your handler, and your handler replies with a `UnifiedResponse`, which goes back through the same adapter.

## Example

<!-- java-contract: src/main/java/com/example/tnsai/docs/ChannelsExample.java -->
```java
package com.example.tnsai.docs;

import com.tnsai.agents.Agent;
import com.tnsai.channels.api.UnifiedResponse;
import com.tnsai.channels.registry.ChannelRegistry;
import com.tnsai.channels.routing.MessageRouter;
import com.tnsai.channels.slack.SlackChannel;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;

/** Compilation contract for connect/channels.md. */
public final class ChannelsExample {

    private ChannelsExample() {
    }

    static MessageRouter telegram(Supplier<Agent> newAgent) {
        ChannelRegistry registry = new ChannelRegistry();
        registry.discoverAdapters();                     // every adapter listed in META-INF/services

        Map<String, Agent> agents = new ConcurrentHashMap<>();
        MessageRouter router = new MessageRouter(registry);
        router.setMessageHandler((message, reply) -> {
            if (message.text() == null) {
                return;                                  // media-only message
            }
            // One agent per conversation, so chats do not share history.
            Agent agent = agents.computeIfAbsent(message.sessionKey(), key -> newAgent.get());
            reply.send(UnifiedResponse.builder()
                .channelId(message.channelId())
                .conversationId(message.conversationId())
                .text(agent.chat(message.text()))
                .build());
        });

        registry.startAdapter("telegram", router);       // reads TELEGRAM_BOT_TOKEN
        return router;
    }

    static void slack(MessageRouter router, ChannelRegistry registry) {
        // Without discoverAdapters(); a second adapter with the same id is ignored.
        registry.register(new SlackChannel());           // reads SLACK_BOT_TOKEN and SLACK_APP_TOKEN
        registry.startAdapter("slack", router);
        if (!registry.get("slack").orElseThrow().isRunning()) {
            throw new IllegalStateException("Slack did not start; see the router's error log");
        }
    }

    static int reap(MessageRouter router) {
        return router.getSessions().reapIdle(Duration.ofHours(1));   // nothing reaps on its own
    }
}
```

## Adapters

| Id | Platform | Configuration | Longest message |
|---|---|---|---|
| `telegram` | Telegram long polling | `TELEGRAM_BOT_TOKEN`, or the `telegram.bot.token` system property. Needs the optional `com.github.pengrad:java-telegram-bot-api` dependency. | 4,096 characters; longer text is split |
| `slack` | Slack Socket Mode | `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` | 40,000 characters; longer text is cut |
| `discord` | Discord Gateway | `DISCORD_BOT_TOKEN`; optional `DISCORD_INTENTS` and `DISCORD_APPLICATION_ID` | 2,000 characters; longer text is cut |
| `whatsapp` | WhatsApp Cloud API webhook | `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_VERIFY_TOKEN` and `WHATSAPP_APP_SECRET` | 4,096 characters; longer text is cut |
| `email` | IMAP polling and SMTP replies | `EMAIL_IMAP_HOST`, `EMAIL_IMAP_USER`, `EMAIL_IMAP_PASSWORD`, `EMAIL_SMTP_HOST` and `EMAIL_SENDER_ALLOWLIST`. Needs the optional `jakarta.mail:jakarta.mail-api` and `org.eclipse.angus:angus-mail` dependencies. | 1,000,000 characters |
| `cli` | Terminal REPL, or JSON lines | None. The default constructor reads `System.in`. | No limit |

Each adapter except `telegram` and `cli` also takes a config record (`SlackChannelConfig` and so on) in its constructor. Without one, it reads the environment when it starts.

- Discord's default intents include message content, which you must turn on for the bot in the Discord developer portal.
- The WhatsApp webhook listens on `127.0.0.1:8443/webhook` by default (`WHATSAPP_WEBHOOK_BIND_ADDRESS`, `WHATSAPP_WEBHOOK_PORT`, `WHATSAPP_WEBHOOK_PATH`). Put a public HTTPS proxy or tunnel in front of it. It rejects events whose `X-Hub-Signature-256` does not match `WHATSAPP_APP_SECRET`.
- The email adapter only answers senders on `EMAIL_SENDER_ALLOWLIST`, a comma-separated list. Write the addresses in lowercase, because incoming addresses are lowercased before the check. With an empty list it answers no one.
- The Telegram, Slack, Discord and WhatsApp adapters answer anyone who can message the bot.

## Start adapters

`discoverAdapters()` registers every adapter listed in `META-INF/services/com.tnsai.channels.api.ChannelAdapter`, which is all six. Start the ones you configured with `startAdapter(id, router)`. `startAll(router)` also starts the rest: the CLI adapter takes over standard input, and each unconfigured adapter reports an error. `register(adapter)` adds one adapter yourself. A second adapter with an id already registered is ignored.

When an adapter has no configuration, it reports a fatal `ChannelError` to the router and returns. The router logs the error, but `getState(id)` still says `RUNNING`, because the adapter did not throw. Check `adapter.isRunning()` instead. `getState` returns `FAILED` only when `start` throws.

## Handle messages

`MessageRouter` calls your handler on the thread that delivered the message, so a slow agent call holds up that adapter. The reply goes to the adapter the message came from. The response's `channelId` is not used for routing. Without a handler, the router logs an error and drops the message.

`message.sessionKey()` is `channelId:conversationId`. A single agent keeps one chat history, so give each conversation its own agent, as the example does. `getSessions()` records when each conversation was last active. Nothing removes old entries until you call `reapIdle(maxIdle)`.

`sendProactive(channelId, conversationId, text)` sends a message that is not a reply.

## Messages

| Record | Holds |
|---|---|
| `UnifiedMessage` | `channelId`, `senderId`, `senderName`, `conversationId`, `text` (null for a message with only attachments), `attachments`, `meta`, `timestamp` |
| `UnifiedResponse` | `channelId`, `conversationId`, `text`, `attachments`, `hints` (default `ResponseHints.defaults()`) |
| `MessageMeta` | `replyToMessageId`, `threadId`, `platformMessageId`, `extra` |
| `ResponseHints` | `streaming`, `showTyping`, `splitLongMessages`, `replyToMessageId` |
| `ChannelCapabilities` | What the platform supports, and `maxMessageLength` |

Telegram uses `showTyping` and `replyToMessageId`. Slack and Discord use `replyToMessageId`, which on Slack is the thread timestamp. No adapter reads `streaming` or `splitLongMessages`.

## Stream replies

`StreamingChannelAdapter` adds `sendChunk(UnifiedChunk)` for text that arrives in pieces. Only `CliChannel` implements it, and `MessageRouter` never calls it, so your handler sends the chunks to the adapter itself. `UnifiedChunk.text(conversationId, delta)` makes a piece and `UnifiedChunk.done(conversationId)` ends the reply. After a streamed reply, `CliChannel` skips the next `send(...)`, because the text is already on screen.

## Write an adapter

1. Implement `ChannelAdapter`: `id()`, `displayName()`, `capabilities()`, `start(context)`, `stop()`, `send(response)` and `isRunning()`. Call `context.onMessage(...)` for each incoming message, and `context.onError(...)` with a `TRANSIENT`, `DEGRADED` or `FATAL` severity when something fails.
2. List the adapter in `META-INF/services/com.tnsai.channels.api.ChannelAdapter`. This is the file `discoverAdapters()` reads. The adapter needs a public no-argument constructor.
3. Optionally implement `ChannelAdapterFactory` and list it in `META-INF/services/com.tnsai.channels.api.ChannelAdapterFactory`. `ChannelDiscovery.discoverFactories()` returns the factories whose `isAvailable()` is true. The registry does not use factories.

`@Channel(id, displayName)` documents an adapter class. Nothing reads it, so it does not register anything.

## Related

- [Connect](index.md)
- [Create an agent](../agents/create.md)
- [Server](../server/index.md)
