Bridge a Threaded Channel to the Agent Team
You are building an adapter that relays messages between a human
channel (GitHub Discussions, Microsoft Teams, the next chat platform
someone asks for) and the Kata agent team's
kata-dispatch workflow. The first time you do this, you
reach for last project's callback registry, rate limiter, and
history-bound prompt builder.
@forwardimpact/libbridge gives you those primitives so
the host service can focus on the channel-specific SDK glue and
leave thread state, callback verification, prompt construction, and
workflow dispatch to a shared library.
Prerequisites
- Node.js 22+
- Install the library and its peers:
npm install @forwardimpact/libbridge @forwardimpact/libstorage @forwardimpact/libindex
-
A workflow on the target repository that accepts the
channel-bridge payload via
workflow_dispatch(the Kata Agent Team'skata-dispatch.ymlis the reference implementation). -
A GitHub token with
actions:writeon that repository.
What libbridge owns
libbridge is channel-agnostic: it never imports
botbuilder, @octokit/*, or any
channel-specific SDK. The host service
(services/ghbridge, services/msbridge,
your next adapter) owns the SDK glue, signature verification, and
channel-shaped responses. libbridge owns the shared primitives every
adapter needs:
| Primitive | Purpose |
|---|---|
createBridgeServer |
Hono server wiring a channel webhook route and
/api/callback/:tenant_id/:token together
|
Acknowledgement |
Reaction-plus-optional-typing-verb lifecycle for "I received your message" feedback |
Dispatcher |
Composes callback registration, acknowledgement, workflow dispatch, history append, and rollback-on-failure into one call |
createCallbackHandler |
Inbound-callback skeleton with verdict routing (adjourned
/ failed / recessed) and span
instrumentation
|
ResumeScheduler |
Channel-agnostic suspend/resume lifecycle for
recessed verdicts; wraps
ElapsedScheduler
|
CallbackRegistry |
In-memory token registry with tenant-bound entries, TTL enforced at lookup, periodic sweep, and atomic consume |
DiscussionAdapter (typedef) |
The persistence contract every bridge implements:
loadByChannel, loadByCorrelation,
listOpenRecesses, add,
flush, shutdown (plus optional
putPendingDispatch /
resolvePendingDispatch)
|
newDiscussionContext |
Channel-agnostic factory for a fresh per-thread record, keyed
by (channel, discussion_id)
|
RateLimiter |
Sliding-window per-thread rate limit so a noisy channel cannot DoS the workflow |
ProgressTicker |
Tick-and-stop timer so the host can show progress while the workflow runs |
appendHistory |
Bounded message history (default cap: 10 entries; oldest dropped on overflow) |
buildPrompt |
Prompt builder that prepends recent history bounded by exchange count and char cap |
dispatchWorkflow |
GitHub Actions workflow_dispatch POST with the
agreed input shape
|
evaluateTrigger |
Caller-clock resume-trigger evaluation (kinds:
missing_input, elapsed,
escalation_needed)
|
parseIsoDuration |
ISO-8601 duration parser (P1D,
PT12H, P1DT6H) used by
evaluateTrigger
|
The top four — Acknowledgement,
Dispatcher, createCallbackHandler, and
ResumeScheduler — are the composition layer. A real
bridge wires the channel SDK into these constructors and lets each
one own its slice of the dance; the primitives below them are still
available when you need to step outside the shared composition.
Two injection rules keep the surface testable from any host.
Persistence is contract-injected: every libbridge
primitive that touches per-thread state (Dispatcher,
ResumeScheduler, createCallbackHandler,
createLinkCompleteHandler) takes a
store parameter satisfying the
DiscussionAdapter typedef, and the library never
constructs persistence on its own. The trigger evaluator is
clock-injected:
evaluateTrigger(trigger, observed, now) takes
now as a parameter, never calling
Date.now() inside the library.
Compose a bridge server
The minimum shape a channel adapter needs is a Hono server with a
channel-shaped webhook route and a workflow callback route.
createBridgeServer mounts both routes on a Hono app and
returns lifecycle handles. Both routes hand the raw Hono
Context to host-supplied callbacks — the host owns
signature verification, token redemption, and channel-shaped
responses:
import {
createBridgeServer,
CallbackRegistry,
} from "@forwardimpact/libbridge";
const store = createDiscussionAdapter(); // see "Persist per-thread context" below
const registry = new CallbackRegistry({ ttlMs: 60 * 60 * 1000, clock });
registry.startSweepTimer(); // periodic eviction of expired tokens
const bridge = createBridgeServer({
config: { host: "0.0.0.0", port: 8080 },
logger,
webhookPath: "/api/messages",
onWebhook: async (c) => {
const event = await verifyChannelSignature(c);
await handleChannelEvent({ event, store, registry });
return c.body(null, 200);
},
onCallback: async (c) => {
const tenantId = c.req.param("tenant_id");
const entry = registry.consume(c.req.param("token"), { tenant_id: tenantId });
if (!entry) return c.json({ error: "Unknown token" }, 404);
const payload = await c.req.json();
if (payload.correlation_id !== entry.correlationId) {
return c.json({ error: "Correlation ID mismatch" }, 400);
}
const ctx = await store.loadByChannel("example", entry.meta.discussionId);
if (payload.verdict === "adjourned") {
for (const reply of payload.replies) {
await postChannelMessage(ctx.discussion_id, reply.body);
}
} else if (payload.verdict === "failed") {
await postChannelMessage(ctx.discussion_id, `Failed: ${payload.summary}`);
}
return c.json({ ok: true }, 200);
},
});
await bridge.start();
createBridgeServer mounts
POST <webhookPath> and
POST /api/callback/:tenant_id/:token on a Hono app,
captures the raw POST body on
c.get("rawBody") for signature verification,
and returns { start, stop, app, address }. The host
owns lifecycle, the channel SDK, and the verdict-to-channel
translation (a GraphQL addDiscussionComment for GitHub,
a botbuilder activity for Teams, etc.).
Persist per-thread context
Each thread (a Discussion, a Teams conversation) carries its own
context record, keyed by (channel, discussion_id).
newDiscussionContext builds a fresh record so every
bridge agrees on the shape:
import { newDiscussionContext } from "@forwardimpact/libbridge";
const ctx = newDiscussionContext({
clock,
channel: "github-discussions",
discussionId,
participant: { name: "octocat", kind: "human", external_id: "1234" },
});
// {
// id: "github-discussions:<discussion_id>",
// channel, discussion_id,
// history: [], participants: [participant],
// open_rfcs: {}, lead: "release-engineer",
// pending_callbacks: {}, dispatches: [],
// active_requester: null, last_posted_seq: -1,
// last_active_at: <clock.now()>,
// }
The host owns persistence by implementing the
DiscussionAdapter typedef and passing the instance as
store to Dispatcher,
ResumeScheduler, createCallbackHandler,
and createLinkCompleteHandler. The contract:
/**
* @typedef {object} DiscussionAdapter
* @property {(channel: string, discussionId: string) => Promise<object|null>} loadByChannel
* @property {(correlationId: string) => Promise<object|null>} loadByCorrelation
* @property {() => Promise<Array<{correlationId: string, dueAt: number}>>} listOpenRecesses
* @property {(ctx: object) => Promise<void>} add
* @property {() => Promise<void>} flush
* @property {() => Promise<void>} shutdown
* @property {(target: object) => Promise<void>} [putPendingDispatch]
* @property {(linkToken: string, expectedSurfaceUserId?: string) => Promise<object|null>} [resolvePendingDispatch]
*/
A minimal in-process adapter — durable JSONL via
@forwardimpact/libindex and
@forwardimpact/libstorage, suitable for single-process
bridges:
import { BufferedIndex } from "@forwardimpact/libindex";
import { createStorage } from "@forwardimpact/libstorage";
import { appendHistory } from "@forwardimpact/libbridge";
function createInProcessAdapter({ clock }) {
const storage = createStorage("bridges/example");
const index = new BufferedIndex(storage, "discussions.jsonl", {}, { clock });
return {
async loadByChannel(channel, id) {
await index.loadData();
return index.index.get(`${channel}:${id}`) ?? null;
},
async loadByCorrelation(correlationId) {
await index.loadData();
for (const rec of index.index.values()) {
if (Object.values(rec.pending_callbacks ?? {}).includes(correlationId)) {
return rec;
}
if (rec.open_rfcs?.[correlationId]) return rec;
}
return null;
},
async listOpenRecesses() {
await index.loadData();
const refs = [];
for (const rec of index.index.values()) {
for (const [cid, rfc] of Object.entries(rec.open_rfcs ?? {})) {
if (typeof rfc.due_at === "number") {
refs.push({ correlationId: cid, dueAt: rfc.due_at });
}
}
}
return refs;
},
add: (ctx) => index.add(ctx),
flush: () => index.flush(),
shutdown: () => index.flush(),
};
}
const store = createInProcessAdapter({ clock });
const ctx = (await store.loadByChannel("github-discussions", discussionId))
?? newDiscussionContext({ clock, channel: "github-discussions", discussionId, participant });
appendHistory(ctx.history, { role: "user", text: "Should we add nested levels?" });
ctx.last_active_at = clock.now();
await store.add(ctx);
await store.flush();
For multi-process bridges, point the adapter at a shared backend
(Redis, Postgres, or a dedicated persistence service) so every
bridge replica sees the same
(channel, discussion_id) records and
pending_callbacks tokens survive restarts. The Kata
Agent Team's monorepo runs the canonical implementation — a
small gRPC service that owns the JSONL files and the TTL sweep — and
services/ghbridge / services/msbridge wrap
a generated client in a DiscussionAdapter to talk to
it. Implementations swap freely; libbridge only sees the contract.
Issue and verify callback tokens
A bridge dispatches a workflow run and waits for the workflow to
POST back its verdict. The host registers a
(correlationId, meta) pair —
meta.tenant_id is required — and receives a randomly
generated token; the host embeds the token in the callback URL; the
workflow echoes it; the host consumes the token once and rejects all
subsequent attempts. consume(token, { tenant_id }) is
atomic — it removes the entry and returns it in one call, and
returns null when the token is unknown, expired, or
bound to a different tenant. The default TTL is two hours, expired
entries are dropped at the lookup that observes them, and
startSweepTimer() evicts tokens whose dispatch never
calls back (every 10 minutes by default;
stopSweepTimer() cancels it). Use
peek(token, { tenant_id }) to inspect an entry without
consuming it.
import { randomUUID } from "node:crypto";
import {
CallbackRegistry,
dispatchWorkflow,
} from "@forwardimpact/libbridge";
const registry = new CallbackRegistry({ ttlMs: 60 * 60 * 1000, clock });
registry.startSweepTimer();
const correlationId = randomUUID();
const token = registry.register(correlationId, { tenant_id: tenantId, discussionId });
await dispatchWorkflow({
workflowFile: "kata-dispatch.yml",
ref: "main",
repo: "owner/repo",
token: ghInstallationToken,
prompt,
callbackUrl: `${publicUrl}/api/callback/${tenantId}/${token}`,
correlationId,
discussionId,
});
// In the `onCallback` handler passed to createBridgeServer:
async function onCallback(c) {
const entry = registry.consume(c.req.param("token"), {
tenant_id: c.req.param("tenant_id"),
});
if (!entry) return c.json({ error: "Unknown token" }, 404);
const payload = await c.req.json();
if (payload.correlation_id !== entry.correlationId) {
return c.json({ error: "Correlation ID mismatch" }, 400);
}
// …deliver replies, recess, or fail per payload.verdict…
return c.json({ ok: true }, 200);
}
The registry is in-memory; for multi-process bridges, persist
pending_callbacks on each discussion-context record
(via the adapter's add() call) so the host can
re-register tokens on restart. The
correlation_id echoes through the workflow and is
checked against the consumed entry's
correlationId to defend against token-and-payload
mismatches; the tenant binding ensures a token issued for one tenant
cannot redeem a callback addressed to another.
Evaluate recess triggers
Long-running RFCs use the libharness Recess verdict to
wait for an external signal. A trigger is one of three shapes, named
for the lead's intent:
-
{ kind: "missing_input", replies: N }— fire when at leastNnew replies have arrived on the dispatching thread since the recess opened. -
{ kind: "elapsed", elapsed: "P1D" }— fire after an ISO-8601 duration passes. Days, hours, minutes, seconds supported (P14D,PT12H,P1DT6H). -
{ kind: "escalation_needed", signal: "<name>" }— reserved for future use. The schema accepts this shape, but the scheduler throws until signal-based resume support ships.
evaluateTrigger(trigger, observed, now) returns
{ fired: boolean, due_at?: number } where
due_at is the absolute ms-epoch when an elapsed arm
will fire (useful for scheduling a wake-up). The host owns
now so unit tests stay deterministic:
import { evaluateTrigger } from "@forwardimpact/libbridge";
const trigger = { kind: "elapsed", elapsed: "P1D" };
const observed = { opened_at: Date.now() - 25 * 60 * 60 * 1000 };
const result = evaluateTrigger(trigger, observed, Date.now());
if (result.fired) {
await dispatchWorkflow({
workflowFile: "kata-dispatch.yml",
ref: "main",
repo: "owner/repo",
token: ghInstallationToken,
prompt: "Resume requested.",
callbackUrl,
correlationId: newCorrelationId,
discussionId,
resumeContext: JSON.stringify({
correlation_id: priorCorrelationId,
history_since: historySliceSinceRecess,
}),
});
}
evaluateTrigger is pure: it takes a trigger, an
observation ({ replies?, opened_at? }), and a clock
reading, and returns whether the observation satisfies the trigger.
The host calls it whenever a candidate event arrives — for
missing_input, on every new channel message; for
elapsed, on a host-scheduled wake-up at
due_at; escalation_needed throws today and
will integrate with channel signal intake once that spec lands.
Verify
You have reached the outcome of this guide when:
-
You can stand up a Hono server with channel-webhook and
/api/callback/:tenant_id/:tokenroutes viacreateBridgeServer, with the host's channel-specific SDK glue only insideonWebhookandonCallback. -
You can persist per-thread state by implementing the
DiscussionAdaptercontract —loadByChannel,loadByCorrelation,listOpenRecesses,add,flush,shutdown— and build fresh records vianewDiscussionContextkeyed by(channel, discussion_id). -
You can
registertenant-bound tokens, dispatch, and one-shotconsume(token, { tenant_id })throughCallbackRegistry, withcorrelation_idechoed end-to-end and expired tokens rejected at lookup. -
You can evaluate
missing_inputandelapsedrecess triggers against a caller-supplied clock and route the resume back throughdispatchWorkflowwith a JSON-encodedresume_context.escalation_neededtriggers parse but throw at evaluation until signal-based resume ships.