FabricFabricHarness
Building Agents

Channels

Turn platform webhooks (Slack, GitHub, …) into agent dispatches.

Channels are how agents get triggered by the outside world. A webhook hits /channels/:name/*, the handler verifies the signature, normalizes the platform event, and dispatches to a persistent agent keyed by a stable conversation id. Outbound actions are tools bound at agent init.

The core seam lives in @fabric-harness/sdk; 17 vendor adapters live in @fabric-harness/channels behind subpath exports, so platform SDK code stays out of core. Handlers are written against the Web Request/Response API and crypto.subtle, so the same channel runs on Node and Cloudflare.

Why channels

  • Stateless. A channel is a route container plus a conversation-id (de)serializer. Session continuity falls out of the key — the same Slack thread → same key → same session.
  • Exactly-once. The platform event id (Slack event_id, GitHub X-GitHub-Delivery) becomes the dispatchId, so a webhook redelivery collapses to a single agent turn via the persistent-run idempotency marker.
  • Identity propagation. The platform user becomes the dispatch actor and the team/org becomes the tenantId — flowing into the audit trail and into on-behalf-of governance (e.g. Databricks UC).
  • Policy-gated outbound. Outbound actions are defineTool tools (effect: 'write'), so the agent's CapabilityPolicy can gate "can this agent post to Slack".

Slack

A channel lives in .fabricharness/channels/<name>.ts and exports a channel:

.fabricharness/channels/slack.ts
import { createSlackChannel } from '@fabric-harness/channels/slack';

export const channel = createSlackChannel({
  signingSecret: process.env.SLACK_SIGNING_SECRET!,
  agent: 'assistant', // dispatch app mentions / threaded messages here
});

The dev server mounts it at POST /channels/slack/events. Point your Slack app's Event Subscriptions → Request URL there and subscribe to app_mention. The handler verifies the v0 HMAC signature, answers the URL-verification challenge, and dispatches the event keyed by the thread.

Bind the reply tool to the thread at agent init — the instance id is the thread key:

.fabricharness/agents/assistant.ts
import { createAgent } from '@fabric-harness/sdk';
import { parseSlackConversationKey, replyInSlackThread } from '@fabric-harness/channels/slack';

export default createAgent(({ id }) => {
  const thread = parseSlackConversationKey(id);
  return {
    model: 'anthropic/claude-haiku-4-5',
    tools: [replyInSlackThread(thread, { botToken: process.env.SLACK_BOT_TOKEN! })],
  };
});

GitHub

Same shape, different signature scheme (X-Hub-Signature-256) and key (owner/repo/<kind>/<number>):

.fabricharness/channels/github.ts
import { createGitHubChannel } from '@fabric-harness/channels/github';

export const channel = createGitHubChannel({
  secret: process.env.GITHUB_WEBHOOK_SECRET!,
  agent: 'triage',
});

Mounted at POST /channels/github/webhook. It acknowledges ping, dispatches issue / PR / comment events (with X-GitHub-Delivery as the dedupe key, the owner as tenant, the sender as actor), and ignores bot senders to avoid loops. Outbound: commentOnGitHubIssue(ref, { token }).

Other first-party channels

Scaffold the provider module, environment template, dependency set, and starter test:

fh add channel discord
fh add channel teams
fh add channel telegram
fh add channel twilio
fh add channel whatsapp
fh add channel google-chat
fh add channel linear
fh add channel stripe
fh add channel zendesk

Each first-party adapter verifies the provider's native request authentication, assigns the provider event ID as dedupeKey, derives a stable conversation key, and propagates tenant/actor identity. Provider setup and outbound tool examples are in the channel catalog. Microsoft Teams includes Bot Connector key discovery and validates issuer, App ID audience, token lifetime, service URL, and channel endorsement without requiring an application JWT library.

The subpath exports include typed webhook payloads and conversation refs for custom routing. The built-in normalization covers the event families an agent commonly needs:

ProviderNormalized events
Slackmentions, messages, edited messages
GitHubopened, edited, reopened, synchronized, and issue/PR comments
Discordcommands and component callbacks
Teamsmessages, message updates, and deletes
Telegrammessages, edited messages, channel posts, and callback queries
TwilioSMS/WhatsApp messages and delivery statuses
WhatsApptext messages and delivery/read statuses
Google Chatmessages and interactions
Linearissue and project resource changes
Notionworkspace entity changes
Stripeaccount and financial resource events
Zendeskticket events
Intercomconversation and ticket notifications
Shopifyversioned commerce webhooks
Messengermessages and postbacks
Resendinbound and delivery email events
Salesforce Marketing Cloudsigned ENS event batches

Delivery-status dedupe keys include both message ID and status, so a sent event cannot suppress a later delivered or read event.

Authoring your own channel

A channel is defineChannel({ routes, conversationKey, parseConversationKey }). The SDK provides the building blocks: verifyHmacSha256 (constant-time), hexToBytes, conversationKey / parseConversationKey (url-safe), and readJsonBody (raw bytes for HMAC and parsed JSON from a single read). Inside a route handler, call ctx.dispatch(agent, { instanceId, input, dedupeKey, tenantId, actor }).

See also