Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/channels-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"agents": minor
---

Add `ChannelGateway` to `agents/experimental/channels`: the Worker entry point that verifies channel webhooks, routes each event to the agent object that holds its conversation, and delivers outbound messages.
Add `ChannelGateway` to `agents/experimental/channels`: the Worker entry point that verifies channel webhooks, takes Web Channel upgrades through `web()` from `agents/experimental/channels/web`, routes each to the agent object that holds its conversation, and delivers outbound messages. Every Channel that takes ingress needs a `participant` callback, where the application says who the sender is; Channels never picks an identity. The agent object is the authorization boundary: by default each participant gets one of their own, and a `route` callback can share one between participants or refuse them.
8 changes: 5 additions & 3 deletions examples/next/channels/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Every agent has the same shape:
2. `Channels.forHarness(harness, { channels: { web: new WebChannel() } })`
serves each harness session as a conversation through the Web Channel.
3. The Worker's **`ChannelGateway`** routes each WebSocket upgrade to the
agent that holds the conversation.
agent object its route names, which holds the room's conversations.

| Path | Agent | Harness |
| ---------------------------------- | ------------ | ---------------------------------------------------------------- |
Expand All @@ -21,8 +21,10 @@ Every agent has the same shape:

- `src/server.ts` is the Worker. It builds a `ChannelGateway` whose `agent`
picks the Durable Object namespace from the first segment of the route, so
one Worker serves several agent classes. Its `web` resolver lets the client
name itself with `?as=`, which only a demo should trust.
one Worker serves several agent classes. Its `web()` Channel lets the
client name itself with `?as=` and join any room, which only a demo
should allow: the agent object is the authorization boundary, so whoever
reaches a room may use every conversation in it.
- `src/ai-sdk-agent.ts` is `AiSdkAgent`: `AiSdkHarness` runs each message
with `streamText` on Workers AI. It has a client tool (`getLocation`, run
by the participant who asked) and a tool that needs approval (`flipCoin`).
Expand Down
50 changes: 33 additions & 17 deletions examples/next/channels/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ChannelGateway,
type GatewayAgent
} from "agents/experimental/channels";
import { web } from "agents/experimental/channels/web";

export { AiSdkAgent } from "./ai-sdk-agent";
export { PiAgent } from "./pi/agent";
Expand All @@ -13,30 +14,45 @@ const harnesses: Record<string, (env: Env, name: string) => GatewayAgent> = {
};

/**
* A route is `<harness>/<room>`: `/channels/ai-sdk/lobby` reaches the
* AiSdkAgent named `lobby`. The gateway hands routes back to `agent`, which
* picks the namespace.
* `/channels/<harness>/<room>[/<conversation>]`: `/channels/ai-sdk/lobby`
* reaches the AiSdkAgent named `lobby`.
*/
function parse(request: Request) {
const match = /^\/channels\/([^/]+)\/([^/]+)(?:\/([^/]+))?$/.exec(
new URL(request.url).pathname
);
if (!match || !Object.hasOwn(harnesses, match[1])) return undefined;
return {
route: `${match[1]}/${decodeURIComponent(match[2])}`,
conversationId: match[3] && decodeURIComponent(match[3])
};
}

function gatewayFor(env: Env) {
return new ChannelGateway({
channels: {},
agent: (route) => {
const slash = route.indexOf("/");
return harnesses[route.slice(0, slash)](env, route.slice(slash + 1));
},
// Demo only: the client names itself with `?as=`. A real app resolves
// the participant from a session it can verify.
web: (request) => {
const url = new URL(request.url);
const match = /^\/channels\/([^/]+)\/([^/]+)(?:\/([^/]+))?$/.exec(
url.pathname
);
if (!match || !Object.hasOwn(harnesses, match[1])) return undefined;
return {
route: `${match[1]}/${decodeURIComponent(match[2])}`,
...(match[3] && { conversationId: decodeURIComponent(match[3]) }),
participant: { id: url.searchParams.get("as") ?? "anonymous" }
};
channels: {
web: web({
match: (request) => {
const parsed = parse(request);
if (!parsed) return undefined;
return parsed.conversationId
? { conversationId: parsed.conversationId }
: {};
},
// Demo only: the client names itself with `?as=`. A real app
// resolves the participant from a session it can verify.
participant: (request) =>
new URL(request.url).searchParams.get("as") ?? crypto.randomUUID(),
// Demo only: anyone may join any room. The agent object is the
// authorization boundary, so a real app checks that the
// participant may enter the room, or leaves out `route` to give
// each participant a room of their own.
route: (request) => parse(request)?.route ?? null
})
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ describe("experimental email channel", () => {
binding: { send: vi.fn() as EmailSendBinding["send"] },
from: "agent@example.com",
inbound: { from: "support@example.com" },
participant: (event) => event.actor?.id ?? null,
route
});
const raw = new TextEncoder().encode(
Expand Down Expand Up @@ -202,7 +203,8 @@ describe("experimental email channel", () => {
it("does not infer inbound recipient or sender filters from outbound configuration", async () => {
const channel = email({
binding: { send: vi.fn() as EmailSendBinding["send"] },
from: "agent@example.com"
from: "agent@example.com",
participant: (event) => event.actor?.id ?? null
});
const raw = new TextEncoder().encode(
[
Expand Down
Loading
Loading