Skip to main content

Building Channel Plugins

This guide walks through building a channel plugin that connects FluffBuzz to a messaging platform. By the end you will have a working channel with DM security, pairing, reply threading, and outbound messaging.
If you have not built any FluffBuzz plugin before, read Getting Started first for the basic package structure and manifest setup.

How channel plugins work

Channel plugins do not need their own send/edit/react tools. FluffBuzz keeps one shared message tool in core. Your plugin owns:
  • Config — account resolution and setup wizard
  • Security — DM policy and allowlists
  • Pairing — DM approval flow
  • Outbound — sending text, media, and polls to the platform
  • Threading — how replies are threaded
Core owns the shared message tool, prompt wiring, session bookkeeping, and dispatch.

Walkthrough

1

Package and manifest

Create the standard plugin files. The channel field in package.json is what makes this a channel plugin:
{
  "name": "@myorg/fluffbuzz-acme-chat",
  "version": "1.0.0",
  "type": "module",
  "fluffbuzz": {
    "extensions": ["./index.ts"],
    "setupEntry": "./setup-entry.ts",
    "channel": {
      "id": "acme-chat",
      "label": "Acme Chat",
      "blurb": "Connect FluffBuzz to Acme Chat."
    }
  }
}
2

Build the channel plugin object

The ChannelPlugin interface has many optional adapter surfaces. Start with the minimum — id and setup — and add adapters as you need them.Create src/channel.ts:
src/channel.ts
import {
  createChatChannelPlugin,
  createChannelPluginBase,
} from "fluffbuzz/plugin-sdk/core";
import type { FluffBuzzConfig } from "fluffbuzz/plugin-sdk/core";
import { acmeChatApi } from "./client.js"; // your platform API client

type ResolvedAccount = {
  accountId: string | null;
  token: string;
  allowFrom: string[];
  dmPolicy: string | undefined;
};

function resolveAccount(
  cfg: FluffBuzzConfig,
  accountId?: string | null,
): ResolvedAccount {
  const section = (cfg.channels as Record<string, any>)?.["acme-chat"];
  const token = section?.token;
  if (!token) throw new Error("acme-chat: token is required");
  return {
    accountId: accountId ?? null,
    token,
    allowFrom: section?.allowFrom ?? [],
    dmPolicy: section?.dmSecurity,
  };
}

export const acmeChatPlugin = createChatChannelPlugin<ResolvedAccount>({
  base: createChannelPluginBase({
    id: "acme-chat",
    setup: {
      resolveAccount,
      inspectAccount(cfg, accountId) {
        const section =
          (cfg.channels as Record<string, any>)?.["acme-chat"];
        return {
          enabled: Boolean(section?.token),
          configured: Boolean(section?.token),
          tokenStatus: section?.token ? "available" : "missing",
        };
      },
    },
  }),

  // DM security: who can message the bot
  security: {
    dm: {
      channelKey: "acme-chat",
      resolvePolicy: (account) => account.dmPolicy,
      resolveAllowFrom: (account) => account.allowFrom,
      defaultPolicy: "allowlist",
    },
  },

  // Pairing: approval flow for new DM contacts
  pairing: {
    text: {
      idLabel: "Acme Chat username",
      message: "Send this code to verify your identity:",
      notify: async ({ target, code }) => {
        await acmeChatApi.sendDm(target, `Pairing code: ${code}`);
      },
    },
  },

  // Threading: how replies are delivered
  threading: { topLevelReplyToMode: "reply" },

  // Outbound: send messages to the platform
  outbound: {
    attachedResults: {
      sendText: async (params) => {
        const result = await acmeChatApi.sendMessage(
          params.to,
          params.text,
        );
        return { messageId: result.id };
      },
    },
    base: {
      sendMedia: async (params) => {
        await acmeChatApi.sendFile(params.to, params.filePath);
      },
    },
  },
});
Instead of implementing low-level adapter interfaces manually, you pass declarative options and the builder composes them:
OptionWhat it wires
security.dmScoped DM security resolver from config fields
pairing.textText-based DM pairing flow with code exchange
threadingReply-to-mode resolver (fixed, account-scoped, or custom)
outbound.attachedResultsSend functions that return result metadata (message IDs)
You can also pass raw adapter objects instead of the declarative options if you need full control.
3

Wire the entry point

Create index.ts:
index.ts
import { defineChannelPluginEntry } from "fluffbuzz/plugin-sdk/core";
import { acmeChatPlugin } from "./src/channel.js";

export default defineChannelPluginEntry({
  id: "acme-chat",
  name: "Acme Chat",
  description: "Acme Chat channel plugin",
  plugin: acmeChatPlugin,
  registerFull(api) {
    api.registerCli(
      ({ program }) => {
        program
          .command("acme-chat")
          .description("Acme Chat management");
      },
      { commands: ["acme-chat"] },
    );
  },
});
defineChannelPluginEntry handles the setup/full registration split automatically. See Entry Points for all options.
4

Add a setup entry

Create setup-entry.ts for lightweight loading during onboarding:
setup-entry.ts
import { defineSetupPluginEntry } from "fluffbuzz/plugin-sdk/core";
import { acmeChatPlugin } from "./src/channel.js";

export default defineSetupPluginEntry(acmeChatPlugin);
FluffBuzz loads this instead of the full entry when the channel is disabled or unconfigured. It avoids pulling in heavy runtime code during setup flows. See Setup and Config for details.
5

Handle inbound messages

Your plugin needs to receive messages from the platform and forward them to FluffBuzz. The typical pattern is a webhook that verifies the request and dispatches it through your channel’s inbound handler:
registerFull(api) {
  api.registerHttpRoute({
    path: "/acme-chat/webhook",
    auth: "plugin", // plugin-managed auth (verify signatures yourself)
    handler: async (req, res) => {
      const event = parseWebhookPayload(req);

      // Your inbound handler dispatches the message to FluffBuzz.
      // The exact wiring depends on your platform SDK —
      // see a real example in extensions/msteams or extensions/googlechat.
      await handleAcmeChatInbound(api, event);

      res.statusCode = 200;
      res.end("ok");
      return true;
    },
  });
}
Inbound message handling is channel-specific. Each channel plugin owns its own inbound pipeline. Look at bundled channel plugins (e.g. extensions/msteams, extensions/googlechat) for real patterns.
6

Test

Write colocated tests in src/channel.test.ts:
src/channel.test.ts
import { describe, it, expect } from "vitest";
import { acmeChatPlugin } from "./channel.js";

describe("acme-chat plugin", () => {
  it("resolves account from config", () => {
    const cfg = {
      channels: {
        "acme-chat": { token: "test-token", allowFrom: ["user1"] },
      },
    } as any;
    const account = acmeChatPlugin.setup!.resolveAccount(cfg, undefined);
    expect(account.token).toBe("test-token");
  });

  it("inspects account without materializing secrets", () => {
    const cfg = {
      channels: { "acme-chat": { token: "test-token" } },
    } as any;
    const result = acmeChatPlugin.setup!.inspectAccount!(cfg, undefined);
    expect(result.configured).toBe(true);
    expect(result.tokenStatus).toBe("available");
  });

  it("reports missing config", () => {
    const cfg = { channels: {} } as any;
    const result = acmeChatPlugin.setup!.inspectAccount!(cfg, undefined);
    expect(result.configured).toBe(false);
  });
});
pnpm test -- extensions/acme-chat/
For shared test helpers, see Testing.

File structure

extensions/acme-chat/
├── package.json              # fluffbuzz.channel metadata
├── fluffbuzz.plugin.json      # Manifest with config schema
├── index.ts                  # defineChannelPluginEntry
├── setup-entry.ts            # defineSetupPluginEntry
├── api.ts                    # Public exports (optional)
├── runtime-api.ts            # Internal runtime exports (optional)
└── src/
    ├── channel.ts            # ChannelPlugin via createChatChannelPlugin
    ├── channel.test.ts       # Tests
    ├── client.ts             # Platform API client
    └── runtime.ts            # Runtime store (if needed)

Advanced topics

Threading options

Fixed, account-scoped, or custom reply modes

Message tool integration

describeMessageTool and action discovery

Target resolution

inferTargetChatType, looksLikeId, resolveTarget

Runtime helpers

TTS, STT, media, subagent via api.runtime

Next steps