agents
Version:
A home for your AI agents
324 lines (264 loc) • 11.7 kB
Markdown
# `agents/channels`
`agents/channels` gives an agent one interface for sending and receiving
messages across different platforms. Use a Channel directly, expose it as an AI
tool, or register it with a durable `ChannelHost` that owns routing and delivery
recovery.
> [!NOTE]
> Channels is experimental. Its interface _will_ change before the package
> reaches a stable release.
## Install
```bash
npm install agents
```
## Create channels
Each adapter turns provider configuration into the same `Channel` interface. A
`ChannelHost` holds them, keyed by names you choose:
```typescript
import { ChannelHost, routes } from "agents/channels";
import { email } from "agents/channels/email";
import { slack } from "agents/channels/slack";
import { telegram } from "agents/channels/telegram";
const host = new ChannelHost({
channels: {
slack: slack({
botToken: env.SLACK_BOT_TOKEN,
webhook: {
signingSecret: env.SLACK_SIGNING_SECRET,
botUserId: env.SLACK_BOT_USER_ID
},
route: routes.perThread
}),
telegram: telegram({
botToken: env.TELEGRAM_BOT_TOKEN,
webhook: { secretToken: env.TELEGRAM_WEBHOOK_SECRET },
route: routes.perThread
}),
email: email({
binding: env.EMAIL,
from: "agent@example.com",
route: routes.perThread
})
},
async onMessage({ route, dispatchId, message }) {
await conversationFor(route).receive(dispatchId, message);
}
});
```
## 1. Send a message
A **surface** is a destination for an outbound message.
You get one from an inbound message's reply field, or by constructing one from a raw channel identifier through the host.
```typescript
const surface = host.contactSurface({
channelKey: "slack",
scope: "T123",
subject: "U456"
});
await host.deliver(surface, {
title: "Import needs attention",
markdown: "The customer import stopped after **1,240 records**."
});
```
An identity's `channelKey` names the configured Channel that observed it. Its
optional `scope` names a tenant within that Channel and defaults to `"default"`;
for example, one configured Slack app can observe the same user ID in several
workspaces. The Host stamps `channelKey` on inbound identities because an adapter
does not know the key it was configured under.
The same human observed through two configured Channels on one platform is two
Channel identities, just as the same human on Slack and email is. Applications
that know they are the same person link those identities explicitly.
Compose destinations with `fallback()` and `fanout()`:
```typescript
import { fallback, fanout } from "agents/channels";
await host.deliver(fallback([slackSurface, emailSurface]), message);
await host.deliver(fanout([slackSurface, emailSurface]), message);
```
`fallback()` tries destinations in order, advancing only after a _confirmed_
failure, so it can never duplicate a delivery. For a stream, it advances only
when that failure happens before the destination starts reading; replaying an
arbitrarily large consumed prefix would require an unbounded buffer. `fanout()`
sends to all destinations; a partial or uncertain result is reported as
`uncertain` for the same reason.
The Host installs both policies as ordinary Channels under reserved keys. You
can register another composite policy as an ordinary Channel under your own key
and pair it with a surface constructor that writes that key; inject only the
outbound resolution capability the policy needs, as the exported built-in
policy Channels do.
## 2. Give a destination to a model
```typescript
import { generateText, stepCountIs } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import { createSendMessageTool } from "agents/channels/ai-sdk";
const workersai = createWorkersAI({ binding: env.AI });
await generateText({
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
prompt: "An import stopped after 1,240 records. Notify support.",
tools: {
contactSupport: createSendMessageTool(
host,
fallback([slackSurface, emailSurface]),
{ description: "Contact support when a person needs to intervene" }
)
},
stopWhen: stepCountIs(2)
});
```
The model writes the message; you chose the destination. TanStack AI exports the
same `createSendMessageTool(host, surface, options)` from
`agents/channels/tanstack-ai`.
## 3. Receive messages
One entry point covers every configured Channel's webhook, and Workers Email
arrives the same way:
```typescript
export default {
async fetch(request: Request): Promise<Response> {
const response = await host.handleRequest(request);
if (response) return response;
return new Response("Not found", { status: 404 });
},
async email(message: ForwardableEmailMessage): Promise<void> {
await host.handleEmail(message);
}
} satisfies ExportedHandler<Env>;
```
Each Channel authenticates its own input and declines what isn't its business,
so the Host asks them in configuration order and the first to claim it wins.
### Routing
A Channel's `route` turns one normalized event into an opaque application
string — a Durable Object name, a queue key, a database id — or `null` to ignore
the event entirely. The key is used to identify a common destination for
messages -- i.e. typically a single conversation:
```typescript
telegram({
// …credentials…
route(event) {
return event.thread.isDirectMessage === true ? event.thread.id : null;
}
});
```
Channels exposes builtin helpers for the common mappings — `routes.perThread`
and `routes.perEvent`, which namespace their routes as `thread:…` and
`event:…`.
Deciding whether an event is relevant at all is a different question, and one
only your application can answer, because the answer usually depends on state
you hold. Write that in your own `route` and return `null` to ignore the event:
```typescript
route(event) {
// Slack shows a bot every message in every channel it belongs to. Answer
// when addressed, and otherwise only join threads we are already in.
const thread = routes.perThread(event);
const addressed =
event.thread.isDirectMessage === true || event.message.isMention === true;
if (addressed) return thread;
return (await myConversations.knows(thread)) ? thread : null;
}
```
A lookup like that runs for every event reaching it, so put it behind a cheaper
check, keep it read-only, and do not let it create the state it is testing for —
otherwise the first stray message conjures the thing the check is looking for.
### Link identities
Personal agents often wwant to resolve users regardless of the channel they messaged on. Your application can explicitly record connections between channel identities and expose them to the Host for messages to be routed on:
```typescript
const host = new ChannelHost({
channels,
findUser: (identity) => users.findUser(identity),
onMessage,
onApprovalResponse
});
// ...
// route to a user's central conversaion if one exists, else start a new
// conversation for each thread:
route: routes.byUser(routes.perThread);
// or prefer the linked person, then the sender we recognise, then a new
// conversation per event:
route: routes.byUser(routes.byIdentity(routes.perEvent));
```
`byIdentity` groups events carrying the _same_ identity. It never infers that
two different identities belong to one person — that stays an explicit
application decision, which `byUser` then exposes to routing. Omit its fallback
to ignore events that carry no identity at all.
If your application does not already store user identities, `createUserIdentityStore(storage)` creates a Durable Object SQL store of the right shape. Your application can call `store.link` to connect multiple identities together.
### Ask for approval
### Request approval
The Host exposes a utility for durably correlating inbound approvals to outbound requests:
```typescript
await host.requestApproval({
interactionId: "deploy-42",
request: {
title: "Production deployment",
summary: "Deploy version 2026.08.17 to production?",
input: {
version: "2026.08.17",
environment: "production"
}
}
});
await host.requestApproval(surface, {
interactionId: crypto.randomUUID(),
request: {
title: "Production deployment",
summary: "Deploy version 2026.08.17 to production?",
input: { version: "2026.08.17" }
}
});
```
Users can respond to approval requests through native surfaces (e.g. Telegram
buttons) or HTTP inbound URLs, resolved by the Channel Host itself for your application to settle.
## Custom channels
Any transport can become a Channel:
```typescript
import { matchesPath, routes, type Channel } from "agents/channels";
const supportForm: Channel = {
route: routes.perEvent,
ingress: {
async receive(request) {
if (!matchesPath(request, "/support")) return null;
const raw = await request.json<{ message: string; email: string }>();
const eventId = crypto.randomUUID();
return {
events: [
{
raw,
event: {
type: "message",
eventId,
thread: { id: eventId, isDirectMessage: true },
actor: {
id: raw.email,
// The Host stamps the configured `channelKey`.
identity: { subject: raw.email }
},
message: { id: eventId, text: raw.message }
}
}
],
response: Response.json({ accepted: true }, { status: 202 })
};
}
}
};
```
Returning `null` declines the request so another Channel can claim it.
## Durability contract
Channels holds no state: no outbox, no retries, no deduplication, no scheduler.
Durability is a property of how your application uses it. A caller-supplied
`deliveryId` is correlation metadata, not an idempotency guarantee; an adapter
may map it to a provider primitive when one exists.
| Channels guarantees | Your application must |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| A `dispatchId` stable across redelivery and unaffected by routing | Deduplicate on it before starting any side effect |
| The Host awaits your callback before the provider is acknowledged | Hand off durably before returning — a DO RPC, queue send, or workflow start |
| One outbound attempt per `deliver()` or `stream()`, reported honestly | Decide whether to retry; `uncertain` may duplicate a real delivery |
| Surfaces are plain JSON you can persist | Keep configured channel keys stable |
| Decisions arrive as normalized events carrying your own `interactionId` | Own settlement; an interaction id is not an authorization credential |
## Future work
- [ ] Approval-link ingress: signing, verification, and a confirmation page, so
link approvals return through the same normalized path as Slack buttons
- [ ] Reader-initiated stream cancellation: Slack's `message_stream_stopped`
and Telegram's `stopped_message_generation` should reach the running
generation as ordinary ingress, so aborting it errors the stream and
each Channel finalizes on the path it already has
- [ ] More built-in channels
- [ ] Rendering templates (pretty emails)
- [ ] Automatic webhook registration
- [ ] Security review of approval flows
- [ ] Conformance tests of adapters