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; vendor adapters (Slack, GitHub) 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 }).

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