Skip to content

feat(agents): add experimental outbound channels - #2086

Closed
cjol wants to merge 3 commits into
mainfrom
investigate/think-channels-package-prototype
Closed

feat(agents): add experimental outbound channels#2086
cjol wants to merge 3 commits into
mainfrom
investigate/think-channels-package-prototype

Conversation

@cjol

@cjol cjol commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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

  • Outbound actions are currently built as transport-specific tools, so changing from email to another delivery route also changes the model-facing contract.
  • A small canonical message shape, optional title plus Markdown content, gives adapters one semantic payload to project into transport-specific fields.
  • Direct delivery can fail after a side effect may have occurred. Returning delivered, safely retryable or permanent failed, and uncertain outcomes lets callers avoid blindly duplicating messages.
  • A standalone package was considered, but an experimental agents entry point keeps the first slice small while the abstraction is validated.
  • The first slice intentionally tests only whether a canonical payload and configured-route tool are useful before introducing a broader channel lifecycle.

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.

  • Email is the only included transport adapter. There are no chat, SMS, voice, or other channel implementations.
  • Delivery is immediate and in-process. There is no durable submission record, queue, retry worker, or recovery mechanism.
  • There is no inbound processing, identity resolution, thread routing, or request-to-agent continuity.
  • Normal assistant output is not delivered through a Channel. The model must explicitly call a tool created with createChannelTool() for anything to be sent.

Public API Surface

All additions are exported from agents/experimental/channels.

Symbol Kind Notes
ChannelMessage Type Canonical optional title and Markdown content
DeliveryFailure Type Model-visible failure code and message
DeliveryResult Type Delivered, failed, or uncertain direct-delivery outcome
Channel Interface Configured outbound route with deliver()
CreateChannelToolOptions Type Caller-owned AI SDK description, examples, metadata, and approval policy
createChannelTool() Function Adapts a configured Channel to an AI SDK tool
RenderedEmailMarkdown Type Text and HTML projection returned by an email renderer
EmailChannelOptions Type Destination-bound Email Service channel configuration
email() Function Creates an Email Service-backed Channel

The existing EmailSendBinding export remains available under the same name and now aliases Cloudflare's platform SendEmail type. The existing SendEmailOptions shape is preserved.

Architectural Changes

AI SDK tool
    |
createChannelTool()
    |
configured Channel
    |
transport adapter
    |
Email Service binding
  • The generic channel contract owns the canonical message and delivery semantics.
  • Each adapter binds addressing at construction time and owns transport projection and error classification.
  • Tool names and policy stay with the caller's ToolSet, so the Channels module does not prescribe product behavior.

Code Changes

  • Adds the experimental Channels entry point, generic contract, and AI SDK tool adapter.
  • Adds a destination-bound email adapter that maps title to subject, renders Markdown as text by default, supports caller-provided text and HTML rendering, and conservatively classifies ambiguous failures as uncertain.
  • Moves Agent.sendEmail() message construction into an internal email helper while preserving Agent routing headers, signing, observability, error handling, and the existing public method.
  • Wires the new entry point into the package build and export map and includes a minor changeset for agents.

@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c013eb2

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

This PR includes changesets to release 3 packages
Name Type
@cloudflare/think Minor
@cloudflare/channels 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

devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

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

@cloudflare/ai-chat

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

@cloudflare/channels

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: 8a5fa99

devin-ai-integration[bot]

This comment was marked as resolved.

@cjol
cjol force-pushed the investigate/think-channels-package-prototype branch from 46a111d to b15beef Compare August 18, 2026 09:36

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

View 6 additional findings in Devin Review.

Open in Devin Review


override async alarm(): Promise<void> {
await this.#alarms.handleAlarm({
channels: this.#host.handleAlarm,

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.

🔴 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).

Suggested change
channels: this.#host.handleAlarm,
channels: (deliveryIds) => this.#host.handleAlarm(deliveryIds),
Open in Devin Review

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

Comment on lines +282 to +284
async email(message: ForwardableEmailMessage, env: Env): Promise<void> {
await env.CHANNELS_EXAMPLE.getByName("default").handleEmail(message);
}

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.

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

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

);
}

const recorded = await recordResult(attempting, result);

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.

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

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

Comment on lines +39 to +41
import { signAgentHeaders, type SendEmailOptions } from "./email";
import { sendAgentEmail } from "./email-send";
export type { EmailSendBinding, SendEmailOptions } from "./email";

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.

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

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

}
}
});
await this.channelHost.init();

@devin-ai-integration devin-ai-integration Bot Aug 18, 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.

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

Open in Devin Review

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

Comment thread packages/channels/src/telegram.ts
Comment thread packages/channels/src/telegram.ts
Comment thread packages/channels/src/host/approval-links.ts
@cjol
cjol force-pushed the investigate/think-channels-package-prototype branch from b15beef to 8a5fa99 Compare August 18, 2026 09:45
devin-ai-integration[bot]

This comment was marked as resolved.

@cjol
cjol force-pushed the investigate/think-channels-package-prototype branch from 8a5fa99 to c013eb2 Compare August 18, 2026 10:02
@cjol cjol closed this Aug 18, 2026

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

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +251 to +256
if (
request.headers.get("x-telegram-bot-api-secret-token") !==
options.secretToken
) {
return emptyIngressResponse(401);
}

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

Open in Devin Review

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

Comment on lines +127 to +154
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>");

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.

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

Open in Devin Review

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

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