Skip to main content
ACP connections have a well-defined lifecycle from establishment to closure. Understanding this lifecycle is essential for proper resource management and error handling.

Connection Phases

An ACP connection goes through these phases:
  1. Creation - Connection object is instantiated with a stream
  2. Active - Messages can be sent and received
  3. Closing - Stream is ending (readable stream closed)
  4. Closed - Connection is terminated, no more messages possible

Creating a Connection

Connections are created by passing a stream and handler function:
import * as acp from '@agentclientprotocol/acp';
import { Readable, Writable } from 'node:stream';

// Create stream from stdio
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const stream = acp.ndJsonStream(input, output);

// Create agent-side connection
const connection = new acp.AgentSideConnection(
  (conn) => new MyAgent(conn),
  stream
);

// Connection is now active and ready to receive messages
The handler function (toAgent or toClient) receives the connection object and should return the Agent or Client implementation.

Monitoring Connection State

The SDK provides two mechanisms for monitoring connection state:

Using signal (AbortSignal)

The signal property provides an AbortSignal that aborts when the connection closes:
const connection = new acp.ClientSideConnection(
  (agent) => new MyClient(),
  stream
);

// Check if connection is closed
if (connection.signal.aborted) {
  console.log('Connection is closed');
}

// Listen for connection closure
connection.signal.addEventListener('abort', () => {
  console.log('Connection closed');
  performCleanup();
});

// Pass signal to other APIs for automatic cancellation
fetch('https://api.example.com/data', {
  signal: connection.signal,
});

setTimeout(() => {
  console.log('This will be cancelled if connection closes');
}, 10000, connection.signal);
AbortSignal advantages:
  • Synchronous status check with .aborted
  • Can be passed to other APIs (fetch, setTimeout)
  • Standard Web API for cancellation

Using closed (Promise)

The closed property provides a Promise that resolves when the connection closes:
const connection = new acp.ClientSideConnection(
  (agent) => new MyClient(),
  stream
);

// Async/await style
await connection.closed;
console.log('Connection has closed');
performCleanup();

// Promise style
connection.closed.then(() => {
  console.log('Connection has closed');
  performCleanup();
});
Promise advantages:
  • Natural async/await syntax
  • Can be combined with Promise.race() for timeouts

Connection Closure

Connections close when the underlying readable stream ends. This can happen:
  1. Normally - The other side closes their output stream
  2. Due to error - A stream error or write failure occurs
  3. Process termination - The subprocess exits

Detecting Closure

Both sides should monitor for connection closure:
class MyAgent implements acp.Agent {
  private connection: acp.AgentSideConnection;
  
  constructor(connection: acp.AgentSideConnection) {
    this.connection = connection;
    
    // Monitor connection closure
    this.connection.closed.then(() => {
      console.log('Client disconnected');
      this.cleanup();
    });
  }
  
  private cleanup() {
    // Clean up resources
    this.sessions.forEach(session => session.cancel());
    this.sessions.clear();
  }
  
  // ... rest of implementation
}

Resource Management

Proper resource management is critical for long-running connections.

Cleaning Up on Close

Always clean up resources when the connection closes:
import * as acp from '@agentclientprotocol/acp';

class MyAgent implements acp.Agent {
  private sessions = new Map<string, Session>();
  private timers = new Set<NodeJS.Timeout>();
  private connection: acp.AgentSideConnection;
  
  constructor(connection: acp.AgentSideConnection) {
    this.connection = connection;
    
    // Set up cleanup handler
    connection.closed.then(() => this.cleanup());
  }
  
  private cleanup() {
    console.log('Cleaning up resources...');
    
    // Cancel all active sessions
    for (const session of this.sessions.values()) {
      session.abort();
    }
    this.sessions.clear();
    
    // Clear all timers
    for (const timer of this.timers) {
      clearTimeout(timer);
    }
    this.timers.clear();
    
    // Close any open file handles, database connections, etc.
  }
  
  async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
    // Pass connection signal to abort long operations
    const result = await this.runLLM(
      params.prompt,
      this.connection.signal
    );
    
    return { stopReason: 'end_turn' };
  }
}

Using Signals for Cancellation

Pass the connection’s signal to operations that should be cancelled on disconnect:
class MyAgent implements acp.Agent {
  private connection: acp.AgentSideConnection;
  
  constructor(connection: acp.AgentSideConnection) {
    this.connection = connection;
  }
  
  async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
    // Create a combined signal: cancel on either disconnect OR explicit cancel
    const sessionController = new AbortController();
    const combinedSignal = AbortSignal.any([
      this.connection.signal,
      sessionController.signal,
    ]);
    
    try {
      // Pass signal to operations
      const response = await fetch('https://api.openai.com/v1/chat/completions', {
        signal: combinedSignal,
        method: 'POST',
        body: JSON.stringify({ /* ... */ }),
      });
      
      return { stopReason: 'end_turn' };
    } catch (err) {
      if (combinedSignal.aborted) {
        return { stopReason: 'cancelled' };
      }
      throw err;
    }
  }
  
  async cancel(params: acp.CancelNotification): Promise<void> {
    // Cancel specific session
    const session = this.sessions.get(params.sessionId);
    session?.abort();
  }
}

Graceful Shutdown

For clean shutdown, ensure all pending operations complete:
class MyClient implements acp.Client {
  private pendingRequests = new Set<Promise<unknown>>();
  
  async requestPermission(
    params: acp.RequestPermissionRequest
  ): Promise<acp.RequestPermissionResponse> {
    const promise = this.showPermissionDialog(params);
    
    // Track pending requests
    this.pendingRequests.add(promise);
    try {
      return await promise;
    } finally {
      this.pendingRequests.delete(promise);
    }
  }
  
  async sessionUpdate(params: acp.SessionNotification): Promise<void> {
    // Handle update...
  }
  
  async shutdown(): Promise<void> {
    console.log('Waiting for pending requests to complete...');
    
    // Wait for all pending operations
    await Promise.allSettled(this.pendingRequests);
    
    console.log('Shutdown complete');
  }
}

// Usage
const client = new MyClient();
const connection = new acp.ClientSideConnection(
  (agent) => client,
  stream
);

// Monitor for closure
connection.closed.then(() => client.shutdown());

Handling Stream Errors

Stream errors trigger connection closure:
const connection = new acp.ClientSideConnection(
  (agent) => new MyClient(),
  stream
);

connection.signal.addEventListener('abort', () => {
  if (connection.signal.reason) {
    console.error('Connection closed due to error:', connection.signal.reason);
  } else {
    console.log('Connection closed normally');
  }
  
  performCleanup();
});

Example: Complete Lifecycle Management

Here’s a complete example showing proper lifecycle management:
import * as acp from '@agentclientprotocol/acp';
import { spawn } from 'node:child_process';
import { Readable, Writable } from 'node:stream';

class LifecycleAwareClient implements acp.Client {
  private isShuttingDown = false;
  private pendingOperations = new Set<Promise<unknown>>();
  
  async requestPermission(
    params: acp.RequestPermissionRequest
  ): Promise<acp.RequestPermissionResponse> {
    if (this.isShuttingDown) {
      return { outcome: { outcome: 'cancelled' } };
    }
    
    const operation = this.showDialog(params);
    this.pendingOperations.add(operation);
    
    try {
      return await operation;
    } finally {
      this.pendingOperations.delete(operation);
    }
  }
  
  async sessionUpdate(params: acp.SessionNotification): Promise<void> {
    if (!this.isShuttingDown) {
      this.handleUpdate(params);
    }
  }
  
  async shutdown(): Promise<void> {
    this.isShuttingDown = true;
    console.log('Shutting down client...');
    
    // Wait for pending operations with timeout
    const timeout = new Promise((resolve) => setTimeout(resolve, 5000));
    await Promise.race([
      Promise.allSettled(this.pendingOperations),
      timeout,
    ]);
    
    console.log('Client shutdown complete');
  }
  
  private async showDialog(
    params: acp.RequestPermissionRequest
  ): Promise<acp.RequestPermissionResponse> {
    // Show dialog implementation...
    return { outcome: { outcome: 'selected', optionId: 'allow' } };
  }
  
  private handleUpdate(params: acp.SessionNotification): void {
    // Handle update implementation...
  }
}

async function main() {
  // Spawn agent
  const agentProcess = spawn('npx', ['tsx', 'agent.ts'], {
    stdio: ['pipe', 'pipe', 'inherit'],
  });
  
  // Create connection
  const input = Writable.toWeb(agentProcess.stdin!);
  const output = Readable.toWeb(
    agentProcess.stdout!
  ) as ReadableStream<Uint8Array>;
  const stream = acp.ndJsonStream(input, output);
  
  const client = new LifecycleAwareClient();
  const connection = new acp.ClientSideConnection(
    (agent) => client,
    stream
  );
  
  // Set up lifecycle handlers
  connection.signal.addEventListener('abort', async () => {
    console.log('Connection closed');
    await client.shutdown();
    process.exit(0);
  });
  
  // Handle process signals
  process.on('SIGINT', async () => {
    console.log('Received SIGINT, shutting down...');
    agentProcess.kill();
    await connection.closed;
    process.exit(0);
  });
  
  try {
    // Initialize and use connection
    await connection.initialize({
      protocolVersion: acp.PROTOCOL_VERSION,
      clientCapabilities: {},
    });
    
    // Do work...
    
  } catch (err) {
    console.error('Error:', err);
  } finally {
    // Ensure cleanup
    agentProcess.kill();
    await connection.closed;
  }
}

main().catch(console.error);

Best Practices

Do:
  • Monitor connection.closed or connection.signal for disconnection
  • Clean up resources (timers, file handles) when connection closes
  • Pass connection.signal to operations that should be cancelled
  • Handle both normal and error-based closure
  • Wait for pending operations during shutdown
Don’t:
  • Attempt to send messages after connection closes
  • Leak resources by not cleaning up on close
  • Ignore connection closure errors
  • Block shutdown waiting indefinitely for operations

Build docs developers (and LLMs) love