feat(channels): add stateless channels package - #2129
Conversation
🦋 Changeset detectedLatest commit: 9fe3844 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
agents
@cloudflare/ai-chat
@cloudflare/channels
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
e4630ee to
21aefde
Compare
Rework the experimental channels package around a stateless Host, and rebuild its example as teaching material rather than a demo of every feature. Package: - ChannelHost owns no storage, scheduler, outbox, retries, or dedupe. Applications own durability; the package supplies stable identities and honest delivery outcomes. The durability contract is documented. - A Channel has no implicit destination. Surfaces are self-contained JSON naming the configured Channel that can reach them, carrying a display label, stamped by the Host. `provider` and `kind` are gone. - Outbound resolution moves to the Host: deliver, requestApproval, and contactSurface. fallback and fanout become inert composite surfaces resolved by ordinary Channels the Host installs, so custom policies are possible. - Ingress selection is imperative and symmetric: an ingress returns null to decline, and the Host takes the first Channel to claim an input. Adapters no longer filter events that routing should decide, which fixes Slack silently dropping channel messages, Telegram dropping other chats, and email defaulting to a recipient filter. - Identities are keyed by channelKey and scope, so contacting someone uses the credentials of the Channel that saw them. Links stay explicit and are exposed to routing through `findUser`. - Routing helpers: perEvent, perThread, byIdentity, byUser. - Interaction ids are opaque; approval decisions route like any other event. Example: - One reference file for Channel configuration and Host wiring, two plain Durable Objects, a small JSON API, and a UI kept out of the way. - No Agents SDK dependency, no diagnostics pipeline, no defensive code. - Demonstrates cross-channel conversations: a support form and an email address linked to one user converge on a single conversation, answered through whichever Channel can reach them. Docs: - packages/channels/README.md is the only package documentation. - design/channels.md records the decisions and the rejected alternatives.
f915dde to
9fe3844
Compare
| return ( | ||
| <div className="flex h-dvh flex-col overflow-hidden bg-kumo-canvas"> |
There was a problem hiding this comment.
🟡 New example app is missing the required Cloudflare attribution footer
The new full-stack Channels example app renders a header and dark-mode toggle but never includes the Cloudflare attribution footer badge (examples/channels/src/ui/app.tsx:97-242), which the examples convention marks as required in every example.
Impact: The example ships without the mandated attribution element, diverging from every other full-stack example in the repo.
Rule source and how other examples comply
examples/AGENTS.md lists under "Required UI patterns": "PoweredByCloudflare (from @cloudflare/kumo) — footer attribution badge (required in every example)". A grep shows numerous full-stack examples (e.g. examples/agent-skills/src/client.tsx, examples/a2a/src/client.tsx) importing and rendering PoweredByCloudflare, but examples/channels contains no reference to it in any file. The Channels example is a full-stack Kumo-based app (index.html, src/client.tsx, vite.config.ts with the Cloudflare/React/Tailwind plugins), so the rule applies.
Prompt for agents
The examples/AGENTS.md convention requires every full-stack example to render the PoweredByCloudflare footer badge from @cloudflare/kumo. The Channels example app (examples/channels/src/ui/app.tsx) has no such footer. Import PoweredByCloudflare from @cloudflare/kumo and render it as a footer within the App component (and, if appropriate, the SupportFormPage in examples/channels/src/ui/support-form.tsx), matching how other examples such as examples/a2a/src/client.tsx place it.
Was this helpful? React with 👍 or 👎 to provide feedback.
This PR adds the experimental standalone
@cloudflare/channelspackage: onetransport-neutral way to receive and send messages over Slack, Telegram, email,
and transports of your own. It supersedes #2086, which was closed to restart
review against a focused package-only history.
Why
provider. Applications need one message and approval contract that can route
through any of them, and one normalized event shape to route on.
ChannelHost: it requiredDurable Object storage and a scheduler, and owned an outbox, retry backoff,
ingress receipts, provider reference indexes, approval link tokens, and
delivery preferences. That duplicates state a durable application already
keeps, and it pins deployment topology to a Durable Object, so a simple
integration cannot run in a plain Worker.
application callback and the receipt write replays the callback anyway, so
ingress was never exactly-once.
package supplies stable identities and honest per-attempt outcomes, and
documents that contract. We could have hidden durability inside each adapter,
but that produces inconsistent guarantees per provider and obscures which
layer owns retries. A
DurableChannelHostwrapper around the stateless corestays possible, and should not ship until a concrete consumer proves the
interface.
interesting destination is a particular person on whichever transport reaches
them, which a channel-wide default cannot express. Destinations are now
self-describing surfaces: JSON that names the configured Channel able to
deliver it. We considered pairing a Channel with a surface at the call site,
but that pair is just a surface missing a field, and we considered having the
Host offer a surface to each Channel until one claims it, but surfaces
resemble each other closely enough that mis-delivery through the wrong
configured instance would be silent.
channel message that was not a mention, Telegram discarded updates from any
chat other than the configured one, and email defaulted to filtering by
recipient. Each was undocumented and invisible: the ingress acknowledged the
provider and
onRoutenever fired. Adapters now normalize what they receiveand
routedecides.Public API Surface
ChannelHostdeliver,requestApproval,contactSurface,handleRequest,handleEmailChannelroute,ingress,emailIngress,contactSurface,deliver,requestApproval, all optional except as an adapter needs themChannelMessageSurface{ channelKey, version, address, label }, JSON, safe to persistChannelIdentity{ channelKey, scope?, subject }, a person as seen through one ChannelroutesperEvent,perThread,byIdentity(fallback?),byUser(fallback)fallback,fanoutfallbackChannel,fanoutChannelcreateUserIdentityStore,identityKey,linkChannelIdentitiesmatchesPath,isChannelMessageSurfaceemail,telegramslack,browserVoice/slackand/voice, isolated so their weight is opt increateSendMessageTool/ai-sdkand/tanstack-ai, bound to a Host and a surfaceRemoved relative to the earlier revision of this branch:
sharedAlarm, theHost's
storageandscheduleroptions and its delivery, retry, receipt andapproval-link machinery,
ChannelDelivery,ChannelIngress.path,ChannelEmailIngress.accepts, andcreateChannelTool. Surfaces lostproviderand
kind; identities lostprovider.Architectural Changes
Before, the Host was the durable system of record, and the application read from
it:
Now the Host is a pure function of its configuration, and the application owns
every durable decision:
Two consequences shape the rest of the diff. Ingress selection became
imperative and symmetric: an ingress returns
nullto decline, and the Hosttakes the first Channel to claim an input, for HTTP and Workers Email alike.
And composite destinations became data:
fallback([a, b])produces a surfacerather than a Channel, which the Host resolves by recursing into ordinary
Channels it installs under reserved keys.
Code Changes
packages/channelsChannelHostholds no storage, scheduler, outbox, retries, or dedupe, andgained the outbound half of the API so a persisted surface can be used without
the caller remembering which Channel produced it.
channelKeyand a displaylabel, both stamped by the Host,because an adapter cannot know the key it was configured under.
kindwentaway once every branch on it could be answered by the address instead.
channelKeyandscoperather than a provider name,so contacting someone uses the credentials of the Channel that saw them. With
two Slack Channels for two workspaces, provider matching selected whichever
was configured first.
src/adapters/with shared helpers insrc/internal.ts.ChannelSurfaceValueare named interfaces. Workers RPCgates return types on
R extends Rpc.Serializable<R>, which expands aself-recursive type alias until TypeScript gives up, so a surface could not be
returned from a Durable Object method.
examples/channelswiring, two plain Durable Objects, a small JSON API, and a UI kept in its own
directory. It no longer depends on the Agents SDK.
email address linked to one user converge on a single conversation, answered
through whichever Channel can reach that person.
validation, and says so.
Docs
packages/channels/README.mdis the only package documentation, organised bywhat you can do rather than by what the package contains.
design/channels.mdrecords the decisions and the rejected alternatives, anddeliberately describes no API so that it cannot go stale.
docs/channels/is deleted while the interface moves this quickly.Compatibility
The package is unreleased, so there is nothing to migrate. Reviewers who read
the earlier revision of this branch should know the durable Host API is gone
entirely rather than deprecated.
Two shapes are now durable data, and worth attention in review: configured
Channel keys appear inside every persisted surface and identity, so renaming one
orphans stored destinations, and a surface
labelis a snapshot rather than alive lookup, so a renamed user or channel leaves stale text behind.