Skip to main content

Overview

This example demonstrates how to build a functional ACP Agent using the TypeScript SDK. The agent implements all required protocol methods and showcases key features including session management, tool calls, and permission requests.

What This Example Demonstrates

Session Management

Creating and managing agent sessions with unique IDs

Tool Calls

Executing tool calls with different permission requirements

Permission Requests

Requesting user approval for sensitive operations

Streaming Updates

Sending real-time text chunks and status updates to the client

Complete Code

Here’s the full implementation from src/examples/agent.ts:
#!/usr/bin/env node

import * as acp from "@agentclientprotocol/sdk";
import { Readable, Writable } from "node:stream";

interface AgentSession {
  pendingPrompt: AbortController | null;
}

class ExampleAgent implements acp.Agent {
  private connection: acp.AgentSideConnection;
  private sessions: Map<string, AgentSession>;

  constructor(connection: acp.AgentSideConnection) {
    this.connection = connection;
    this.sessions = new Map();
  }

  async initialize(
    _params: acp.InitializeRequest,
  ): Promise<acp.InitializeResponse> {
    return {
      protocolVersion: acp.PROTOCOL_VERSION,
      agentCapabilities: {
        loadSession: false,
      },
    };
  }

  async newSession(
    _params: acp.NewSessionRequest,
  ): Promise<acp.NewSessionResponse> {
    const sessionId = Array.from(crypto.getRandomValues(new Uint8Array(16)))
      .map((b) => b.toString(16).padStart(2, "0"))
      .join("");

    this.sessions.set(sessionId, {
      pendingPrompt: null,
    });

    return {
      sessionId,
    };
  }

  async authenticate(
    _params: acp.AuthenticateRequest,
  ): Promise<acp.AuthenticateResponse | void> {
    // No auth needed - return empty response
    return {};
  }

  async setSessionMode(
    _params: acp.SetSessionModeRequest,
  ): Promise<acp.SetSessionModeResponse> {
    // Session mode changes not implemented in this example
    return {};
  }

  async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
    const session = this.sessions.get(params.sessionId);

    if (!session) {
      throw new Error(`Session ${params.sessionId} not found`);
    }

    session.pendingPrompt?.abort();
    session.pendingPrompt = new AbortController();

    try {
      await this.simulateTurn(params.sessionId, session.pendingPrompt.signal);
    } catch (err) {
      if (session.pendingPrompt.signal.aborted) {
        return { stopReason: "cancelled" };
      }

      throw err;
    }

    session.pendingPrompt = null;

    return {
      stopReason: "end_turn",
    };
  }

  private async simulateTurn(
    sessionId: string,
    abortSignal: AbortSignal,
  ): Promise<void> {
    // Send initial text chunk
    await this.connection.sessionUpdate({
      sessionId,
      update: {
        sessionUpdate: "agent_message_chunk",
        content: {
          type: "text",
          text: "I'll help you with that. Let me start by reading some files to understand the current situation.",
        },
      },
    });

    await this.simulateModelInteraction(abortSignal);

    // Send a tool call that doesn't need permission
    await this.connection.sessionUpdate({
      sessionId,
      update: {
        sessionUpdate: "tool_call",
        toolCallId: "call_1",
        title: "Reading project files",
        kind: "read",
        status: "pending",
        locations: [{ path: "/project/README.md" }],
        rawInput: { path: "/project/README.md" },
      },
    });

    await this.simulateModelInteraction(abortSignal);

    // Update tool call to completed
    await this.connection.sessionUpdate({
      sessionId,
      update: {
        sessionUpdate: "tool_call_update",
        toolCallId: "call_1",
        status: "completed",
        content: [
          {
            type: "content",
            content: {
              type: "text",
              text: "# My Project\n\nThis is a sample project...",
            },
          },
        ],
        rawOutput: { content: "# My Project\n\nThis is a sample project..." },
      },
    });

    await this.simulateModelInteraction(abortSignal);

    // Send more text
    await this.connection.sessionUpdate({
      sessionId,
      update: {
        sessionUpdate: "agent_message_chunk",
        content: {
          type: "text",
          text: " Now I understand the project structure. I need to make some changes to improve it.",
        },
      },
    });

    await this.simulateModelInteraction(abortSignal);

    // Send a tool call that DOES need permission
    await this.connection.sessionUpdate({
      sessionId,
      update: {
        sessionUpdate: "tool_call",
        toolCallId: "call_2",
        title: "Modifying critical configuration file",
        kind: "edit",
        status: "pending",
        locations: [{ path: "/project/config.json" }],
        rawInput: {
          path: "/project/config.json",
          content: '{"database": {"host": "new-host"}}',
        },
      },
    });

    // Request permission for the sensitive operation
    const permissionResponse = await this.connection.requestPermission({
      sessionId,
      toolCall: {
        toolCallId: "call_2",
        title: "Modifying critical configuration file",
        kind: "edit",
        status: "pending",
        locations: [{ path: "/home/user/project/config.json" }],
        rawInput: {
          path: "/home/user/project/config.json",
          content: '{"database": {"host": "new-host"}}',
        },
      },
      options: [
        {
          kind: "allow_once",
          name: "Allow this change",
          optionId: "allow",
        },
        {
          kind: "reject_once",
          name: "Skip this change",
          optionId: "reject",
        },
      ],
    });

    if (permissionResponse.outcome.outcome === "cancelled") {
      return;
    }

    switch (permissionResponse.outcome.optionId) {
      case "allow": {
        await this.connection.sessionUpdate({
          sessionId,
          update: {
            sessionUpdate: "tool_call_update",
            toolCallId: "call_2",
            status: "completed",
            rawOutput: { success: true, message: "Configuration updated" },
          },
        });

        await this.simulateModelInteraction(abortSignal);

        await this.connection.sessionUpdate({
          sessionId,
          update: {
            sessionUpdate: "agent_message_chunk",
            content: {
              type: "text",
              text: " Perfect! I've successfully updated the configuration. The changes have been applied.",
            },
          },
        });
        break;
      }
      case "reject": {
        await this.simulateModelInteraction(abortSignal);

        await this.connection.sessionUpdate({
          sessionId,
          update: {
            sessionUpdate: "agent_message_chunk",
            content: {
              type: "text",
              text: " I understand you prefer not to make that change. I'll skip the configuration update.",
            },
          },
        });
        break;
      }
      default:
        throw new Error(
          `Unexpected permission outcome ${permissionResponse.outcome}`,
        );
    }
  }

  private simulateModelInteraction(abortSignal: AbortSignal): Promise<void> {
    return new Promise((resolve, reject) =>
      setTimeout(() => {
        // In a real agent, you'd pass this abort signal to the LLM client
        if (abortSignal.aborted) {
          reject();
        } else {
          resolve();
        }
      }, 1000),
    );
  }

  async cancel(params: acp.CancelNotification): Promise<void> {
    this.sessions.get(params.sessionId)?.pendingPrompt?.abort();
  }
}

const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;

const stream = acp.ndJsonStream(input, output);
new acp.AgentSideConnection((conn) => new ExampleAgent(conn), stream);

Code Walkthrough

1

Session State Management

The agent maintains a map of active sessions, each with an AbortController to handle cancellation:
interface AgentSession {
  pendingPrompt: AbortController | null;
}

private sessions: Map<string, AgentSession>;
2

Initialize Method

Returns the protocol version and agent capabilities to the client:
async initialize(_params: acp.InitializeRequest): Promise<acp.InitializeResponse> {
  return {
    protocolVersion: acp.PROTOCOL_VERSION,
    agentCapabilities: {
      loadSession: false,
    },
  };
}
3

New Session Method

Creates a unique session ID and initializes session state:
async newSession(_params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
  const sessionId = Array.from(crypto.getRandomValues(new Uint8Array(16)))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  this.sessions.set(sessionId, { pendingPrompt: null });
  return { sessionId };
}
4

Prompt Method

Handles user prompts with cancellation support:
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
  const session = this.sessions.get(params.sessionId);
  
  // Cancel any existing prompt
  session.pendingPrompt?.abort();
  session.pendingPrompt = new AbortController();

  try {
    await this.simulateTurn(params.sessionId, session.pendingPrompt.signal);
  } catch (err) {
    if (session.pendingPrompt.signal.aborted) {
      return { stopReason: "cancelled" };
    }
    throw err;
  }

  return { stopReason: "end_turn" };
}
5

Sending Text Chunks

Stream text responses to the client using sessionUpdate:
await this.connection.sessionUpdate({
  sessionId,
  update: {
    sessionUpdate: "agent_message_chunk",
    content: {
      type: "text",
      text: "I'll help you with that...",
    },
  },
});
6

Tool Calls Without Permission

Execute read-only tool calls that don’t require user approval:
// Send pending tool call
await this.connection.sessionUpdate({
  sessionId,
  update: {
    sessionUpdate: "tool_call",
    toolCallId: "call_1",
    title: "Reading project files",
    kind: "read",
    status: "pending",
    locations: [{ path: "/project/README.md" }],
    rawInput: { path: "/project/README.md" },
  },
});

// Update to completed
await this.connection.sessionUpdate({
  sessionId,
  update: {
    sessionUpdate: "tool_call_update",
    toolCallId: "call_1",
    status: "completed",
    content: [/* results */],
  },
});
7

Permission Requests

Request user permission for sensitive operations:
const permissionResponse = await this.connection.requestPermission({
  sessionId,
  toolCall: {
    toolCallId: "call_2",
    title: "Modifying critical configuration file",
    kind: "edit",
    status: "pending",
    locations: [{ path: "/home/user/project/config.json" }],
    rawInput: { /* ... */ },
  },
  options: [
    { kind: "allow_once", name: "Allow this change", optionId: "allow" },
    { kind: "reject_once", name: "Skip this change", optionId: "reject" },
  ],
});

// Handle the user's response
if (permissionResponse.outcome.outcome === "cancelled") {
  return;
}
8

Cancellation Support

Handle cancellation requests from the client:
async cancel(params: acp.CancelNotification): Promise<void> {
  this.sessions.get(params.sessionId)?.pendingPrompt?.abort();
}
9

Connection Setup

Initialize the agent connection using stdio streams:
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;

const stream = acp.ndJsonStream(input, output);
new acp.AgentSideConnection((conn) => new ExampleAgent(conn), stream);

Running the Example

This agent is designed to be launched by an ACP client. It communicates via stdin/stdout using the newline-delimited JSON format.

Option 1: Run with the Example Client

npx tsx src/examples/client.ts
This will automatically spawn the agent and demonstrate a complete interaction.

Option 2: Run from an ACP Client

Configure your ACP client (like Zed) to launch this agent:
npx tsx path/to/agent.ts

Option 3: Test Standalone

While the agent expects client communication via stdio, you can verify it runs:
npx tsx src/examples/agent.ts
The agent will wait for JSON-RPC messages on stdin. Without a client, it will simply idle.

Key Concepts

Session Management

Each agent session is independent and has its own state. Sessions are identified by unique IDs and can be cancelled independently.

Tool Call Lifecycle

  1. Pending: Tool call is created with tool_call update
  2. Executing: Tool is running (optional progress updates)
  3. Permission Check: For sensitive operations, request user approval
  4. Completed/Failed: Final status sent via tool_call_update

Permission Flow

For operations that modify files or perform sensitive actions:
  1. Send initial tool call update
  2. Call connection.requestPermission() with options
  3. Wait for user response
  4. Handle approval or rejection accordingly

Streaming Updates

The agent streams responses in real-time using sessionUpdate notifications:
  • agent_message_chunk: Text responses
  • tool_call: New tool execution
  • tool_call_update: Tool status changes
  • agent_thought_chunk: Internal reasoning (optional)

Next Steps

Simple Client Example

See how to build a client that connects to this agent

API Reference

Explore the full AgentSideConnection API

Production Examples

Study real-world agent implementations

Protocol Overview

Learn more about the ACP protocol

Build docs developers (and LLMs) love