Skip to content

feat(channels): the Web Channel, its browser client and WebSockets.use - #2433

Merged
cjol merged 1 commit into
channels/04-gatewayfrom
channels/05-web
Oct 2, 2026
Merged

cjol merged 1 commit into
channels/04-gatewayfrom
channels/05-web

Conversation

@cjol

@cjol cjol commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

This PR adds the Web Channel, which connects browsers and terminals to a conversation over WebSockets, and its client.

Why

  • Clients need to join a conversation, receive the snapshot and live updates, and send messages, approvals and client tool results.
  • They also need to manage conversations: create, fork and reset one, list the agent's conversations and move between them. A create or fork moves only the connection that asked, and other connections stay where they are.
  • The agent already owns its WebSocket connections through the WebSockets capability. The Web Channel shares those connections instead of opening its own, so WebSockets gains use for adding protocol handlers that can claim a message. new WebChannel() takes the agent's WebSockets when Channels mounts it, or creates one that channels.websockets exposes.
  • The gateway resolves who is connecting from trusted request data and passes it in a header, so the Web Channel never trusts identity from the client.

Public API Surface

Additive:

Symbol Entry point Notes
WebChannel, WebChannelOptions agents/experimental/channels/web Server side of the Web Channel; options are optional
ClientFrame, ServerFrame, parseClientFrame, WEB_IDENTITY_HEADER, WebIdentity agents/experimental/channels/web Wire protocol. Every server frame names its conversation
WebChannelClient, WebChannelClientOptions, WebChannelClientState, WebChannelActivity agents/experimental/channels/web/client Client, with listConversations() and follow(id)
ChannelGatewayOptions.web, GatewayWebIdentity agents/experimental/channels Resolve a WebSocket upgrade to a route and, optionally, a conversation
WebSockets.use agents/websockets Add handlers that run before the configured ones

Code Changes

  • web/channel.ts serves the protocol on shared connections. The snapshot lists the conversation operations a connection may send, and channels:list-conversations is answered from the harness's sessions. The list is pushed again after a create or fork.
  • web/client.ts keeps transcript and turn state with apply-chunks.ts. It follows a created or forked conversation and reconnects to the one it follows; followUrl overrides how that URL is built.
  • The gateway upgrades /channels/<route> and /channels/<route>/<conversation> by default.
  • A ChannelsHarnessObject in the worker tests plays a scripted harness and drives both sides end to end. gateway-rpc.test.ts sends a webhook through a ChannelGateway to the agent's receive over RPC.
  • Minor changeset for agents.

@changeset-bot

changeset-bot Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1b17acd

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

This PR includes changesets to release 2 packages
Name Type
agents Minor
@cloudflare/agent-think Patch

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

@cjol
cjol added this pull request to stack #2440 October 1, 2026 13:56

@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.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 9 potential issues.

Devin Review

Comment on lines +245 to +249
for (const [responseId, response] of this.#responses) {
if (!running.has(responseId)) {
this.#responses.delete(responseId);
continue;
}

@devin-ai-integration devin-ai-integration Bot Oct 1, 2026 •

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.

🔴 Unfinished response output lost on reconnect

If a response continues after its turn settles, #resume deletes its cached cursor on reconnect. Snapshots omit settled turns, so the client loses remaining chunks and the response ending.

Learn more

A response stream can continue delivering chunks after its turn settles. The client keeps such a response locally to emit activity until its end, but a reconnect snapshot includes only queued, running, or awaiting-input turns (ConversationSnapshot). The resume logic deletes every response absent from the running set, including one still being read. Its prior cursor and remaining activity are then lost.

Example: A turn settles while response R has sent five chunks but not ended. The socket closes and reconnects. The snapshot contains no running turn for R; the client deletes R instead of subscribing from cursor five.

Recommended fix: Retain and resubscribe responses whose reads have not ended, even if the snapshot no longer includes their turn. Reconcile their transcript visibility independently from the read lifecycle, and release each read only after its terminal frame.

Devin Review


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

Comment thread packages/agents/src/experimental/channels/web/channel.ts
Comment thread packages/agents/src/experimental/channels/web/client.ts
Comment thread packages/agents/src/experimental/channels/web/client.ts
Comment thread packages/agents/src/experimental/channels/apply-chunks.ts
Comment thread packages/agents/package.json Outdated
Comment thread packages/agents/src/experimental/channels/web/channel.ts
Comment thread packages/agents/src/channels/gateway.ts Outdated
Comment on lines +328 to +332
const defaultWeb = (request: Request): GatewayWebIdentity | undefined => {
const match = /^\/channels\/([^/]+)$/.exec(new URL(request.url).pathname);
return match
? { route: decodeURIComponent(match[1]), participant: { id: "anonymous" } }
: undefined;

@devin-ai-integration devin-ai-integration Bot Oct 1, 2026 •

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.

🟥 Default Web upgrades expose conversations anonymously

Without a custom web resolver, any caller can upgrade /channels/<route> as anonymous. The gateway forwards that identity to the conversation agent, allowing the caller to read its snapshot and submit events.

Devin Review


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

Comment thread packages/agents/src/experimental/channels/web/protocol.ts
@agent-think

agent-think Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

🔴 agents import sizes: 1 entry point over threshold

Entry point Exports Largest gzip change Size now
🔴 agents/experimental/channels 1 resized, 1 new +301 B (+10.71%) 3 KiB
🆕 agents/experimental/channels/web 3 new — 22.2 KiB
🆕 agents/experimental/channels/web/client 1 new — 2.7 KiB
Changed exports (6)
Import Gzip change Size now
🔴 agents/experimental/channels#ChannelGateway +301 B (+10.71%) 3 KiB
🆕 agents/experimental/channels/web#WebChannel — 22.2 KiB
🆕 agents/experimental/channels/web#parseClientFrame — 13.2 KiB
🆕 agents/experimental/channels/web#WEB_IDENTITY_HEADER — 12.5 KiB
🆕 agents/experimental/channels/web/client#WebChannelClient — 2.7 KiB
🆕 agents/experimental/channels#applyChunks — 735 B
How this works

Each runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement.

Compared 1362902c → 1b17acd1 · workflow run · reported by agent-think[bot]

@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2433

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: 1b17acd

@cjol
cjol force-pushed the channels/05-web branch from e56645e to c9e5d14 Compare October 2, 2026 10:13
@cjol
cjol force-pushed the channels/05-web branch from c9e5d14 to 8dcbe0f Compare October 2, 2026 11:08
@cjol
cjol removed this pull request from stack #2440 October 2, 2026 17:37
@cjol
cjol force-pushed the channels/05-web branch from 8dcbe0f to 1c57b3e Compare October 2, 2026 17:37
@cjol
cjol added this pull request to stack #2460 October 2, 2026 17:39

@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 7 new potential issues.

Devin Review

Comment on lines +259 to +261
for (const { event } of this.#outbox.values()) {
this.#send({ type: "channels:event", event });
}

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.

🔴 Create acknowledgments trigger repeated conversation creation

When channels:snapshot precedes a create acknowledgment, #outbox resends the create into the new conversation. Deduplication includes conversation ID, so each replay creates another conversation.

Learn more

The server's #message sends the new snapshot before acknowledging a successful create or fork. The client treats every snapshot as a reconnect and replays the entire outbox. #dispatch deduplicates on conversation ID and event ID together, so replaying that event after following a different conversation starts another create or fork. The next snapshot repeats the process.

Example: Alice sends conversation-create with event ID e1 in main. The server creates s1 and sends its snapshot. Before receiving the ack, Alice replays e1 in s1, which creates s2; the process continues.

Recommended fix: Do not replay pending events when a snapshot moves the client to a different conversation. Settle or reject old-conversation events explicitly, and only replay them after reconnecting to the same conversation.

Devin Review


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

}
this.#lists.clear();
this.#update();
if (!this.#closed) setTimeout(() => this.#connect(), 1000);

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.

🔴 Pending reconnect strands a followed socket

If follow() runs during a reconnect delay, the old close timer opens another socket and replaces #socket. The followed socket stays open, but its snapshots and acknowledgments are ignored.

Learn more

A close schedules a future #connect() unconditionally. follow opens a new socket immediately but cannot cancel that scheduled reconnect. When the old timer fires, #connect overwrites #socket; its message listener ignores all traffic on the followed socket because it is no longer the current socket. Both connections remain open.

Example: A socket closes at t=0 and schedules a reconnect for t=1000 ms. At t=500 ms the user follows f1, opening socket B. At t=1000 ms the timer opens socket C; frames from B are dropped even though B remains connected.

Recommended fix: Track and clear pending reconnect timers during follow() and close(), and guard the timer callback with the socket or connection generation it was created for.

Devin Review


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

Comment on lines +137 to +148
send(event: ClientEvent): Promise<void> {
const eventId = event.eventId ?? crypto.randomUUID();
// SAFETY: the same event, with its id filled in.
const complete = { ...event, eventId } as InboundEvent;
return new Promise((resolve, reject) => {
const accepted = () => {
// Other connections get the message from Channels; this one adds its
// own once the agent has accepted it.
if (complete.type === "message") this.#upsert([complete.message]);
resolve();
};
this.#outbox.set(eventId, { event: complete, resolve: accepted, reject });

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.

🟡 Events sent after closing never settle

Calling send() after close() adds an event to #outbox without any future connection or acknowledgment. Its returned promise remains pending forever.

Learn more

close sets #closed and rejects pending events, but send does not check that flag when adding later events. A closed client will never reconnect to acknowledge those events.

Example: Call client.close() followed by await client.send({ type: "conversation-create" }). The await never finishes.

Recommended fix: Reject immediately at the start of send when #closed is true.

Devin Review


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

Comment on lines +405 to +413
"./experimental/channels/web": {
"types": "./dist/experimental/channels/web/index.d.ts",
"import": "./dist/experimental/channels/web/index.js",
"require": "./dist/experimental/channels/web/index.js"
},
"./experimental/channels/web/client": {
"types": "./dist/experimental/channels/web/client.d.ts",
"import": "./dist/experimental/channels/web/client.js",
"require": "./dist/experimental/channels/web/client.js"

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.

🔍 Published channel paths differ from description

The package exports only agents/experimental/channels/..., while the description promises agents/channels/.... Align the documented import paths with the intended published API.

Devin Review


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

Comment on lines +109 to 113
if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
const web = await this.#upgrade(request);
if (web) return web;
}
return this.#webhook(request);

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.

🟥 Unmatched upgrades retain forged identity headers

When #upgrade returns undefined, fetch leaves the client's x-channels-identity header intact. A fallback forwarding that upgrade to a Channels agent lets the client choose its participant and conversation.

Devin Review


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

Comment on lines +350 to +356
return {
route: decodeURIComponent(match[1]),
...(match[2] !== undefined && {
conversationId: decodeURIComponent(match[2])
}),
participant: { id: "anonymous" }
};

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.

🟥 Anonymous browsers share tool ownership

The default resolver gives every browser participant ID anonymous. The harness uses that ID for tool ownership, so distinct browsers share the same owner.

Devin Review


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

Comment on lines +145 to +151
const { eventId } = frame.event;
let result;
try {
result = await this.#mounted().dispatch(
frame.event,
origin(identity, connection.id)
);

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.

🟥 Any participant can reset a conversation

#message dispatches conversation operations from every connected participant without checking privileges. A participant can reset the shared conversation or fork its transcript.

Devin Review


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

@cjol
cjol force-pushed the channels/05-web branch from 1c57b3e to eea9318 Compare October 2, 2026 18:20
This PR adds the Web Channel, which connects browsers and terminals to a conversation over WebSockets, and its client.

## Why

- Clients need to join a conversation, receive the snapshot and live updates, and send messages, approvals and client tool results.
- They also need to manage conversations: create, fork and reset one, list the agent's conversations and move between them. A create or fork moves only the connection that asked, and other connections stay where they are.
- The agent already owns its WebSocket connections through the `WebSockets` capability. The Web Channel shares those connections instead of opening its own, so `WebSockets` gains `use` for adding protocol handlers that can claim a message. `new WebChannel()` takes the agent's `WebSockets` when Channels mounts it, or creates one that `channels.websockets` exposes.
- The gateway resolves who is connecting from trusted request data and passes it in a header, so the Web Channel never trusts identity from the client.

## Public API Surface

Additive:

| Symbol | Entry point | Notes |
| --- | --- | --- |
| `WebChannel`, `WebChannelOptions` | `agents/experimental/channels/web` | Server side of the Web Channel; options are optional |
| `ClientFrame`, `ServerFrame`, `parseClientFrame`, `WEB_IDENTITY_HEADER`, `WebIdentity` | `agents/experimental/channels/web` | Wire protocol. Every server frame names its conversation |
| `WebChannelClient`, `WebChannelClientOptions`, `WebChannelClientState`, `WebChannelActivity` | `agents/experimental/channels/web/client` | Client, with `listConversations()` and `follow(id)` |
| `ChannelGatewayOptions.web`, `GatewayWebIdentity` | `agents/experimental/channels` | Resolve a WebSocket upgrade to a route and, optionally, a conversation |
| `WebSockets.use` | `agents/websockets` | Add handlers that run before the configured ones |

## Code Changes

- `web/channel.ts` serves the protocol on shared connections. The snapshot lists the conversation operations a connection may send, and `channels:list-conversations` is answered from the harness's sessions. The list is pushed again after a create or fork.
- `web/client.ts` keeps transcript and turn state with `apply-chunks.ts`. It follows a created or forked conversation and reconnects to the one it follows; `followUrl` overrides how that URL is built.
- The gateway upgrades `/channels/<route>` and `/channels/<route>/<conversation>` by default.
- A `ChannelsHarnessObject` in the worker tests plays a scripted harness and drives both sides end to end. `gateway-rpc.test.ts` sends a webhook through a `ChannelGateway` to the agent's `receive` over RPC.
- Minor changeset for `agents`.

# Conflicts:
#	packages/agents/AGENTS.md

# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto 74570a1
# Last commands done (11 commands done):
#    update-ref cc492a7 
#    pick 1c57b3e # feat(channels): the Web Channel, its browser client and WebSockets.use
# Next commands to do (16 remaining commands):
#    update-ref 1c57b3e 
#    pick 245a7b9 # feat(harness): add an AI SDK harness
# You are currently rebasing branch 'channels/13-harness-bridge' on '74570a19a'.
#
# Changes to be committed:
#	new file:   .changeset/channels-web.md
#	modified:   packages/agents/AGENTS.md
#	modified:   packages/agents/package.json
#	modified:   packages/agents/scripts/build.ts
#	new file:   packages/agents/src/experimental/channels/__tests__/apply-chunks.test.ts
#	new file:   packages/agents/src/experimental/channels/__tests__/web-client.test.ts
#	new file:   packages/agents/src/experimental/channels/apply-chunks.ts
#	modified:   packages/agents/src/experimental/channels/gateway.ts
#	modified:   packages/agents/src/experimental/channels/index.ts
#	new file:   packages/agents/src/experimental/channels/web/channel.ts
#	new file:   packages/agents/src/experimental/channels/web/client.ts
#	new file:   packages/agents/src/experimental/channels/web/index.ts
#	new file:   packages/agents/src/experimental/channels/web/protocol.ts
#	modified:   packages/agents/src/tests-d/lifecycle-export.test-d.ts
#	new file:   packages/agents/src/tests/capabilities/channels.ts
#	new file:   packages/agents/src/tests/channels/conversations.test.ts
#	new file:   packages/agents/src/tests/channels/gateway-rpc.test.ts
#	modified:   packages/agents/src/tests/worker.ts
#	modified:   packages/agents/src/tests/wrangler.jsonc
#	modified:   packages/agents/src/websockets/options.ts
#	modified:   packages/agents/src/websockets/websockets.ts
#
@cjol
cjol force-pushed the channels/05-web branch from eea9318 to 1b17acd Compare October 2, 2026 18:53
@cjol
cjol merged commit 6e6c58a into main Oct 2, 2026
16 checks passed
@cjol
cjol deleted the channels/05-web branch October 2, 2026 19:20
@github-actions github-actions Bot mentioned this pull request Oct 2, 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