# Example: Discord

**Official plugin** · channel (`send`, `typing`, `fetchAttachment`) + live connection (`open`, `receive`) ·
channel-scoped secret · wildcard egress · ~330 lines

Discord bots receive messages only over the Discord Gateway — a WebSocket the bot keeps open indefinitely. This
plugin connects a bot as an AgentParley channel: direct messages, @mentions and replies to the bot reach the agent;
everything else in a server never even wakes the plugin; replies go back over Discord's REST API.

The platform's LiveConnections service holds the socket. The plugin only describes the protocol.

## The manifest

```ts
// plugins/discord/index.ts
globalThis.manifest = {
  egress: ["discord.com", "gateway.discord.gg", "*.discord.gg", "cdn.discordapp.com", "media.discordapp.net"],
  settings: [
    { key: "botToken", title: "Bot token", type: "secret", required: true, scope: "channel" },
    {
      key: "readMessageContent",
      title: "Use the Message Content intent",
      type: "bool",
      default: true,
      scope: "channel",
      description: 'Needs "Message Content Intent" on in the Developer Portal. DMs and @mentions work without it.',
    },
    { key: "respondToBots", title: "Answer other bots", type: "bool", default: false, scope: "channel" },
  ],
};
```

- **Egress** covers the REST API, the gateway, Discord's resume hosts (`*.discord.gg` — any subdomain, not the apex)
  and the two attachment CDNs.
- **Every setting is `scope: "channel"`.** Each connected Discord channel is its own bot with its own token,
  entered on **Channels → Connect a channel**.

## Connecting — `connection.open`

```ts
// plugins/discord/index.ts (abridged)
open: async (request) => {
  if (request.owner !== "channel") {
    return { links: [], fail: "Discord connections must be channel-owned." };
  }
  const lastCloseCode = request.lastClose?.code ?? null;
  if (lastCloseCode !== null && lastCloseCode in FATAL_CLOSE_REASONS) {
    return { links: [], fail: FATAL_CLOSE_REASONS[lastCloseCode] };   // 4004 bad token, 4014 intents, …
  }

  const state = (request.state as DiscordState | null) ?? {};
  const token = await host.settings.getSecret("botToken");
  const respondToBots = host.settings.get("respondToBots") === true;
  const canResume = !!state.sessionId && !!state.resumeUrl && lastCloseCode !== 4007 && lastCloseCode !== 4009;

  let url: string;
  if (canResume) {
    url = `${state.resumeUrl}?v=10&encoding=json`;
  } else {
    const gatewayCheck = await discordFetch(token, "GET", "/gateway/bot");
    if (gatewayCheck.status === 401) {
      return { links: [], fail: "Discord rejected the bot token." };
    }
    // … refuse if Discord's daily session-start budget is nearly spent …
    url = GATEWAY_WS_URL;
  }

  return {
    state,
    links: [
      {
        transport: "websocket",
        url,
        sequence: { path: "s" },
        idleTimeoutMs: 120_000,
        filters: baseFilters(state.botId, respondToBots),
      },
    ],
    resumable: true,
  };
},
```

1. **Owner check.** This plugin only serves channel-owned connections (it has no `installOwnedConnection`).
2. **Fatal closes stop for good.** `lastClose` tells `open` why the last socket died. For a rejected token or a
   missing intent it returns `fail` — nothing reconnects until the user fixes it, and the readme explains each code.
3. **Resume when possible.** `state` survives between calls (it's where the Gateway session id lives). After a
   deploy moves the socket, `open` resumes the Discord session instead of logging in again — `resumable: true` makes
   that allowed and gets the faster resume rate.
4. **One WebSocket link** with `sequence: { path: "s" }` (the service tracks Discord's sequence number on every
   frame), an idle timeout, and the initial filters.

## Filtering before waking

```ts
// plugins/discord/index.ts
function baseFilters(botId: string | undefined, respondToBots: boolean): FrameRule[] {
  const rules: FrameRule[] = [
    { when: [{ path: "op", equals: 11 }], action: "drop" },
    { when: [{ path: "t", equals: "RESUMED" }], action: "drop" },
  ];
  if (!botId) {
    return rules;
  }
  if (!respondToBots) {
    rules.push({ when: [{ path: "d.author.bot", equals: true }], action: "drop" });
  }
  rules.push(
    { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.guild_id", exists: false }], action: "keep" },
    { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.mentions[].id", equals: botId }], action: "keep" },
    { when: [{ path: "t", equals: "MESSAGE_CREATE" }, { path: "d.referenced_message.author.id", equals: botId }], action: "keep" },
    { when: [{ path: "t", equals: "READY" }], action: "keep" },
    { when: [{ path: "op", equals: 0 }], action: "drop" },
  );
  return rules;
}
```

Heartbeat acknowledgements, other bots, and every server message that isn't a DM, a mention or a reply to the bot
are dropped by the service — they cost the account no plugin wakes. Until the bot's own id is known (before
READY), only the first two rules apply; `receive` sends the full set once READY arrives.

## Receiving — `connection.receive`

For each text frame (abridged):

| Frame | The plugin returns |
| --- | --- |
| `op 10` HELLO | `send`: IDENTIFY (or RESUME with the stored session and `lastSequence`); `heartbeat`: `{ op: 1, d: null }` every `heartbeat_interval` ms with `sequenceAt: "d"` so the service fills in the sequence |
| `op 1` heartbeat request | `send`: an immediate heartbeat |
| `op 7` reconnect | `reconnect: { reason }` |
| `op 9` invalid session | `reconnect` with `resetState` unless resumable |
| `READY` | new `state` (session id, resume URL, bot id and name) and the full `filters` |
| `MESSAGE_CREATE` | `messages`: one `ChannelInboundMessage` |

The message mapping:

```ts
// plugins/discord/index.ts
return {
  conversationId: message.channel_id,
  messageId: message.id,
  sender: { id: message.author.id, displayName: displayName(message.author, message.member), handle: message.author.username },
  text,                                          // "<@botId>" replaced by "@botname"
  isGroup,                                       // true in a server
  mentionsBot: isGroup ? mentionsBot : false,    // the platform only wakes the agent in a group if mentioned
  attachments: attachments.length > 0 ? attachments : undefined,   // refs are the CDN URLs
};
```

For server messages it also looks up the channel's name once (`#general`) and caches up to 50 names in `state`.

## Sending — `channel.send`

```ts
// plugins/discord/index.ts
function chunkNonce(deliveryId: string, index: number): string {
  return host.crypto.sha256({ data: `${deliveryId}:${index}`, outputEncoding: "hex" }).slice(0, 25);
}
```

Replies are split into 2,000-character messages. Each chunk gets a **nonce derived from the platform's
`deliveryId`** with `enforce_nonce`, so when the platform retries a send, Discord de-duplicates it. A 4xx other than
408/429 throws `[rejected] …` (don't retry); anything else throws a plain error (retry). `typing` posts Discord's
typing indicator and swallows failures; `fetchAttachment` downloads a CDN URL as base64.

## What the user does

The readme (shown in the console) walks through creating the bot in Discord's Developer Portal, turning on
**Message Content Intent**, inviting it with the right permissions, then: install the plugin → **Channels → Connect
a channel → Discord** → paste the token → pick the agent.

## Techniques to copy

- Keep protocol state (session id, resume URL, small caches) in `state` — never secrets.
- Put every "not for us" rule in `filters` so noise never costs a wake.
- Let the service send heartbeats (`heartbeat` + `sequenceAt`) instead of waking on a timer.
- Turn permanent failures into `fail` with a message the user can act on; everything else reconnects on its own.
- Derive idempotency keys from `deliveryId`.
