Building

Channels

Connect agents to a chat app — people message the agent there, and it answers there.

View as Markdown

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.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", 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

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

  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.