Building
Channels
Connect agents to a chat app — people message the agent there, and it answers there.
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 |
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 for sockets.
The handlers
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
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:
// 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.
The inbound message
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.idis 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).- In a group conversation the agent only wakes when
mentionsBotis true — the same rule for every channel. - Attachments are references: the platform calls your
fetchAttachmentwith arefonly when it wants the file.
send — deliver the agent's reply
request: { conversationId: string; text: string; deliveryId: string | null }
deliveryIdis 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.
// 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)
request: { conversationId: string; lastInboundMessageId: string | null }
Called while the agent is working, so the user sees "typing…". Failures are harmless — swallow them:
// 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)
request: { ref: string; maxBytes: number }
returns: { contentBase64: string; contentType?: string }
Download the file behind a ref you returned. Use host.fetch with responseEncoding: "base64":
// 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":
// 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
- Install the plugin.
- Channels → Connect a channel, choose your plugin, pick the agent that answers, fill in the channel's settings.
- 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
Messages per receive |
20 |
| Text per message | 32,000 characters |
| Attachments per message | 5, about 3 MB each |
receive |
10 s; 15 s per attachment fetch; 25 s for the whole request |
| 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.