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,
};
},
- Owner check. This plugin only serves channel-owned connections (it has no
installOwnedConnection). - Fatal closes stop for good.
lastClosetellsopenwhy the last socket died. For a rejected token or a missing intent it returnsfail— nothing reconnects until the user fixes it, and the readme explains each code. - Resume when possible.
statesurvives between calls (it's where the Gateway session id lives). After a deploy moves the socket,openresumes the Discord session instead of logging in again —resumable: truemakes that allowed and gets the faster resume rate. - 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
filtersso noise never costs a wake. - Let the service send heartbeats (
heartbeat+sequenceAt) instead of waking on a timer. - Turn permanent failures into
failwith a message the user can act on; everything else reconnects on its own. - Derive idempotency keys from
deliveryId.