feat(agents): add experimental outbound channels - #2086
Conversation
🦋 Changeset detectedLatest commit: c013eb2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
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: |
46a111d to
b15beef
Compare
|
|
||
| override async alarm(): Promise<void> { | ||
| await this.#alarms.handleAlarm({ | ||
| channels: this.#host.handleAlarm, |
There was a problem hiding this comment.
🔴 Alarm handling in the new Channels example always crashes
The channel alarm handler is handed over as a bare function reference (channels: this.#host.handleAlarm at examples/channels/src/index.ts:241) instead of being called on its owner, so every alarm in the example fails and no retried delivery or application alarm ever runs.
Impact: In the Channels example, scheduled retries and the application-alarm demo route silently never fire; the Durable Object alarm throws each time it is invoked.
Why the unbound method reference throws
ChannelHost.handleAlarm reads the private field this.#ownedAlarms (packages/channels/src/host/index.ts:178-186). AlarmCoordinator.handleAlarm invokes the registered handler as a plain call (await handler(...) in packages/channels/src/alarm-coordinator.ts:145), so this is undefined and private-field access throws a TypeError. Because the coordinator dispatches sources in sorted order (application before channels) and rethrows, the whole alarm callback fails. Both the package README and docs/channels/index.md use the correct wrapped form channels: (deliveryIds) => host.handleAlarm(deliveryIds).
| channels: this.#host.handleAlarm, | |
| channels: (deliveryIds) => this.#host.handleAlarm(deliveryIds), |
Was this helpful? React with 👍 or 👎 to provide feedback.
| async email(message: ForwardableEmailMessage, env: Env): Promise<void> { | ||
| await env.CHANNELS_EXAMPLE.getByName("default").handleEmail(message); | ||
| } |
There was a problem hiding this comment.
🟡 Inbound email in the Channels example is forwarded in a form the platform cannot pass along
The raw platform email event is handed straight to the Durable Object (handleEmail(message) at examples/channels/src/index.ts:283) instead of the plain data the call requires, so inbound mail fails before it is ever processed.
Impact: Emails sent to the example are never recorded or answered; the email handler errors out.
ForwardableEmailMessage is not serializable across Durable Object RPC
ForwardableEmailMessage is a runtime host object with methods (setReject, forward, reply), not a structured-cloneable value, so passing it as an RPC argument to the DO stub fails serialization. The SDK's own email routing never forwards the event object: it copies the plain fields and bridges the callbacks through an RpcTarget (packages/agents/src/index.ts:13040-13052). Both READMEs for this feature show the supported shape — convert first, e.g. headers: [...message.headers] and raw as an ArrayBuffer (packages/channels/README.md:196-204) — and ChannelEmailInput accepts raw/getRaw plus a Headers instance (packages/channels/src/ingress.ts:49-55).
Prompt for agents
In examples/channels/src/index.ts the top-level `email()` handler forwards the ForwardableEmailMessage object directly to the Durable Object stub (`env.CHANNELS_EXAMPLE.getByName("default").handleEmail(message)`). Durable Object RPC cannot serialize that platform object, so inbound email ingress fails. Convert the event into plain data at the Worker boundary (from, to, headers as an array of tuples, raw as an ArrayBuffer) and reconstruct a ChannelEmailInput inside the DO method (Headers from the tuples, getRaw returning a Uint8Array), mirroring the pattern documented in packages/channels/README.md.
Was this helpful? React with 👍 or 👎 to provide feedback.
| ); | ||
| } | ||
|
|
||
| const recorded = await recordResult(attempting, result); |
There was a problem hiding this comment.
🟡 Approval links stored for a request are wiped as soon as the send result is recorded
The record of an approval request is overwritten from a stale in-memory copy (recordResult(attempting, result) at packages/channels/src/host/delivery.ts:431) after the approve/reject links were saved onto it, so the saved links are lost and a later attempt mints a second set of links for the same request.
Impact: Retried approval notifications contain different approve/reject links than the first message, and unused link records accumulate in storage.
Write ordering that discards the persisted tokens
performAttempt snapshots the record as attempting and persists it (packages/channels/src/host/delivery.ts:379-386), then calls channel.requestApproval with getApprovalLinks. That callback re-reads the record and writes approvalLinks back to storage (packages/channels/src/host/approval-links.ts:73-94). When the channel returns, recordResult spreads the stale attempting object (which has no approvalLinks) and calls putDelivery, dropping the field. On a retry, get() sees no approvalLinks and generates a fresh token pair plus two more cf_channels:approval-link:* records. A fix is to re-read the delivery (or merge the latest stored fields) before recording the attempt result.
Prompt for agents
In packages/channels/src/host/delivery.ts, performAttempt captures the delivery record as `attempting` before invoking the channel, then passes that stale object to recordResult after the channel call returns. During the channel call, createApprovalLinkController.get() (packages/channels/src/host/approval-links.ts) writes an `approvalLinks` field onto the same stored record, and dispatchApprovalResponse can write a `response` field. Because recordResult spreads the stale snapshot and calls putDelivery, those concurrent writes are silently discarded, so a retry generates a brand new approve/reject token pair and leaves orphan approval-link records. Consider re-reading the stored delivery immediately before recording the result and merging the fields written during the attempt (approvalLinks, response) instead of spreading the pre-attempt snapshot.
Was this helpful? React with 👍 or 👎 to provide feedback.
| import { signAgentHeaders, type SendEmailOptions } from "./email"; | ||
| import { sendAgentEmail } from "./email-send"; | ||
| export type { EmailSendBinding, SendEmailOptions } from "./email"; |
There was a problem hiding this comment.
🟡 Public API changes to the core SDK ship without a release note
The core SDK's public email exports are reshaped and re-exported (export type { EmailSendBinding, SendEmailOptions } at packages/agents/src/index.ts:41) without an accompanying release note entry, which the repository requires for public API changes.
Impact: The change can be released without being versioned or described, so consumers get a silently altered public type.
AGENTS.md changeset requirement
AGENTS.md (Contributing → Changesets): "Changes to packages/ that affect the public API or fix bugs need a changeset." This PR adds .changeset/ entries for @cloudflare/channels, @cloudflare/think, and @cloudflare/voice, but none for agents, even though packages/agents/src/email.ts now owns and exports EmailSendBinding (redefined as the platform SendEmail type instead of the previous structural type) and SendEmailOptions, and packages/agents/src/index.ts re-exports them.
Prompt for agents
Add a changeset for the `agents` package describing the email export changes made in this PR (EmailSendBinding is now an alias of the platform SendEmail type, SendEmailOptions and EmailSendBinding now live in and are exported from agents/email, and Agent.sendEmail delegates to the new internal helper). AGENTS.md requires a changeset for any packages/ change affecting the public API.
Was this helpful? React with 👍 or 👎 to provide feedback.
| } | ||
| } | ||
| }); | ||
| await this.channelHost.init(); |
There was a problem hiding this comment.
🔴 An agent can fail to start forever after a channel is removed from its configuration
Channel setup is run during agent startup without any failure containment (await this.channelHost.init() at packages/think/src/think.ts:4487), so if the saved routing target no longer exists in the agent's configuration the agent throws on every start and never comes back up.
Impact: Removing or renaming a channel after a route was selected at runtime permanently bricks that agent instance — every wake, including alarm retries, fails.
Failure path and why it is permanent
setApprovalRequestsChannel("x") persists the channel id in Durable Object storage (packages/channels/src/host/delivery.ts:115-124). On the next start, initialize() reads that key and calls validateChannelId, which throws Unknown channel "x" when the id is no longer present in configureChannelHost().channels (packages/channels/src/host/delivery.ts:70-78, 302-306). _initializeChannelHost is awaited inside the non-best-effort startup block in packages/think/src/think.ts:4452-4489, unlike the neighbouring steps that use _runBestEffortOnStartStep precisely to avoid bricking the DO. Additionally, ChannelHost.#ensureInitialized caches the rejected promise (packages/channels/src/host/index.ts:114-117), so even in-isolate retries keep failing. Options: wrap the Channel Host initialization in _runBestEffortOnStartStep, and/or have initialize() ignore (and clear) a persisted route whose channel is no longer configured.
Was this helpful? React with 👍 or 👎 to provide feedback.
b15beef to
8a5fa99
Compare
8a5fa99 to
c013eb2
Compare
| if ( | ||
| request.headers.get("x-telegram-bot-api-secret-token") !== | ||
| options.secretToken | ||
| ) { | ||
| return emptyIngressResponse(401); | ||
| } |
There was a problem hiding this comment.
🟨 Telegram webhook secret token compared with a non-constant-time string comparison
Telegram webhook ingress authenticates each inbound update by comparing the x-telegram-bot-api-secret-token header to the configured secret with !== (packages/channels/src/telegram.ts:251-256). The comparison short-circuits on the first differing byte, so response timing leaks information about the expected secret to an unauthenticated caller who can POST to the mounted ingress path. An attacker who forges accepted updates can inject approval decisions (approvalResponse builds approve/reject events from an inbound YES/NO reply) and, in Think, drive approveExecution() on a parked durable-pause Action.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (request.method === "GET") { | ||
| const label = link.decision === "approve" ? "Approve" : "Reject"; | ||
| return html( | ||
| `<h1>Confirm ${label.toLowerCase()}</h1><form method="post"><button type="submit">${label}</button></form>` | ||
| ); | ||
| } | ||
| if (request.method !== "POST") { | ||
| return new Response(null, { | ||
| status: 405, | ||
| headers: { allow: "GET, POST" } | ||
| }); | ||
| } | ||
|
|
||
| const settled = await getSettlement(options.storage, link.interactionId); | ||
| if (settled) { | ||
| return settled.decision === link.decision | ||
| ? html("<h1>This response was already recorded</h1>") | ||
| : html("<h1>This approval has already been resolved</h1>", 409); | ||
| } | ||
|
|
||
| try { | ||
| await options.handleResponse({ | ||
| type: "approval-response", | ||
| interactionId: link.interactionId, | ||
| decision: link.decision, | ||
| reference: token | ||
| }); | ||
| return html("<h1>Response recorded</h1>"); |
There was a problem hiding this comment.
🟨 Approval links are settled by an unauthenticated POST with no CSRF protection
Host-owned approval links resolve a pending approval on any POST to /{approvalLinkPath}/{token} (packages/channels/src/host/approval-links.ts:127-154). Possession of the token is the only authorization, and the POST handler does not verify request origin, so any page a recipient visits can auto-submit a cross-site form to a leaked or guessed link and approve or reject an action. Tokens are emitted in plaintext email/webhook bodies (packages/channels/src/email.ts:180-186), where they can be captured by mail scanners, forwards, or logs.
Was this helpful? React with 👍 or 👎 to provide feedback.
This PR adds an experimental transport-neutral outbound Channels API at
agents/experimental/channels, with an Email Service adapter and an AI SDK tool bridge. Think is an integration consumer, but it does not appear in the Channels contract.Why
titleplus Markdown content, gives adapters one semantic payload to project into transport-specific fields.delivered, safely retryable or permanentfailed, anduncertainoutcomes lets callers avoid blindly duplicating messages.agentsentry point keeps the first slice small while the abstraction is validated.Not Included
This is deliberately narrower than the broader Channels direction discussed previously. At this stage it is a tool wrapper around one configured outbound transport.
Channel. The model must explicitly call a tool created withcreateChannelTool()for anything to be sent.Public API Surface
All additions are exported from
agents/experimental/channels.ChannelMessageDeliveryFailureDeliveryResultChanneldeliver()CreateChannelToolOptionscreateChannelTool()Channelto an AI SDK toolRenderedEmailMarkdownEmailChannelOptionsemail()ChannelThe existing
EmailSendBindingexport remains available under the same name and now aliases Cloudflare's platformSendEmailtype. The existingSendEmailOptionsshape is preserved.Architectural Changes
ToolSet, so the Channels module does not prescribe product behavior.Code Changes
titleto subject, renders Markdown as text by default, supports caller-provided text and HTML rendering, and conservatively classifies ambiguous failures asuncertain.Agent.sendEmail()message construction into an internal email helper while preserving Agent routing headers, signing, observability, error handling, and the existing public method.agents.