@fabric-harness/channels
v0.24.0
Published
Event-ingress channels (Slack, GitHub, …) for TechFabric Harness agents.
Readme
@fabric-harness/channels
Event-ingress channels for TechFabric Harness — turn platform webhooks (Slack, GitHub, …) into agent
dispatches. The core Channel seam lives in @fabric-harness/sdk; this package provides the
17 vendor adapters behind subpath exports so vendor 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.
Maintained subpaths cover Slack, GitHub, Discord, Teams, Telegram, Twilio, WhatsApp, Google Chat,
Linear, Notion, Stripe, Zendesk, Intercom, Shopify, Messenger, Resend, and Salesforce Marketing
Cloud. Run fh add channel <name> to install a versioned recipe. Import channelCompatibility from
@fabric-harness/channels/compatibility for supported provider APIs and the deprecation contract.
Slack
// .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 events handler verifies the v0 HMAC signature, answers the URL-verification challenge, and
dispatches app_mention / threaded message events to the agent keyed by the Slack thread — with the
Slack event_id as the dedupe key (exactly-once), the team as tenantId, and the user as the
acting actor (which powers on-behalf-of governance).
Outbound, bind the reply tool to the thread at agent init:
import { replyInSlackThread } from '@fabric-harness/channels/slack';
export default createAgent(({ id }) => {
const thread = channel.parseConversationKey(id);
return { tools: [replyInSlackThread(thread, { botToken: process.env.SLACK_BOT_TOKEN! })] };
});Buzz (preview)
Buzz is a self-hostable Nostr-based workspace where humans and
agents share channels. Unlike webhook vendors, Buzz pushes over a NIP-01 WebSocket, so ingress uses
the buzz-tail transport shim: it holds a NIP-42-authenticated subscription and forwards events to
the channel's /events route inside a timestamped X-Fabric-Signature: v1= HMAC envelope (same
route a future Buzz workflow call_webhook egress can target). The handler additionally verifies
each inner event's Schnorr signature, so the acting actor is the cryptographically proven author
pubkey. Requires nostr-tools (declared dependency; the only non-Fetch/WebCrypto adapter, hence
status: preview + sdk: nostr-tools in the compatibility contract).
// .fabricharness/channels/buzz.ts
import { buzzPublicKey, createBuzzChannel } from '@fabric-harness/channels/buzz';
import { buzzState } from '../state/buzz.js';
const channels = ['gtm-pipeline'];
export const channel = createBuzzChannel({
agent: 'assistant',
sharedSecret: process.env.BUZZ_ENVELOPE_SECRET!,
selfPubkey: buzzPublicKey(process.env.BUZZ_PRIVATE_KEY!),
community: 'acme',
channels,
decisionReceiptStore: buzzState.deliveryStore,
onLifecycleEvent(notice) {
// Content-free evidence only: edits/deletions never enter model context.
console.info(JSON.stringify({ integration: 'buzz', ...notice }));
},
});// tail process (Node >= 22), co-deployed with the app
import { startBuzzTail } from '@fabric-harness/channels/buzz-tail';
import { buzzState } from '../state/buzz.js';
const channels = ['gtm-pipeline'];
startBuzzTail({
relayUrl: process.env.BUZZ_RELAY_URL!,
secretKey: process.env.BUZZ_PRIVATE_KEY!,
community: 'acme', // must equal the channel's server-owned community
channels, // same server-owned channel allowlist used by createBuzzChannel
forwardUrl: 'https://app/channels/buzz/events',
sharedSecret: process.env.BUZZ_ENVELOPE_SECRET!,
cursorStore: buzzState.cursorStore,
deadLetterStore: buzzState.deadLetterStore,
onOperationalEvent(event) {
// Content-free backfill, delivery, auth, reconnect, and shutdown evidence.
console.info(JSON.stringify({ integration: 'buzz', ...event }));
},
});For PostgreSQL or Databricks Lakebase, inject the application's existing pg.Pool into
the dependency-free structural client boundary. One scoped state object supplies all
three production stores:
import { createPostgresBuzzPersistence } from '@fabric-harness/channels/buzz-postgres';
import { Pool } from 'pg';
export const buzzState = createPostgresBuzzPersistence({
client: new Pool({ connectionString: process.env.DATABASE_URL }),
consumerId: 'gtm-primary', // stable and unique per deployed consumer
});createPostgresBuzzPersistence() initializes isolated cursor, dead-letter, and
decision-card-delivery tables. Pass initialize: false when migrations own DDL and call
ensurePostgresBuzzTables() from the migration boundary instead. The same contract works
with Lakebase clients that implement the structural query() method; no Databricks or
PostgreSQL dependency is loaded by the core channel adapter.
inspectPostgresBuzzHealth() is the read-only certification surface for one consumer. It
returns only cursor lag/age, unresolved dead-letter depth, and prepared/published card counts;
it never selects event, dead-letter, or decision-card payloads.
The default tail filter also observes stock Buzz message edits and event/channel deletions.
createBuzzChannel() never dispatches these lifecycle events to an agent: its optional
onLifecycleEvent sink receives a content-free notice with event ids, kind, author, scope, and
decisionEffect: "none". Deleting a reaction therefore cannot reverse an immutable decision already
committed by Platform. BUZZ_PROTOCOL_SUPPORT exposes this policy for capability discovery; direct
messages, media, voice, and free-form edit approval remain unsupported.
The tail is a durable consumer, not a best-effort reconnect loop: it persists a
cursor (advanced only after a 2xx forward or durably recorded dead-letter), pages a
complete bounded POST /query snapshot backward and forwards it oldest-first on every
(re)start, forwards in order with retry classification (408/429/5xx/network retry with bounded
backoff; other 4xx go to deadLetterStore for reconciliation), answers
NIP-42 challenges including OK acknowledgement and CLOSED re-subscription,
enforces NIP-11 limits, and propagates cancellation and per-request timeouts. It fails
closed when the cursor, backfill, or dead-letter store is unavailable. Use
reconcileBuzzDeadLetters() from a bounded scheduled worker to replay unresolved records
through the ordinary verified/governed ingress path. The channel route derives the tenant from
its own community option (server-owned configuration) and refuses envelopes
claiming any other community with 403 — the tenant is never taken from the
event or envelope.
onOperationalEvent emits content-free backfill counts, forwarding outcomes, authentication
renewals, reconnect attempts, and shutdown evidence. Sink failures are reported through onError
without interrupting durable consumption. Applications generated by Buzz recipe v3 wire both
operational and lifecycle events. Operators can preview and replay one bounded PostgreSQL/Lakebase
dead-letter batch through the normal signed ingress route with:
fh buzz reconcile --dry-run --limit 100
fh buzz reconcile --limit 100 --jsonRecords are acknowledged only after a 2xx ingress response. Reports contain counts and the consumer id, never stored event content.
Outbound tools: postInBuzzChannel, replyInBuzzThread, addBuzzReaction. The tail
also enforces NIP-11 at connect (requiredNips, default [1, 42]) and refuses relays
that do not advertise them; probeBuzzRelay is exported for standalone detection.
Decisions are deterministic and model-free (@fabric-harness/channels/buzz-decisions,
plan D14): postBuzzDecisionCard renders the canonical card from request data (never
agent prose), signs it, and persists the exact immutable event plus its card receipt
before relay I/O. A retry reuses the same Nostr event id, queries that id after an
ambiguous response, and only then marks publication. The receipt binds
(community, channel, cardEventId) to
(approvalRequestId, requestVersion, parametersHash, options, expiry). The
createBuzzDecisionBridge resolves a signed reaction through the receipt store and a
fixed emoji table (👍/✅/+ → approve, 👎/❌/- → reject) into exactly one
decision candidate carrying the attested Nostr identity and source-event proof; unmapped
emoji surface for audit, free-form replies resolve to edit proposals that require
structured confirmation, and everything else is ordinary conversation. The bridge
performs zero authorization and signals nothing — the vertical's governed decision
action owns binding resolution, capability, validation, and the decision transaction.
Stock Buzz Desktop emits NIP-25 reactions with an e target and no h channel tag. To
support that shape, give createBuzzChannel the same durable receipt store used by
postBuzzDecisionCard plus an explicit channels allowlist. The route derives the
channel only when the reaction has one target and exactly one receipt matches it inside
that allowlist. Missing, malformed, cross-channel, and ambiguous matches are ignored;
receipt-store failures fail the forward so the durable tail retries. Receipt correlation
only recovers transport scope: the normal governed identity, membership, expiry,
request-version, parameter-hash, and compare-and-swap checks remain mandatory downstream.
Migration from channels 0.18.x: postBuzzDecisionCard() now requires
deliveryStore: BuzzDecisionCardDeliveryStore instead of a write-only receiptStore.
The route still consumes its lookup-only decisionReceiptStore; in production both must
be views over the same durable store (buzzState.deliveryStore). This closes the
post-succeeded/receipt-write-failed split-brain window.
Vendored kind constants are guarded by scripts/sync-buzz-kinds.mjs against a Buzz
checkout's buzz-core/src/kind.rs.
