Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/channels-acp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"agents": patch
---

Add `AcpChannel` (`agents/experimental/channels/acp`), which serves a Channels agent to [Agent Client Protocol](https://agentclientprotocol.com) clients such as Zed and T3 Code, and `npx agents acp <url>`, which bridges an ACP client's stdio to the channel's WebSocket.

Each ACP session is a conversation. The channel supports `session/new`, `load`, `resume`, `list`, `close` and `fork`. It streams text, reasoning and tool calls as session updates, turns approvals into `session/request_permission`, and cancels the running turn on `session/cancel`. Messages sent from other surfaces show up in the ACP client too.

`acp()` is its side in the gateway, like `web()` for the Web Channel: it takes `/acp` upgrades by default and resolves who is connecting. The gateway now names the Channel that took an upgrade in the connection identity (`channel`), so the agent's channel mounted under the same key serves it, and the Web Channel ignores connections addressed to another channel.
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.
42 changes: 38 additions & 4 deletions examples/next/channels/README.md

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.

🔍 Pi setup snippet omits ACP

The Pi setup snippet still mounts only WebChannel, while PiAgent mounts both channels. Readers copying it will miss the ACP setup.

(Refers to this code)

Devin Review


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

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Channels

Agents served to browsers and terminals through Channels. The Worker serves
Agents served to browsers, terminals and ACP clients through Channels. The Worker serves
one agent class per harness, and any client can talk to either.

Every agent has the same shape:
Expand All @@ -10,19 +10,25 @@ 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 |
| ---------------------------------- | ------------ | ---------------------------------------------------------------- |
| `/channels/pi/<room>[/<conv>]` | `PiAgent` | `PiHarness` from `agents/harness/pi`, behind `piChannelsHarness` |
| `/channels/ai-sdk/<room>[/<conv>]` | `AiSdkAgent` | `AiSdkHarness` from `agents/harness/ai-sdk` |

Each agent also mounts an `AcpChannel` at `/acp/<harness>/<room>`. See
[Use it from an ACP client](#use-it-from-an-acp-client).

## Files

- `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. Its `acp()` Channel
does the same for `/acp/<harness>/<room>`.
- `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 Expand Up @@ -69,6 +75,34 @@ conversations (`/new`, `/fork`, `/conversations`, `/switch <id>`). A
conversation is also reachable directly at
`/channels/<harness>/<room>/<conversation>`.

## Use it from an ACP client

Both agents also mount an `AcpChannel` from
`agents/experimental/channels/acp`, which speaks the
[Agent Client Protocol](https://agentclientprotocol.com) over a WebSocket.
The Worker's `acp()` Channel takes `/acp/<harness>/<room>` upgrades, and
the gateway names its key in the connection identity, so the agent's
`AcpChannel` mounted under the same key serves them rather than the Web
Channel. An ACP client opens sessions itself, so each ACP session is a
conversation in that room, and the browser and terminal clients see its
turns live.

ACP clients launch agents as local commands that speak JSON-RPC on stdio.
`npx agents acp <url>` bridges stdio to the channel's WebSocket, and
`bin/acp-agent` runs it against the dev server. The script ignores its
arguments and reads `ACP_HARNESS` (`ai-sdk` or `pi`), `ACP_ROOM`,
`ACP_PARTICIPANT` or a full `ACP_URL` from the environment.

- **Zed**: add an `agent_servers` entry whose `command` is the absolute
path to `bin/acp-agent`.
- **T3 Code**: add an ACP Registry provider, pick any npx registry agent,
such as Claude Agent, and set **Executable override** to the absolute path
to `bin/acp-agent`. T3 Code passes the registry agent's arguments, which
the script ignores.

Tool calls stream as ACP tool calls, and `flipCoin` approvals arrive as
permission requests in the client.

## The pi agent

`PiHarness` keeps pi's own shape. `src/pi/channels-harness.ts`,
Expand Down
16 changes: 16 additions & 0 deletions examples/next/channels/bin/acp-agent
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/sh
# Starts `agents acp` against this example's ACP channel, for ACP clients
# that launch an agent as a local command (Zed, T3 Code).
#
# T3 Code's ACP Registry provider runs this as an executable override and
# appends the registry agent's own arguments, so arguments are ignored here.
# Configure it with environment variables instead:
#
# ACP_URL Default: ws://localhost:5173/acp/$ACP_HARNESS/$ACP_ROOM
# ACP_HARNESS ai-sdk (default) or pi
# ACP_ROOM Default: lobby
# ACP_PARTICIPANT Sent as ?as=; default: acp
here=$(cd "$(dirname "$0")" && pwd)
exec node "$here/../node_modules/agents/dist/cli.js" acp \
"${ACP_URL:-ws://localhost:5173/acp/${ACP_HARNESS:-ai-sdk}/${ACP_ROOM:-lobby}}" \
--as "${ACP_PARTICIPANT:-acp}"
18 changes: 14 additions & 4 deletions examples/next/channels/src/ai-sdk-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type GatewayEvent,
type GatewayOrigin
} from "agents/experimental/channels";
import { AcpChannel } from "agents/experimental/channels/acp";
import { WebChannel } from "agents/experimental/channels/web";
import { AiSdkHarness } from "agents/harness/ai-sdk";
import { Lifecycle } from "agents/lifecycle";
Expand All @@ -27,9 +28,9 @@ const tools = {
};

/**
* An AI SDK agent served to browsers and terminals through Channels. The
* harness keeps the transcript and runs one message at a time, queueing the
* rest; Channels carries events in and responses out.
* An AI SDK agent served to browsers, terminals and ACP clients through
* Channels. The harness keeps the transcript and runs one message at a time,
* queueing the rest; Channels carries events in and responses out.
*/
export class AiSdkAgent extends DurableObject<Env> {
readonly harness = new AiSdkHarness({
Expand All @@ -40,7 +41,16 @@ export class AiSdkAgent extends DurableObject<Env> {
stopWhen: stepCountIs(5)
});
readonly channels = Channels.forHarness(this.harness, {
channels: { web: new WebChannel() }
channels: {
web: new WebChannel(),
acp: new AcpChannel({
agentInfo: {
name: "ai-sdk-agent",
title: "AI SDK agent",
version: "0.0.0"
}
})
}
});
readonly lifecycle = Lifecycle.install(this)
.use(this.harness)
Expand Down
10 changes: 9 additions & 1 deletion examples/next/channels/src/pi/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type GatewayEvent,
type GatewayOrigin
} from "agents/experimental/channels";
import { AcpChannel } from "agents/experimental/channels/acp";
import { WebChannel } from "agents/experimental/channels/web";
import { Lifecycle } from "agents/lifecycle";
import { createModels } from "@earendil-works/pi-ai/models";
Expand Down Expand Up @@ -65,7 +66,14 @@ export class PiAgent extends DurableObject<Env> {
});
readonly channels = Channels.forHarness(
piChannelsHarness(this.harness, { kv: this.ctx.storage.kv }),
{ channels: { web: new WebChannel() } }
{
channels: {
web: new WebChannel(),
acp: new AcpChannel({
agentInfo: { name: "pi-agent", title: "pi agent", version: "0.0.0" }
})
}
}
);
readonly lifecycle = Lifecycle.install(this)
.use(this.harness)
Expand Down
71 changes: 54 additions & 17 deletions examples/next/channels/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
ChannelGateway,
type GatewayAgent
} from "agents/experimental/channels";
import { acp } from "agents/experimental/channels/acp";
import { web } from "agents/experimental/channels/web";

export { AiSdkAgent } from "./ai-sdk-agent";
export { PiAgent } from "./pi/agent";
Expand All @@ -13,30 +15,65 @@ 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])
};
}

/**
* `/acp/<harness>/<room>`: the same agents, for ACP clients through
* `npx agents acp`. An ACP client opens sessions itself, so the path names
* no conversation.
*/
function parseAcp(request: Request) {
const match = /^\/acp\/([^/]+)\/([^/]+)$/.exec(new URL(request.url).pathname);
if (!match || !Object.hasOwn(harnesses, match[1])) return undefined;
return `${match[1]}/${decodeURIComponent(match[2])}`;
}

// Demo only: the client names itself with `?as=`. A real app resolves the
// participant from a session it can verify.
const demoParticipant = (request: Request) =>
new URL(request.url).searchParams.get("as") ?? crypto.randomUUID();

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 }
: {};
},
participant: demoParticipant,
// 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
}),
// Mounted under the same key as each agent's `AcpChannel`.
acp: acp({
match: (request) => parseAcp(request) !== undefined,
participant: demoParticipant,
// Demo only: anyone may join any room, as on the web.
route: (request) => parseAcp(request) ?? null
})
}
});
}
Expand Down
2 changes: 1 addition & 1 deletion examples/next/channels/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"ai": { "binding": "AI", "remote": true },
"assets": {
"not_found_handling": "single-page-application",
"run_worker_first": ["/channels/*"]
"run_worker_first": ["/channels/*", "/acp/*"]
},
"durable_objects": {
"bindings": [
Expand Down
5 changes: 5 additions & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,11 @@
"import": "./dist/experimental/channels/index.js",
"require": "./dist/experimental/channels/index.js"
},
"./experimental/channels/acp": {
"types": "./dist/experimental/channels/acp/index.d.ts",
"import": "./dist/experimental/channels/acp/index.js",
"require": "./dist/experimental/channels/acp/index.js"
},
"./experimental/channels/email": {
"types": "./dist/experimental/channels/email/index.d.ts",
"import": "./dist/experimental/channels/email/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/agents/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const entries = [
"src/experimental/channels/email/index.ts",
"src/experimental/channels/slack/index.ts",
"src/experimental/channels/telegram/index.ts",
"src/experimental/channels/acp/index.ts",
"src/experimental/channels/web/index.ts",
"src/experimental/channels/web/client.ts",
"src/experimental/channels/web/ai-sdk.ts"
Expand Down
7 changes: 6 additions & 1 deletion packages/agents/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { main as acp } from "../experimental/channels/acp/bridge";
import { main as tui } from "../experimental/channels/web/tui/main";

const usage = `Usage: agents <command>

Commands:
tui <url> Chat with an agent's Web Channel from the terminal`;
tui <url> Chat with an agent's Web Channel from the terminal
acp <url> Connect an ACP client, such as Zed or T3 Code, to an agent's ACP channel`;

const [command, ...rest] = process.argv.slice(2);

if (command === "tui") {
process.exit(await tui(rest));
}
if (command === "acp") {
process.exit(await acp(rest));
}
if (command === undefined || command === "--help" || command === "-h") {
console.log(usage);
process.exit(0);
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