Chat v2: Channels #237
FredKSchott
started this conversation in
Feature Request
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
RFC
First-Party Channels:
@flue/channelsfor Slack, Discord, and GitHubWhat a channel is
A channel is a package module that owns the boilerplate of connecting one platform to a Flue app:
app.tswith one line.dispatch(). Routing stays in your code; the channel never auto-routes into agents.slack.tools.replyInThread(ref)). Trusted code binds the destination; the model only picks content.Critically, a channel is plain code built on public primitives (
dispatch,defineTool, Fetch, Web Crypto). NodefineChannelruntime abstraction, nochannels/discovery, no/channels/*mounting, no generic emit/on event bus — the things removed indc6f6a5stay removed. The "hooks for building your own channel" are the primitives themselves plus a small sharedChannelinterface (essentially{ fetch(request): Promise<Response> }, with a reserved slot for future lifecycle). Someone wiring up Chat SDK is just building a channel this same way; Chat SDK remains a documented alternative.What it looks like (Slack sketch)
Note
slack.on(...)is not the old emitter mistake: the olddefineChannelwas a generic framework emitter where users wrote both sides. Here the events are owned and typed by the provider package — normal SDK design (Octokit webhooks work this way). The durable boundary remainsdispatch().Packaging
One package,
@flue/channels, with per-provider subpath exports (@flue/channels/github,/slack,/discord):fetch. Payload typings are type-only dev dependencies.flueumbrella package as one dependency.@flue/runtimeto preserve the layering proof: if first-party channels needed private runtime hooks, third parties couldn't build their own. v1 requires zero runtime/CLI changes.v1 scope: HTTP ingress only — and why
Slack Socket Mode and the Discord Gateway are persistent WebSocket connections your server opens outbound to receive events without a public URL. They require a long-lived process — fundamentally incompatible with request-scoped Workers, and they'd need a new Node-only start/stop lifecycle in Flue. Deferred. v1 channels are HTTP-only, identical on Node and Cloudflare:
@flue/channels/github@flue/channels/slack@flue/channels/discordConsequences accepted explicitly:
The shared
Channelinterface reservesstart?/stop?so a future Node lifecycle slots in without redesign.Replies, and per-event capability tokens
Replies are per-channel, platform-native tools — no universal reply/thread abstraction. Cross-channel consistency is structural, not semantic: every channel configures, mounts, registers events, and exposes tool factories the same way; only the vocabulary is platform-specific.
One investigated finding shapes the Discord design: per-event capability tokens (Discord's 15-minute interaction token, Slack's interactivity
response_url) cannot cleanly reach tool execution today — tools receive only model-supplied arguments, and dispatch input isn't exposed to the initializer or tool context. Channel-side persistence wouldn't fix it (on Cloudflare, ingress runs in the Worker while tools run in the agent's Durable Object). So:tools.postMessage(channelRef)— no per-event capability needed.response_urlas motivating cases.Channels also hold no server-side state in v1 and do not use Flue's persistence layer — that layer is intentionally internal (sessions, queues, streams) with no app-facing storage API. OAuth installations, gateway resume state, and dedup stores arrive only with their already-deferred features.
Interactive cards / HITL
All three platforms share a "post actionable thing → receive structured action event" loop (Slack Block Kit, Discord components, GitHub comment commands). A shared approval-card abstraction is plausible but out of scope for v1 — kept as a design constraint: event/tool shapes must allow a shared layer to compile down to them later. A design-validation sketch (approve/reject card on Slack + Discord) runs last, before first publish, so any API-shape corrections are free.
Build order and runtime impact
GitHub first (simplest, validates the contract) → Slack (ack deadlines, threads, richest reply tools) → Discord (hardest, interaction semantics) → docs + card-sketch validation → release.
Runtime/CLI changes required: none. First work item: delete the dead
packages/connectors/deprecation stub. v1 is single-workspace token config; multi-workspace OAuth is out of scope but not blocked by the config shape.Click to expand this summary
Status and scope
This plan replaces
plans/2026-05-24-optional-authored-channels-direct-sdk-integrations-and-channel-hardening.mdand supersedes the channel portions ofplans/resource-routes-stateful-channels-and-global-dispatch.mdandplans/stateful-channels-agent-listeners-and-global-dispatch.md. The framework has changed materially since those documents were written:dc6f6a5("refactor: remove channels concept"):defineChannel(...),channels/*discovery,/channels/:name/*mounting, channel docs, and both channel examples are gone.dispatch(...)survived and is the supported external-input primitive:dispatch(agent, { id, input })/dispatch({ agent, id, input })from@flue/runtime, with durable admission on Cloudflare and on Node when adb.tspersistence adapter is configured.f5f2928); dispatched input lands in the instance's default session. The agent instanceidis now the conversation-identity surface.routemiddleware.apps/docs/.../guide/chat.md,examples/chat-sdk/): application-owned webhook route →dispatch()with a thread-derived instance id → replies through explicit agent tools scoped by trusted code.This plan defines what first-party channel support should be, given that baseline.
Lessons carried forward
The removed
defineChannelwas a generic in-process event emitter (emit/on) plus a mounted Hono app. It abstracted the easy part (calling a function in the same build) while skipping every hard part (verification, ack deadlines, outbound APIs, retries), and its{ invoked, errors }aggregation made it easy to acknowledge webhooks whose work had been lost. Its removal was correct, and this plan does not reintroduce it.The durable boundary in Flue is
dispatch(), not an event bus. Everything between the provider anddispatch()is ordinary application code; the value of a first-party channel is owning the provider-specific parts of that code, not inventing new framework semantics.What a channel is
A channel is a provider integration module that owns the boilerplate of connecting one platform to a Flue application:
app.tswith one line.dispatch(). Routing into agents always remains explicit user code; a channel never auto-routes.slack.tools.replyInThread(ref)). Trusted code binds the destination; the model only chooses content.Critically, a channel is plain code built on public primitives (
dispatch,defineTool, Fetch, Web Crypto). v1 requires zero changes to@flue/runtimeor@flue/cli. The "hooks for building your own channel" are the existing primitives plus a small shared contract type. A user wiring up Chat SDK, or any provider we do not cover, builds their channel exactly the way ours are built.Packaging
One package:
@flue/channels, with per-provider subpath exports:Decision rationale:
fetch+ JSON. Provider payload typings come from type-only dev dependencies or vendored types, which ship no code.flueumbrella package as one dependency.@flue/runtimepreserves the layering proof: if first-party channels needed private runtime hooks, third parties could not build their own. Independent versioning also decouples provider-API churn from runtime releases.Naming note: the term "connector" is already used by the
flue add <name>instruction-scaffolding flow, so "channel" does not collide. The old transport-manifest naming conflict is resolved (channels:was renamedtransports:in the runtime manifest). Aflue add slack-style scaffold that writes the channel module andapp.tsmount on top of@flue/channelsis a natural follow-up, out of scope here.Cleanup item:
packages/connectors/is a private, unreferenced deprecation stub (package.json + README only) left behind whenflue addreplaced it. Delete it at the start of this work.Public API design
Shared contract (
@flue/channels)The root subpath also exports the small ingress utilities our providers share and third-party channel authors need: timing-safe comparison, HMAC-SHA256 signing/verification helpers, and an Ed25519 verification helper. Nothing else: no event-bus, no registry, no mounting framework.
Mounting uses Hono's existing fetch-mounting support; no Flue routing changes:
Authoring pattern
A channel instance is created in a plain module (no framework discovery), imported by
app.tsfor mounting and by agent modules for tool factories:slack.on(...)is not the removed emitter pattern: events are owned and typed by the provider package (the way Octokit webhooks or Chat SDK handlers work), not declared by users on both sides of a generic bus. The durable boundary remainsdispatch().Event handling and acknowledgement semantics
Handlers run inline within the ingress request; the provider response depends on handler outcome:
401without invoking handlers.dispatch()) produces a provider-visible failure (500) so the provider retries. A channel must never acknowledge a delivery whose required work was not admitted.X-GitHub-Delivery, Slackevent_id+X-Slack-Retry-Num, Discord interactionid) are surfaced on every event so applications can implement idempotency; retries after partial success can duplicate dispatched input, and dispatched input should carry the delivery id.dispatch()is admission-only and fast, so inline handling fits Slack's 3-second ack deadline; channels do not need background-ack machinery in v1.Conversation identity
Each channel defines a stable, parseable conversation key and helpers in both directions:
channel.conversationKey(event)→ string suitable as an agent instanceid(e.g. Slackteam:channel:threadTs, Discordguild:channel, GitHubowner/repo#issue).channel.parseConversationKey(id)→ typed destination ref accepted by tool factories.This makes the documented "thread as instance id" convention concrete without forcing it: applications with account- or repo-level identity can use their own ids and construct destination refs explicitly. Tool factories accept refs, never raw model-chosen strings, preserving the rule that trusted code selects outbound destinations.
Outbound tools and clients
Outbound capabilities are platform-native; there is no universal reply/thread/message abstraction. Cross-channel consistency is structural, not semantic: every channel configures, mounts, registers events, and exposes
tools.*factories the same way; only the vocabulary is provider-specific.Each provider exposes:
channel.client— a thin typedfetch-based API client for the operations our tools need, with an escape hatch for arbitrary authenticated calls. Users may ignore it and use official SDKs.channel.tools.*— factories returningdefineTool(...)values pre-bound to a destination ref and the channel's credentials.Configuration
Factories take explicit config values. Module-scope creation with
process.envworks on Node and on Workers withnodejs_compatenv population; the docs note this requirement. Config resolution is lazy (validated at first use, not at import) so module-scope channel creation does not crash builds or cold starts that never receive provider traffic. v1 is single-workspace/single-app token configuration; OAuth installation/multi-tenant flows are out of scope but must not be blocked by the config shape (config fields that would become per-installation lookups — bot tokens — are isolated from config fields that are app-global — signing secrets).Provider scope (v1: HTTP ingress only)
Slack Socket Mode and the Discord Gateway are persistent WebSocket connections a server opens outbound. They require a long-lived process, are incompatible with request-scoped Workers, and would need a new Node-only start/stop lifecycle in Flue. They are deferred; the
Channelinterface reservesstart/stopso a future Node lifecycle slots in without redesign. v1 channels are HTTP-only and behave identically on Node and Cloudflare.@flue/channels/github@flue/channels/slack@flue/channels/discordConsequences accepted explicitly:
cloudflared,ngrok) so providers can reach the webhook. Documentation covers this; Socket Mode later improves the local story.Provider-specific design notes
GitHub (build first; simplest, validates the contract):
X-Hub-Signature-256; typedon('issues.opened', ...)-style events using type-only payload typings.tools.commentOnIssue(ref),tools.addLabels(ref)as initial factories; client covers issue/PR comment and label endpoints.Slack (second; richest conversational surface):
fetch; URL-verification challenge handled internally; signature verification with 5-minute timestamp window.eventId,retryNum, team/channel/thread refs.conversationKeyusesteam:channel:thread_ts(falling back to the messagetsas thread root).tools.replyInThread(ref),tools.addReaction(ref)initially; client wrapschat.postMessage,reactions.add.Discord (third; hardest, stress-tests the capability-binding pattern):
tools.followUp(interactionRef), where the interaction token travels inside the dispatched input. Tokens expire after 15 minutes, so channels also providetools.postMessage(channelRef)via the bot token for slow replies.dispatch()input into pre-scoped tools without new runtime support. If it cannot be expressed cleanly, that is a finding to bring back to design, not to patch with runtime hooks.Interactive cards / human-in-the-loop (design constraint, not deliverable)
All three platforms share a loop: post an actionable element (Slack Block Kit, Discord components, GitHub comment commands) → receive a structured action event → resume work. A shared "card"/approval abstraction is plausibly valuable for human-in-the-loop flows but is out of scope for v1.
Design constraint now: each channel's interaction events and "post message with actions" tools must be shaped so a shared layer could later compile down to them — i.e., action events carry a stable application-chosen action id and structured values, and posting tools accept provider-native component payloads rather than only plain text.
Validation exercise, ordered last before release: sketch (on paper, against the built APIs) an approve/reject approval card on Slack and Discord. If the sketch reveals API-shape problems, fix them before the first publish, while breaking changes are free.
Non-goals
defineChannel-style runtime abstraction, generic emit/on event bus,channels/*discovery, or/channels/:name/*framework mounting.dispatch()calls are always explicit user code.@flue/channels; custom routes +dispatch()and third-party SDKs (including Chat SDK) remain fully supported and documented.Implementation plan
Phase 0: Repo preparation and shared contract
packages/connectors/stub.packages/channels/(@flue/channels) following existing package conventions (tsdown build, vitest, subpath exports map, Node ≥ 22.18 engine).Channelinterface, timing-safe compare, HMAC-SHA256 helpers, Ed25519 verify helper.Phase 1: GitHub channel
createGitHubChannel({ webhookSecret, token? })with verifiedfetch, typed events, delivery-id surfacing, handler-failure →500semantics.conversationKey/parseConversationKeyfor repo/issue refs;clientand initial tool factories (commentOnIssue,addLabels).examples/github-channel/— webhook → dispatch → agent replies with a comment tool; runnable on Node and Cloudflare.Phase 2: Slack channel
createSlackChannel({ signingSecret, botToken }): events + interactivity ingress, URL verification, timestamp-window signature checks, retry-header surfacing.client(chat.postMessage,reactions.add), tool factories (replyInThread,addReaction).Phase 3: Discord channel
createDiscordChannel({ publicKey, applicationId, botToken }): Ed25519 verification, PING handling, typed command/component/modal handlers returning interaction responses, deferred-response support.tools.followUp(interactionRef)andtools.postMessage(channelRef).Phase 4: Docs, design validation, release
guide/chat.mdto position first-party channels, Chat SDK, and bare routes +dispatch()as the three supported styles.@flue/channelsvia the standard release flow.Test plan
Design tests from the channel's observable contract (ingress request in, dispatch admission + provider response out; tool invocation in, provider API request out), using a narrow fetch-transport fixture for outbound calls and the runtime's real test harness for dispatch where practical.
401) without invoking handlers.dispatch()rejects) yields a provider-visible failure response.followUpcorrectly, including token-expiry signaling.Decision log
dispatch()@flue/channelspackage, per-provider subpaths, zero runtime depsChannelreservesstart/stopCompletion criteria
packages/connectors/is removed.@flue/channelsships GitHub, Slack, and Discord channels meeting the test plan, working on Node and Cloudflare targets with no@flue/runtime/@flue/clichanges.dispatch(), and third-party SDKs as supported styles, with ack/retry/idempotency and local-dev tunnel guidance.Beta Was this translation helpful? Give feedback.
All reactions