LSP did it for language servers. ACP does it for AI coding agents. The Agent Client Protocol is an open JSON-RPC standard that lets any editor talk to any agent. In this tutorial, you will build your own ACP-compatible agent in TypeScript and connect it to Zed.
What You Will Learn
By the end of this tutorial, you will:
- Understand the Agent Client Protocol (ACP) architecture and why it exists
- Set up a TypeScript ACP agent project from scratch
- Implement the core
Agentinterface:initialize,newSession, andprompt - Stream real-time output to the editor with session updates
- Ask the user for permission before running tools
- Wire your agent into Zed and test it end to end
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - TypeScript knowledge (interfaces, async/await, ES modules)
- A code editor — Zed is used for the final test, but VS Code or Cursor work for writing the code
- Basic understanding of JSON-RPC and standard input/output streams
- Optionally, an Anthropic API key if you want the agent to call a real model
What is the Agent Client Protocol?
The Agent Client Protocol (ACP) is an open standard that defines how a code editor (the client) communicates with an AI coding agent (the agent). It is built on JSON-RPC 2.0 and runs over stdio — the agent is a subprocess, and messages flow as newline-delimited JSON over standard input and output.
Think of it as the Language Server Protocol (LSP) for AI agents. Before LSP, every editor needed a custom integration for every language. Before ACP, every editor needed a custom integration for every agent. ACP turns an N×M integration problem into N+M: write your agent once, and it works in Zed, Neovim, JetBrains, Emacs, and any other ACP client.
Where ACP fits
ACP is complementary to two other standards you may know:
- MCP (Model Context Protocol) connects agents to tools and data.
- A2A (Agent-to-Agent) connects agents to other agents.
- ACP connects agents to the editor and the human in the loop.
The three core methods
An ACP agent must respond to three requests at minimum:
1. initialize → negotiate protocol version + capabilities
2. session/new → create a conversation session
3. session/prompt → process a user turn, stream output, return a stop reason
During a prompt turn, the agent pushes session/update notifications back to the client (streaming text, thoughts, tool calls) and can send session/request_permission before doing anything sensitive.
Step 1: Project Setup
Create a new project and install the official TypeScript SDK.
mkdir acp-hello-agent && cd acp-hello-agent
npm init -y
npm install @zed-industries/agent-client-protocol
npm install -D typescript tsx @types/nodeCreate a tsconfig.json configured for modern ES modules:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}Set "type": "module" in package.json so Node treats your files as ES modules:
{
"name": "acp-hello-agent",
"type": "module",
"bin": { "acp-hello-agent": "dist/agent.js" },
"scripts": {
"dev": "tsx src/agent.ts",
"build": "tsc"
}
}Why stdio matters. ACP agents communicate over standard input and output, never over the network by default. That means console.log is off-limits in your agent code — anything you print to stdout becomes a malformed protocol message. Use console.error (stderr) for debugging instead.
Step 2: Understand the Agent Interface
The SDK exposes an Agent interface. Here is the shape you will implement (simplified):
interface Agent {
initialize(params: InitializeRequest): Promise<InitializeResponse>;
newSession(params: NewSessionRequest): Promise<NewSessionResponse>;
prompt(params: PromptRequest): Promise<PromptResponse>;
cancel(params: CancelNotification): Promise<void>;
// optional: loadSession, authenticate, setSessionMode, ...
}You connect your implementation to the editor using AgentSideConnection and a stream created with ndJsonStream (newline-delimited JSON). The connection object is also how you send updates back to the client.
Step 3: Implement initialize and newSession
Create src/agent.ts. Start with the handshake methods. The initialize call negotiates the protocol version and advertises what your agent can do.
import {
Agent,
AgentSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type InitializeRequest,
type InitializeResponse,
type NewSessionRequest,
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type CancelNotification,
} from "@zed-industries/agent-client-protocol";
import { Readable, Writable } from "node:stream";
class HelloAgent implements Agent {
// The connection lets us push session updates to the client.
constructor(private conn: AgentSideConnection) {}
// Track active sessions so we can cancel them later.
private sessions = new Map<string, AbortController>();
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
return {
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: {
// We can load past sessions back into memory.
loadSession: false,
// Declare the prompt content types we accept.
promptCapabilities: { image: false, audio: false, embeddedContext: true },
},
authMethods: [], // No auth needed for this demo.
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
// params.cwd holds the workspace root the editor opened.
const sessionId = `sess_${this.sessions.size + 1}`;
this.sessions.set(sessionId, new AbortController());
return { sessionId };
}
async cancel(params: CancelNotification): Promise<void> {
this.sessions.get(params.sessionId)?.abort();
}
// prompt() comes next.
async prompt(params: PromptRequest): Promise<PromptResponse> {
return { stopReason: "end_turn" };
}
}A few things to note:
PROTOCOL_VERSIONis exported by the SDK so your agent always reports the version it was built against.agentCapabilitiestells the editor what to enable in its UI — for example, whether to offer image attachments.- We store an
AbortControllerper session so acancelnotification can interrupt a running turn.
Step 4: Stream Output with Session Updates
The heart of an agent is the prompt method. The editor sends the user's message, and your job is to:
- Read the user prompt from
params.prompt(an array of content blocks). - Stream the response back using
session/updatenotifications. - Return a stop reason when the turn is done.
Replace the placeholder prompt method with a streaming echo that types out a reply word by word:
async prompt(params: PromptRequest): Promise<PromptResponse> {
const { sessionId } = params;
const controller = this.sessions.get(sessionId);
// Extract the user's text from the content blocks.
const userText = params.prompt
.filter((block) => block.type === "text")
.map((block) => block.text)
.join(" ");
const reply = `You said: "${userText}". Here is a streamed answer.`;
// Stream the reply one chunk at a time.
for (const word of reply.split(" ")) {
if (controller?.signal.aborted) {
return { stopReason: "cancelled" };
}
await this.conn.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: word + " " },
},
});
// Simulate token latency so the streaming is visible.
await new Promise((r) => setTimeout(r, 60));
}
return { stopReason: "end_turn" };
}The sessionUpdate payload supports several update kinds. The most common are:
sessionUpdate value | Purpose |
|---|---|
agent_message_chunk | Visible assistant text |
agent_thought_chunk | Reasoning shown in a collapsible block |
tool_call | Announce a tool the agent is about to run |
tool_call_update | Report progress or result of a tool |
plan | A multi-step plan the agent intends to follow |
Valid stop reasons are end_turn, max_tokens, max_turn_requests, refusal, and cancelled.
Step 5: Connect Over stdio
Now wire the agent to the transport. ACP runs over newline-delimited JSON on stdio, so you convert Node's process.stdout and process.stdin into Web streams and hand them to ndJsonStream.
Add this entry point at the bottom of src/agent.ts:
function main() {
// ACP writes to stdout and reads from stdin.
// Convert Node streams to the Web streams the SDK expects.
const input = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const output = Writable.toWeb(process.stdout) as WritableStream<Uint8Array>;
const stream = ndJsonStream(output, input);
// The factory receives the connection and returns our Agent.
new AgentSideConnection((conn) => new HelloAgent(conn), stream);
// Keep the process alive; the connection drives everything.
process.stdin.resume();
}
main();The AgentSideConnection constructor takes a factory function. The SDK hands you the connection object, and you return your Agent implementation. This inversion is what lets your agent push updates back through conn while still responding to incoming requests.
Test that the process starts without crashing:
npm run dev
# It should hang waiting for stdin — that is correct. Press Ctrl+C.Step 6: Ask for Permission Before Acting
Real agents edit files and run commands. ACP has a built-in flow for this: before a sensitive action, send session/request_permission and let the editor show the user a choice.
Here is a helper you can call inside prompt before, say, writing a file:
private async confirm(
sessionId: string,
title: string,
): Promise<boolean> {
const result = await this.conn.requestPermission({
sessionId,
toolCall: {
toolCallId: `call_${Date.now()}`,
title,
kind: "edit",
status: "pending",
},
options: [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
});
// The client returns which option the user picked.
return result.outcome?.outcome === "selected" &&
result.outcome.optionId === "allow";
}Because we use a timestamp instead of a random value for the id, the example stays deterministic and easy to log. In production, generate a stable unique id per tool call. The kind field — one of read, edit, delete, execute, fetch, and others — lets the editor render an appropriate icon and warning.
Step 7: Plug in a Real Model (Optional)
To make the agent useful, replace the echo logic with a call to a language model. Install the Anthropic SDK and stream tokens straight into sessionUpdate:
npm install @anthropic-ai/sdkimport Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
async function streamModel(
conn: AgentSideConnection,
sessionId: string,
userText: string,
) {
const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [{ role: "user", content: userText }],
});
stream.on("text", async (delta) => {
await conn.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: delta },
},
});
});
await stream.finalMessage();
}Call streamModel from inside prompt, then return { stopReason: "end_turn" } when the stream finishes. Never hardcode the API key — keep it in the ANTHROPIC_API_KEY environment variable.
Step 8: Connect to Zed
Build your agent, then register it in Zed's settings.json under the agent_servers key:
npm run build{
"agent_servers": {
"Hello Agent": {
"command": "node",
"args": ["/absolute/path/to/acp-hello-agent/dist/agent.js"],
"env": {
"ANTHROPIC_API_KEY": "sk-ant-..."
}
}
}
}Open the agent panel in Zed, pick Hello Agent from the list, and send a message. You should see your streamed reply appear token by token — proof that your custom agent is speaking ACP correctly.
Testing Your Implementation
Verify each layer before declaring victory:
- Process starts:
npm run devhangs on stdin without errors. - Handshake: Zed shows the agent in the picker (means
initializesucceeded). - Streaming: Replies appear incrementally, not all at once.
- Cancellation: Stopping a turn mid-stream returns
cancelledand halts output. - Permission: Sensitive actions trigger a prompt in the editor UI.
For a transport-level check without an editor, pipe a handcrafted JSON-RPC message into the process:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' | npm run devYou should see a single JSON line come back with your agent's capabilities.
Troubleshooting
The agent never appears in Zed. Check the path in args is absolute and that npm run build produced dist/agent.js. Look at Zed's log (zed: open log) for spawn errors.
Garbled or duplicated messages. You almost certainly called console.log somewhere. Every stray stdout write corrupts the JSON-RPC stream. Switch all debugging to console.error.
initialize version mismatch. Always return PROTOCOL_VERSION from the SDK rather than a hardcoded number, so the agent and client stay in sync as the protocol evolves.
Stream never ends. Make sure every prompt path returns a PromptResponse with a stop reason — including the cancelled and error branches.
Next Steps
- Add tool calls so the agent can read and write files via
fs/read_text_fileandfs/write_text_file. - Emit a
planupdate so users see the agent's multi-step intentions before it acts. - Publish your agent to the ACP Registry so other developers can install it across all clients.
- Explore related tutorials: Build an MCP server in TypeScript, Claude Agent SDK for TypeScript, and OpenAI Agents SDK in production.
Conclusion
You built a working ACP agent from nothing: a handshake, a session, streamed output, a permission flow, and a real model behind it — all over plain stdio. Because it speaks the Agent Client Protocol, the same binary now works in Zed today and in every future ACP client without a single change. That portability is the whole point: in a 2026 landscape where model access shifts with pricing and export controls, an agent that is not locked to one editor — and an editor not locked to one agent — is a strategic advantage. Write once, connect everywhere.