Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/quiet-channels-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/channels": minor
---

Add the experimental `@cloudflare/channels` package: one transport-neutral way to receive and send messages over Slack, Telegram, email, and channels of your own.

A stateless `ChannelHost` authenticates and normalizes provider input, and your application decides where each event belongs and how to store it. Outbound delivery reports honest per-attempt outcomes, approvals can be rendered natively by each provider, and channel identities can be linked to your own users.
173 changes: 173 additions & 0 deletions design/channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Channels design

Why `@cloudflare/channels` is shaped the way it is, and what we decided against.

This document records decisions and reasoning only. It deliberately describes no
API, so nothing in it goes stale as the package changes. For what the package
currently does, read [`packages/channels/README.md`](../packages/channels/README.md).

## The core decision: Channels is stateless

Channels authenticates and normalizes provider input, chooses an application
route, and delivers output. It owns no storage, no scheduler, no outbox, no
retries, and no deduplication. Every durability concern belongs to the
application.

The reason is ownership, not minimalism. An application that handles messages
already needs somewhere durable to put them — an Agent, a Durable Object, a
queue, a workflow, a database — and that store is where its own domain state
lives. A messaging library that keeps a second, private store of intents,
attempts, receipts, and preferences ends up competing with the first: two
records of the same conversation, two ideas of what was delivered, and no clear
answer about which one is true after a crash. It also fixes deployment topology,
because durability inside the library means the library needs Durable Object
storage, which means a simple integration cannot run in a plain Worker.

So the package's job is not to provide durability but to make
application-owned durability _possible_: identities that stay stable across
redelivery and rerouting, outcomes that are honest about what actually happened,
and destinations that are plain data an application can persist and use later.
The obligations this places on callers are written down as a durability contract
in the package README, because a guarantee nobody reads is not a guarantee.

### What we rejected

- **Keeping the durable Host.** The original Host required Durable Object
storage and a scheduler, and owned an outbox, retry backoff, ingress receipts,
provider-reference indexes, approval settlement tombstones, and delivery
preferences. It was genuinely useful, and it duplicated what durable callers
already have. It also never delivered exactly-once ingress — a crash between
the callback and its receipt write replays the callback anyway — so the
guarantee it appeared to offer was not one it could keep.
- **Hiding durability in each provider adapter.** This produces inconsistent
guarantees per provider, duplicates storage logic, and makes it impossible to
say which layer owns retries and idempotency.
- **A durable wrapper alongside the stateless core.** Not rejected, deferred. A
`DurableChannelHost` implementing an outbox and inbox around the stateless
core stays possible, but must not ship until a concrete consumer proves the
interface. Speculatively adding it would re-import every problem above.

## Decisions that follow

### Routing is application policy, attached to each Channel

Each Channel carries a `route` function that turns a normalized event into an
opaque application string, or declines the event. Routing happens after
authentication and normalization, so adapters need no knowledge of application
structure, and the application needs no knowledge of provider payloads.

Declining is spelled with an explicit null rather than by returning nothing, so
that an accidental fallthrough in a route function cannot silently drop
messages, and so an absent route stays distinguishable from a deliberate ignore.
Adapters may discard protocol noise — bot echoes, edit events, joins, and other
provider chrome — but not authenticated human messages merely because they do
not look relevant. Direct-message, mention, and thread relevance are application
policy, so valid messages must reach routing along with the authenticated raw
payload needed to decide.

_Rejected:_ a Host-level routing table, which puts application policy in library
configuration; adapters evaluating their own routes, which would deny the
application the raw payload at the moment it decides; and adapter-level relevance
filters that silently discard input below the routing seam.

### Identity links are explicit, and visible while routing

Channels never infers that two identities belong to the same person. Address
matching and display-name matching are unsafe, so linking must be explicitly
performed by the application itself.

An application that _has_ recorded a link should still be able to act on it at
the moment it matters, so a Host can be given a lookup function and routes can
ask it whether the actor is a known user. The application owns the store; the
Host only asks. This is what lets a personal agent see a Slack and an email from
the same person land in the same conversation without guesswork.

_Rejected:_ automatic identity resolution; and a Host-owned identity store,
which would reintroduce exactly the state this design removes.

### Channels select their own inbound work

A Channel is offered an input and returns nothing if it is not interested. The
Host tries each configured Channel in order and takes the first that claims it.
Declining and rejecting are different: a Channel that owns a request but finds a
bad signature answers with its own error response.

This replaced a split model in which the Host matched HTTP by path itself while
email Channels answered a separate predicate. One rule covers both, adapters can
match on anything they like rather than only a path, and there is no Host-side
matching that can disagree with the checks an adapter performs anyway. Selection
exists to distinguish configured Channels that could claim the same input — for
example webhook paths or explicitly configured email mailboxes — not to decide
whether the application cares about an event after a Channel has claimed it.

_Accepted costs:_ configuration order becomes significant, and the Host can no
longer detect two Channels mounted on the same path.

### A destination is self-describing data

A surface names the configured Channel that can reach it, so an application can
store one and use it later without also remembering which object produced it.
The Host stamps that key, because a Channel does not know the name it was
configured under. Composite destinations (try these in order, send to all of
these) are then ordinary surfaces resolved recursively by ordinary Channels
that the Host installs.

Channels have no implicit default destination. In a real conversation the
interesting destination is a particular person on whichever channel currently
reaches them, which a channel-wide default cannot express; and an application
that wants a fixed destination can persist a surface of its own.

_Rejected:_ pairing a surface with a Channel at the call site, which is just a
surface missing a field; and having the Host offer a surface to each Channel
until one claims it, because surfaces resemble each other closely enough that
mis-delivery through the wrong configured instance would be silent, while an
unknown key fails loudly. We also dropped a separate provider tag, accepting
that stored data is no longer interpretable without the configuration that
created it, in exchange for a single unambiguous identifier.

_Consequence:_ configured channel keys are durable identifiers. Renaming one
orphans every surface and identity persisted under the old name.

### Outbound attempts are singular and honestly reported

One `deliver` call is one provider attempt, and the result distinguishes
confirmed delivery, confirmed failure, and genuine uncertainty. Uncertainty is
the interesting case: after a timeout or a crash mid-send, nobody can say
whether the recipient got the message, and a library that retries on the
caller's behalf turns that ambiguity into duplicate messages.

Only the caller knows whether a duplicate is worse than a miss, so only the
caller can decide to retry. This is the single largest residual risk in the
design, and it is a property of providers without idempotent send operations
rather than of this package.

_Rejected:_ automatic retries, and suppressing repeated sends by delivery id
inside the package, which would require the durable record we deliberately
do not keep.

### Interaction identifiers carry nothing

An approval request identifier is opaque. Encoding the requesting conversation
inside it makes the identifier a covert routing channel that silently breaks
when routing changes, and it invites treating the identifier as though it were
an authorization credential, which it is not.

Once decisions route by the same rules as messages, the identifier does not need
to carry routing at all: a decision made on any linked channel arrives at the
conversation that asked. The application settles it, and the first terminal
decision wins.

_Rejected:_ embedding the route in the identifier, and a Host-owned correlation
index, which is state again.

### Approval links are rendered, not hosted

Provider-native approvals round-trip an identifier through the provider's own
controls, so nothing else is needed. Provider-neutral approvals instead need a
public link, and a link needs hosting, a signing key, an expiry, and revocation
— all application concerns with application-specific policy. So a Channel
renders the links its caller supplies and verifies nothing on their behalf. A
Channel asked to request approval without them reports an honest failure rather
than inventing one.

A bare, predictable interaction identifier is not safe as a public approval URL.
13 changes: 13 additions & 0 deletions examples/channels/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copy this file to .dev.vars and replace each value.
# These are also the Worker's deployed secrets: `wrangler secret bulk`.

# The address Workers Email routes to this Worker, and the From address it sends.
EMAIL_FROM=channels@4g3nts.com

SLACK_BOT_TOKEN=xoxb-your-slack-bot-token
SLACK_SIGNING_SECRET=replace-with-your-slack-signing-secret
# Your Slack app's bot user id, used to detect mentions and its own messages.
SLACK_BOT_USER_ID=U0123456789

TELEGRAM_BOT_TOKEN=123456:telegram-bot-token
TELEGRAM_WEBHOOK_SECRET=replace-with-a-long-random-string
136 changes: 136 additions & 0 deletions examples/channels/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Channels support application

A small support inbox built on [`@cloudflare/channels`](../../packages/channels).
Slack, Telegram, email, and support-form messages become one normalized event;
each conversation lives in a Durable Object; and stored surfaces let the
application answer later on the same or another Channel.

## Run locally

```bash
pnpm install
cp .dev.vars.example .dev.vars
pnpm run start
```

Fill in the provider secrets in `.dev.vars`.

The support form Channel works on `localhost`. Slack and Telegram deliver over
webhooks, so they need a public URL: expose the Vite server with
`cloudflared tunnel --url http://localhost:5173` and register that hostname with
each provider, or run `pnpm run deploy` and point them at the Worker. Workers
Email ingress requires a deployed Worker with Email Routing configured.

| Source | Ingress | Setup |
| ------------ | ------------------------------- | ---------------------------------------------------------------- |
| Slack | `/webhooks/slack` | Subscribe the app to direct message and `app_mention` events |
| Telegram | `/webhooks/telegram` | Register the URL with `setWebhook` and `TELEGRAM_WEBHOOK_SECRET` |
| Email | Workers Email `email()` handler | Route `EMAIL_FROM` to this Worker and bind Email Service |
| Support form | `POST /ingress/support-form` | Nothing — it is this example's own Channel |

## Where to read

| File | What it shows |
| ------------------------------------------------------------ | ----------------------------------------------------------- |
| [`src/server.ts`](src/server.ts) | Every Channel, its routing, the Host, and the entry points |
| [`src/conversation.ts`](src/conversation.ts) | Storing what arrived and delivering through stored surfaces |
| [`src/directory.ts`](src/directory.ts) | Linking Channel identities to users |
| [`src/support-form-channel.ts`](src/support-form-channel.ts) | Writing an inbound-only Channel |

`src/api.ts` and `src/ui/` are browser plumbing. Nothing in them is specific to
Channels.

## Ingress and routing

A `ChannelHost` authenticates and normalizes each provider event, asks the
receiving Channel for an application route, stamps its reply surface with the
configured `channelKey`, and hands it to the application:

```typescript
new ChannelHost({
channels: createChannels(env),
findUser: (identity) => directoryFor(env).userFor(identity),
async onMessage({ channelKey, route, dispatchId, message }) {
await env.Conversation.getByName(route).receive(
route,
channelKey,
dispatchId,
message
);
}
});
```

A route is any string returned by the Channel's `route` function, or `null` to
ignore the event. The common `byUser` policy prefers an explicitly linked user
and otherwise delegates to another policy:

```typescript
telegram({
botToken: env.TELEGRAM_BOT_TOKEN,
webhook: { secretToken: env.TELEGRAM_WEBHOOK_SECRET },
route: routes.byUser(routes.perThread)
});
```

The Slack Channel keeps a hand-written route so the example also shows how to
accept direct messages, mentions, and replies within an existing thread while
returning `null` for standalone channel chatter.

Providers can deliver the same event more than once. The conversation
Durable Object deduplicates on `dispatchId` before storing anything.

## Surfaces and replies

Each inbound message may carry `replySurface`: the exact place the conversation
can be answered. The Host stamps the configured Channel key before the
application stores it. Every surface also carries an adapter-provided display
label. Replying later requires only the Host and that surface:

```typescript
const surface = JSON.parse(row.reply_surface);
const delivery = await host.deliver(surface, { markdown });
```

The conversation stores the resulting `delivered`, `failed`, or `uncertain`
result as-is, and the UI displays what the Channel reported.

The custom support-form Channel is inbound only. It has no fake surface and no
`deliver()` method; a linked email, Slack, or Telegram identity supplies an
outbound contact surface when one exists.

## Approvals

The composer can ask for elevated access instead of sending a message. The Host
resolves the same stored surface, and the selected Channel renders the request
as Slack buttons, a Telegram prompt, or email links:

```typescript
await host.requestApproval(surface, {
interactionId: crypto.randomUUID(),
request: { title: "Elevated access request", summary, input }
});
```

Correlation belongs to the application. An approval decision routes by the
approver's explicitly linked identity, then that conversation finds its pending
request by opaque interaction ID. The first decision wins.

## Identity and continuity

Identity and destination are separate:

- `actor.identity` says **who** sent an inbound message;
- `replySurface` says **where** this conversation can be answered;
- `host.contactSurface(identity)` finds a new direct destination;
- `createUserIdentityStore()` records explicit links between identities that
represent the same application user.

Every Channel route prefers an explicitly linked user and returns
`user:${user.id}` when one exists. Future email, support-form, Slack, and
Telegram events from linked identities therefore continue in one cross-channel
conversation. Linking does not merge conversations that already exist; it
changes where future events route.

See [`packages/channels/README.md`](../../packages/channels/README.md) for the
complete package API.
40 changes: 40 additions & 0 deletions examples/channels/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types --include-runtime=false env.d.ts` (hash: dcb0be1d18f5a061447cbc2b09084969)
interface __BaseEnv_Env {
EMAIL: SendEmail;
ASSETS: Fetcher;
EMAIL_FROM: string;
SLACK_BOT_TOKEN: string;
SLACK_BOT_USER_ID: string;
SLACK_SIGNING_SECRET: string;
TELEGRAM_BOT_TOKEN: string;
TELEGRAM_WEBHOOK_SECRET: string;
Directory: DurableObjectNamespace<import("./src/server").Directory>;
Conversation: DurableObjectNamespace<import("./src/server").Conversation>;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/server");
durableNamespaces: "Conversation" | "Directory";
}
interface Env extends __BaseEnv_Env {}
}
interface Env extends __BaseEnv_Env {}
type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string
? EnvType[Binding]
: string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<
Pick<
Cloudflare.Env,
| "EMAIL_FROM"
| "SLACK_BOT_TOKEN"
| "SLACK_BOT_USER_ID"
| "SLACK_SIGNING_SECRET"
| "TELEGRAM_BOT_TOKEN"
| "TELEGRAM_WEBHOOK_SECRET"
>
> {}
}
21 changes: 21 additions & 0 deletions examples/channels/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>Channels Support</title>
<script>
(() => {
const stored = localStorage.getItem("theme");
const mode = stored || "light";
document.documentElement.setAttribute("data-mode", mode);
document.documentElement.style.colorScheme = mode;
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client.tsx"></script>
</body>
</html>
Loading
Loading