Examples

Example: Discord

A full chat channel over a held socket — resumable Gateway, filters, heartbeats, idempotent sends.

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

// 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

// 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

// 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:

// 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

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