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.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).
  • 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.

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

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.