Skip to content

feat(channels): ChannelGateway routes webhooks to the conversation's agent - #2432

Merged
cjol merged 1 commit into
channels/03-corefrom
channels/04-gateway
Oct 2, 2026
Merged

cjol merged 1 commit into
channels/03-corefrom
channels/04-gateway

Conversation

@cjol

@cjol cjol commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

This PR adds ChannelGateway, the Worker entry point that authenticates channel webhooks and routes each event to the agent that holds its conversation.

Why

  • Webhooks arrive at the Worker, not the agent. The Worker has to verify them, normalize them, pick a route, and call the right Durable Object.
  • Keeping routing in the gateway lets the agent stay unaware of providers. The agent only sees GatewayEvents and a GatewayOrigin.
  • A route picks the agent object through agent(route), so one Worker can serve several agent classes by naming routes after them. Routes default to the event's thread, with defaultRoute and onRoute for applications that group threads differently. A webhook names no conversation, so it lands in the agent's default one.

Public API Surface

Additive, exported from agents/experimental/channels:

Symbol Kind Notes
ChannelGateway class fetch for webhooks, plus outbound deliver
ChannelGatewayOptions type channels, agent(route), defaultRoute, findUser, onRoute
GatewayAgent, ChannelRouteEvent types The agent stub shape and the route observer event

Code Changes

  • gateway.ts verifies, normalizes, routes and dispatches, with a stable dispatchId per Channel and event.
  • stream.ts and channel.ts gain the stream collection the gateway uses for outbound delivery.
  • gateway.test.ts covers routing, identity and dispatch.
  • Minor changeset for agents.

@changeset-bot

changeset-bot Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1362902

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

Devin Review

Comment thread packages/agents/src/experimental/channels/gateway.ts
return {
type: "message",
eventId: dispatchId,
message: { id: dispatchId, role: "user", parts }

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

🔴 Telegram edits duplicate transcript messages

When Telegram edits a message, toInboundEvent gives it a new transcript ID instead of the original message ID. normalizedMessage retains the original ID, but each edit has a new event ID. The conversation displays both versions instead of replacing the original.

Learn more

A transcript message ID identifies the message to replace, while an event ID identifies one delivery. Telegram edits keep the same provider message ID but have a fresh update ID in normalizedMessage. The gateway hashes the event ID and uses it for both fields. Channels.receive sends the message onward, so edits become separate transcript messages instead of replacing their original.

Example: A Telegram message with ID 42 arrives in update 100 and is edited in update 101. Both events refer to message 42, but the gateway emits two distinct transcript IDs, so the original and edit coexist.

Recommended fix: Derive the transcript message ID from the configured channel key and message.id, while keeping dispatchId derived from event.eventId for deduplication. Add an edited-message regression test.

Devin Review


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

Comment on lines +97 to +102
for (const envelope of result.events) {
await this.#dispatch(channelKey, channel, envelope);
}
return result.response;
} catch {
return new Response("Failed to handle Channel event", { status: 500 });

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.

🟡 Retried batches can duplicate conversations

When a later event fails, #webhook returns 500 after dispatching earlier events. A provider retry reroutes the entire batch; changed routes deliver earlier events to another conversation despite their stable dispatchId.

Learn more

The gateway dispatches each envelope before it acknowledges a webhook. If an envelope in a multi-event result fails, the catch returns HTTP 500, inviting a provider retry of the entire webhook. The Channels deduplication key includes conversation ID, so it only suppresses repeats sent to the same conversation. A changed route sends a previously accepted envelope to a second conversation.

Example: An ingress yields A and B; A reaches route one, then B fails. Before the provider retries the batch, the route for A changes to route two. A is accepted in both conversations.

Recommended fix: Record successful event IDs and their routing destinations durably before acknowledging or retrying batches, or define batch-level failure handling that does not re-dispatch successful envelopes into changed routes.

Devin Review


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

Comment on lines +303 to +315
const { message } = event;
const parts: MessagePart[] = [
{ type: "text", text: message.markdown ?? message.text }
];
for (const attachment of message.attachments ?? []) {
if (attachment.url && attachment.mediaType) {
parts.push({
type: "file",
url: attachment.url,
mediaType: attachment.mediaType,
...(attachment.name !== undefined && { filename: attachment.name })
});
}

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

🔴 Email subjects and attachments vanish

When inboundEmail supplies a subject and attachments, toInboundEvent drops the subject and every attachment without a URL. inboundEmail produces attachments without URLs, so the agent cannot inspect them.

Learn more

The email adapter extracts the subject as message.title and converts attachments into records containing media type, name, size and sometimes text, but no URL. The gateway projects inbound messages into text plus URL-backed file parts, dropping the subject and all email attachments. The agent receives only the body, even when the sender supplied files or a useful subject.

Example: An email titled "Invoice" with a text attachment invoice.txt arrives. The adapter emits the title and text attachment, but the dispatched agent sees only the body text.

Recommended fix: Preserve the title and available attachment facts in a serializable transcript representation. For binary attachments, expose a retrievable URL or another supported transport before creating file parts; do not silently discard them.

Devin Review


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

Comment thread packages/agents/src/channels/gateway.ts Outdated
Comment thread packages/agents/src/experimental/channels/index.ts
Comment thread packages/agents/src/experimental/channels/gateway.ts
@agent-think

agent-think Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

🟢 agents import sizes: 1 entry point changed, no growth

Entry point Exports Largest gzip change Size now
🆕 agents/experimental/channels 1 new — 2.7 KiB
Changed exports (1)
Import Gzip change Size now
🆕 agents/experimental/channels#ChannelGateway — 2.7 KiB
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 e7342565 → 1362902c · workflow run · reported by agent-think[bot]

@cjol
cjol force-pushed the channels/04-gateway branch from ae78aad to ac0986c Compare October 1, 2026 14:10
@cjol
cjol force-pushed the channels/04-gateway branch from ac0986c to e2d9799 Compare October 1, 2026 14:27
@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@2432

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: 1362902

@cjol
cjol force-pushed the channels/04-gateway branch from e2d9799 to 0e8de89 Compare October 2, 2026 10:13

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

Devin Review

try {
channel = this.#configuredChannel(surface.channelKey);
} catch (error) {
await chunks.cancel().catch(() => {});

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.

🔴 Unknown channel leaves stream delivery pending

When an unknown channel receives a teed stream, stream() waits for chunks.cancel() before rejecting. A tee branch's cancellation waits for its sibling, so an open sibling leaves delivery pending indefinitely.

Learn more

A ReadableStream.tee() branch's cancellation promise can remain pending while the sibling branch stays open. The gateway attempts to cancel an undeliverable source, but awaiting that promise delays the original unknown-channel rejection indefinitely. consumeChunks starts cancellation before finalization for the same reason.

Example: A caller tees a generation into a display branch and a delivery branch, then calls gateway.stream() with an unknown channel key. The display branch remains open; delivery never rejects, although the channel lookup already failed.

Recommended fix: Start chunks.cancel() without awaiting it in this error branch, and retain the rejection handler for cleanup failures. Verify that an open tee sibling does not delay the unknown-channel rejection.

Suggested change
await chunks.cancel().catch(() => {});
void chunks.cancel().catch(() => {});

Devin Review


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

@cjol
cjol force-pushed the channels/04-gateway branch from 0e8de89 to 7ed4bca 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/04-gateway branch from 7ed4bca to cc492a7 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 2 new potential issues.

Devin Review

);
}
await this.#agent(route).receive(toInboundEvent(event, dispatchId), {
route,

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.

🔴 Distinct routes share one conversation

When two routes select the same agent, receive sends both events without a conversation ID. Channels.receive ignores route and puts both events in the default conversation.

Learn more

A route selects the agent object, while a conversation ID selects a session inside that object. The gateway sends only route, and Channels.receive falls back to defaultConversation when conversationId is absent. Consequently, two routes selecting one agent both use its default session, even though the gateway assigned them different routes.

Example: An application maps thread:a and thread:b to one agent object. Messages from both threads enter that object's default conversation; separate transcripts were expected.

Recommended fix: Have Channels.receive use from.route as the conversation ID when no explicit conversationId is supplied, or have the gateway supply the intended conversation ID explicitly. Preserve any deliberate harness-default mapping through a clearly defined separate option.

Devin Review


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

@@ -1,5 +1,6 @@
export * from "./channel";
export * from "./conversations";
export * from "./gateway";

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.

🔍 Clarify the gateway's public import path

The PR description advertises agents/channels, while the package exports ChannelGateway through agents/experimental/channels. Clarify the intended import path for consumers.

Devin Review


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

…agent

This PR adds `ChannelGateway`, the Worker entry point that authenticates channel webhooks and routes each event to the agent that holds its conversation.

## Why

- Webhooks arrive at the Worker, not the agent. The Worker has to verify them, normalize them, pick a route, and call the right Durable Object.
- Keeping routing in the gateway lets the agent stay unaware of providers. The agent only sees `GatewayEvent`s and a `GatewayOrigin`.
- A route picks the agent object through `agent(route)`, so one Worker can serve several agent classes by naming routes after them. Routes default to the event's thread, with `defaultRoute` and `onRoute` for applications that group threads differently. A webhook names no conversation, so it lands in the agent's default one.

## Public API Surface

Additive, exported from `agents/experimental/channels`:

| Symbol | Kind | Notes |
| --- | --- | --- |
| `ChannelGateway` | class | `fetch` for webhooks, plus outbound `deliver` |
| `ChannelGatewayOptions` | type | `channels`, `agent(route)`, `defaultRoute`, `findUser`, `onRoute` |
| `GatewayAgent`, `ChannelRouteEvent` | types | The agent stub shape and the route observer event |

## Code Changes

- `gateway.ts` verifies, normalizes, routes and dispatches, with a stable `dispatchId` per Channel and event.
- `stream.ts` and `channel.ts` gain the stream collection the gateway uses for outbound delivery.
- `gateway.test.ts` covers routing, identity and dispatch.
- Minor changeset for `agents`.
@cjol
cjol force-pushed the channels/04-gateway branch from cc492a7 to 1362902 Compare October 2, 2026 18:20
@cjol
cjol merged commit 752cdc3 into main Oct 2, 2026
17 of 30 checks passed
@cjol
cjol deleted the channels/04-gateway 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