Skip to content

feat(channels): add stateless channels package - #2129

Merged
cjol merged 3 commits into
mainfrom
investigate/think-channels-package-prototype
Aug 24, 2026
Merged

feat(channels): add stateless channels package#2129
cjol merged 3 commits into
mainfrom
investigate/think-channels-package-prototype

Conversation

@cjol

@cjol cjol commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

This PR adds the experimental standalone @cloudflare/channels package: one
transport-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

  • Transport-specific tools couple model behaviour and application policy to one
    provider. Applications need one message and approval contract that can route
    through any of them, and one normalized event shape to route on.
  • The earlier revision of this branch had a durable ChannelHost: it required
    Durable 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.
  • It also could not deliver what it appeared to promise. A crash between the
    application callback and the receipt write replays the callback anyway, so
    ingress was never exactly-once.
  • The Host is now stateless. Applications own storage and idempotency; the
    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 DurableChannelHost wrapper around the stateless core
    stays possible, and should not ship until a concrete consumer proves the
    interface.
  • A Channel no longer carries a default destination. In a real conversation the
    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.
  • Adapters were deciding relevance below the routing seam. Slack discarded every
    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 onRoute never fired. Adapters now normalize what they receive
    and route decides.

Public API Surface

Symbol Kind Notes
ChannelHost class Stateless. Owns the Channel map, stamps and resolves destinations, dispatches normalized events. deliver, requestApproval, contactSurface, handleRequest, handleEmail
Channel interface route, ingress, emailIngress, contactSurface, deliver, requestApproval, all optional except as an adapter needs them
ChannelMessageSurface type { channelKey, version, address, label }, JSON, safe to persist
ChannelIdentity type { channelKey, scope?, subject }, a person as seen through one Channel
routes const perEvent, perThread, byIdentity(fallback?), byUser(fallback)
fallback, fanout functions Build composite surfaces; the Host resolves them recursively
fallbackChannel, fanoutChannel functions The policies behind those surfaces, so callers can register their own
createUserIdentityStore, identityKey, linkChannelIdentities functions Explicit identity linking over Durable Object SQL
matchesPath, isChannelMessageSurface functions Helpers for writing a Channel
email, telegram functions Adapters from the root entry point
slack, browserVoice functions Adapters from /slack and /voice, isolated so their weight is opt in
createSendMessageTool function /ai-sdk and /tanstack-ai, bound to a Host and a surface

Removed relative to the earlier revision of this branch: sharedAlarm, the
Host's storage and scheduler options and its delivery, retry, receipt and
approval-link machinery, ChannelDelivery, ChannelIngress.path,
ChannelEmailIngress.accepts, and createChannelTool. Surfaces lost provider
and kind; identities lost provider.

Architectural Changes

Before, the Host was the durable system of record, and the application read from
it:

provider -> adapter -> Host (storage, alarms, outbox, receipts, correlation)
                          -> application callback

Now the Host is a pure function of its configuration, and the application owns
every durable decision:

provider -> adapter (authenticate, normalize)
         -> Channel.route(event, raw, context) -> opaque application route
         -> application callback (deduplicate, store, decide)

application -> host.deliver(surface, message) -> Channel -> provider

Two consequences shape the rest of the diff. Ingress selection became
imperative and symmetric: an ingress returns null to decline, and the Host
takes the first Channel to claim an input, for HTTP and Workers Email alike.
And composite destinations became data: fallback([a, b]) produces a surface
rather than a Channel, which the Host resolves by recursing into ordinary
Channels it installs under reserved keys.

Code Changes

packages/channels

  • ChannelHost holds no storage, scheduler, outbox, retries, or dedupe, and
    gained the outbound half of the API so a persisted surface can be used without
    the caller remembering which Channel produced it.
  • Surfaces carry channelKey and a display label, both stamped by the Host,
    because an adapter cannot know the key it was configured under. kind went
    away once every branch on it could be answered by the address instead.
  • Identities are keyed by channelKey and scope rather 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.
  • Adapters moved under src/adapters/ with shared helpers in src/internal.ts.
  • Recursive arms of ChannelSurfaceValue are named interfaces. Workers RPC
    gates return types on R extends Rpc.Serializable<R>, which expands a
    self-recursive type alias until TypeScript gives up, so a surface could not be
    returned from a Durable Object method.

examples/channels

  • Rebuilt as teaching material: one file for Channel configuration and Host
    wiring, two plain Durable Objects, a small JSON API, and a UI kept in its own
    directory. It no longer depends on the Agents SDK.
  • Demonstrates cross-channel conversations. A support form submission and an
    email address linked to one user converge on a single conversation, answered
    through whichever Channel can reach that person.
  • Deliberately has no error handling, authentication, observability, or
    validation, and says so.

Docs

  • packages/channels/README.md is the only package documentation, organised by
    what you can do rather than by what the package contains.
  • design/channels.md records the decisions and the rejected alternatives, and
    deliberately 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 label is a snapshot rather than a
live lookup, so a renamed user or channel leaves stale text behind.

@changeset-bot

changeset-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9fe3844

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/channels Minor

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

devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Aug 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/cloudflare/agents@2129

@cloudflare/ai-chat

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/ai-chat@2129

@cloudflare/channels

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/channels@2129

@cloudflare/codemode

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/codemode@2129

hono-agents

npm i https://pkg.pr.new/cloudflare/agents/hono-agents@2129

@cloudflare/shell

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/shell@2129

@cloudflare/think

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/think@2129

@cloudflare/voice

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/voice@2129

@cloudflare/worker-bundler

npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/worker-bundler@2129

commit: 9fe3844

@cjol
cjol force-pushed the investigate/think-channels-package-prototype branch from e4630ee to 21aefde Compare August 18, 2026 10:22
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

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.
@cjol
cjol force-pushed the investigate/think-channels-package-prototype branch from f915dde to 9fe3844 Compare August 20, 2026 16:53

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +97 to +98
return (
<div className="flex h-dvh flex-col overflow-hidden bg-kumo-canvas">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@cjol cjol changed the title feat(channels): add durable channels package feat(channels): add stateless channels package Aug 20, 2026
@cjol
cjol merged commit 4890dc6 into main Aug 24, 2026
20 checks passed
@cjol
cjol deleted the investigate/think-channels-package-prototype branch August 24, 2026 13:18
@github-actions github-actions Bot mentioned this pull request Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant