# Channels

A channel plugin connects agents to a chat app the platform doesn't support natively. People message the bot
there; the message reaches an agent; the agent's reply goes back to the same conversation. It works exactly like
the built-in Telegram, WhatsApp and Slack channels — the official **Discord** plugin is a channel plugin.

You write only the translation between the vendor's format and AgentParley's. The platform does the rest for
every channel alike: the people list and who-may-talk rule, link codes for new people, one chat per outside
conversation, "only wake on @mention in group chats", de-duplication, a retrying outbound queue, parking messages
that keep failing, and inbound files.

## Two ways in

| | Webhook | Live connection |
| --- | --- | --- |
| The vendor… | POSTs each message to a URL | only delivers over a socket you keep open (Discord Gateway, IRC-style protocols) |
| You implement | `channel.receive` + `channel.send` | `connection.open` + `connection.receive` + `channel.send` |
| Example | `hello-channel` (below) | [Discord](example-discord.md) |

A channel plugin must implement `channel.send` and one way in; otherwise the publish fails with *"a channel plugin
needs a way in"*. This page covers the webhook path and the parts both share; see
[Live connections](plugin-live-connections.md) for sockets.

## The handlers

```ts
globalThis.channel = {
  receive?: (request, tools) => { messages: ChannelInboundMessage[] } | ChannelRejection,
  send:     (request) => void,
  typing?:  (request) => void,
  fetchAttachment?: (request) => { contentBase64: string; contentType?: string },
};
```

### `receive` — a webhook request arrived

```ts
request: {
  headers: Record<string, string>;
  body: string;             // the body as text
  rawBodyBase64: string;    // the exact bytes — verify signatures over THIS, not over `body`
  webhookUrl: string | null;
}
tools: { reject(reason: string): ChannelRejection }
```

Verify the request, then return the messages in it:

```ts
// infrastructure/plugins/hello-channel/index.ts
globalThis.channel = {
  receive: async (request, tools) => {
    const header = request.headers["x-hello-signature"] ?? "";
    const signature = header.startsWith("sha256=") ? header.slice("sha256=".length) : "";
    const secret = await host.settings.getSecret("sharedSecret");
    const expected = host.crypto.hmacSha256({ key: secret, data: request.rawBodyBase64, dataEncoding: "base64", outputEncoding: "hex" });
    if (!host.crypto.timingSafeEqual(signature, expected)) {
      return tools.reject("bad signature");
    }

    const body = JSON.parse(request.body) as HelloChannelBody;
    return {
      messages: [
        {
          conversationId: body.conversationId,
          messageId: body.messageId,
          sender: { id: body.sender.id, displayName: body.sender.name },
          text: body.text,
          isGroup: body.isGroup,
          attachments: body.attachments?.map((attachment) => ({
            ref: attachment.dataBase64,
            fileName: attachment.fileName,
            contentType: attachment.contentType,
          })),
        },
      ],
    };
  },
  // …
};
```

Return `{ messages: [] }` for a request that carries nothing to deliver (a delivery receipt, a ping).
`host.crypto` covers the signature schemes chat vendors use: HMAC-SHA1/SHA256, Ed25519 verification, RS256 JWT
verification against a JWKS, AES-CBC decryption, and a timing-safe compare. See the
[Host API](plugin-host-api.md#hostcrypto).

### The inbound message

```ts
interface ChannelInboundMessage {
  conversationId: string;     // the chat/thread on the vendor side — one AgentParley chat per conversation
  messageId: string;          // the vendor's message id — used to drop duplicates
  sender: { id: string; displayName: string; handle?: string };   // required: drives the people list
  text: string;
  isGroup?: boolean;          // a shared conversation (group, server channel)
  mentionsBot?: boolean;      // in a group, the agent only wakes when this is true
  conversationName?: string;  // e.g. "#general", shown in the console
  attachments?: { ref: string; fileName?: string; contentType?: string }[];
}
```

- `sender.id` is how the platform recognizes a person. The channel's **people list** decides who may talk to the
  agent (by default, only linked people; a stranger is told once how to link with a code).
- By default only linked people may talk to the agent. A plugin whose senders are machines declares
  [`channelSenderPolicy: "anyone"`](plugin-manifest.md#channelsenderpolicy), and its channels start open.
- In a group conversation the agent only wakes when `mentionsBot` is true — the same rule for every channel.
- Attachments are **references**: the platform calls your `fetchAttachment` with a `ref` only when it wants the
  file.
- A message is skipped (and a note logged) when `conversationId` (up to 200 characters), `messageId` (up to 64) or
  `sender.id` (up to 100) is missing or too long, or when it has neither text nor an attachment. `ref` is up to 500
  characters; `conversationName` is cut to 100.

### `send` — deliver the agent's reply

```ts
request: { conversationId: string; text: string; deliveryId: string | null }
```

- `deliveryId` is stable across retries of the same reply. Use it as the vendor's idempotency key or nonce so a
  retry never posts twice.
- **To stop retrying**, throw an error whose message contains `[rejected]` — for a 403, a blocked user, a deleted
  conversation. Any other thrown error is retried for a few minutes.
- Split long replies yourself if the vendor has a length limit.

```ts
// plugins/discord/index.ts (abridged)
send: async (request) => {
  const token = await host.settings.getSecret("botToken");
  const chunks: string[] = [];
  for (let offset = 0; offset < request.text.length; offset += 2000) {
    chunks.push(request.text.slice(offset, offset + 2000));
  }
  for (const [index, chunk] of chunks.entries()) {
    const nonce = request.deliveryId ? chunkNonce(request.deliveryId, index) : undefined;
    const response = await discordFetch(token, "POST", `/channels/${request.conversationId}/messages`, {
      content: chunk,
      allowed_mentions: { parse: [] },
      nonce,
      enforce_nonce: nonce ? true : undefined,
    });
    if (response.status >= 400 && response.status < 500 && response.status !== 429 && response.status !== 408) {
      throw new Error(`[rejected] Discord returned ${response.status}`);
    }
    if (response.status < 200 || response.status >= 300) {
      throw new Error(`Discord returned ${response.status}`);
    }
  }
},
```

Plugin channels send **text**. Sending files out through a plugin channel is not supported.

### `typing` (optional)

```ts
request: { conversationId: string; lastInboundMessageId: string | null }
```

Called while the agent is working, so the user sees "typing…". Failures are harmless — swallow them:

```ts
// plugins/discord/index.ts
typing: async (request) => {
  try {
    const token = await host.settings.getSecret("botToken");
    await discordFetch(token, "POST", `/channels/${request.conversationId}/typing`);
  } catch (error) {
    host.log("debug", `typing indicator failed: ${error}`);
  }
},
```

### `fetchAttachment` (optional)

```ts
request: { ref: string; maxBytes: number }
returns: { contentBase64: string; contentType?: string }
```

Download the file behind a `ref` you returned. Use `host.fetch` with `responseEncoding: "base64"`:

```ts
// plugins/discord/index.ts
fetchAttachment: async (request) => {
  const response = await host.fetch({ url: request.ref, responseEncoding: "base64" });
  return { contentBase64: response.body };
},
```

Images, voice notes and documents then go through the same pipeline as on every channel (voice notes are
transcoded, documents converted to text).

## Per-channel settings

A user can connect several bots — one per channel — so credentials usually belong to the **channel**, not the
install. Declare them with `scope: "channel"`:

```ts
// plugins/discord/index.ts
{ key: "botToken", title: "Bot token", type: "secret", required: true, scope: "channel" },
```

Channel-scoped fields are filled in on **Channels → Connect a channel**, per channel, and are never inherited
from the install's settings.

## How a user connects it

1. Install the plugin.
2. **Channels → Connect a channel**, choose your plugin, pick the agent that answers, fill in the channel's
   settings.
3. For a webhook channel, the channel's page shows its **webhook URL** — the user gives it to the vendor. It can
   be replaced with a new address if it leaks.

Only main agents (not subagents) can front a channel.

## Limits

| | |
| --- | --- |
| Inbound webhook body | up to 1 MB |
| Messages per `receive` | 20 |
| Text per message | 32,000 characters (longer text is cut and ends with `[message truncated]`) |
| Attachments per message | 5, about 3 MB each |
| `receive` | 10 s; 15 s per attachment fetch; 25 s for the whole request |
| `send` / `typing` | the ordinary 30 s wall clock each |
| Cost | each `receive` is one **plugin wake** against the account's hourly allowance (Free 60, Solo 600, Entrepreneur 3,000) — charged even on timeout |

A `receive` that times out (or throws an error mentioning "timeout"/"timed out") is retried; any other error is
final and logged. A message that keeps failing is parked rather than retried forever.

## What a channel handler can reach

`host.fetch`, `host.settings` (including channel-scoped values), `host.crypto`, `host.log`, and
`host.connection.send` when the channel runs over a live connection you own. Channel handlers don't act as an
agent — the platform turns your messages into chats and wakes the agent itself.
