CLI Agent Sessions (ACP)
The ACP Agent module lets your Local App create interactive sessions with CLI-based AI agents like Claude Code, Gemini CLI, Qwen, and others. Agents run as persistent processes that can read files, write code, execute commands, and interact with tools.
Prerequisites
The acp.agent permission is required. This is separate from agent.chat because CLI agents can execute commands, read/write files, and perform system operations.
const sdk = new RealtimeXSDK({
realtimex: { apiKey: 'your-api-key' },
permissions: ['acp.agent'],
});Quick Start
Discover available agents
const agents = await sdk.acpAgent.listAgents();
const installed = agents.filter(a => a.installed);
console.log(installed.map(a => `${a.id} (${a.label})`));
// ["claude (Claude Code)", "gemini (Gemini CLI)", "qwen (Qwen CLI)"]Create a session
A session spawns the CLI agent process. The process stays running for all subsequent messages.
const session = await sdk.acpAgent.createSession({
agent_id: 'claude',
cwd: '/path/to/your/project',
approvalPolicy: 'approve-all',
});
console.log(session.session_key); // "agent:claude:acp:550e8400-..."Chat with the agent
Streaming — see text as the agent types:
for await (const event of sdk.acpAgent.streamChat(session.session_key, 'Explain this codebase')) {
if (event.type === 'text_delta') {
process.stdout.write(event.data.text as string);
}
}Sync — wait for the full response:
const response = await sdk.acpAgent.chat(session.session_key, 'What is 2+2?');
console.log(response.text);Sync chat requires approvalPolicy to be set on the session (at creation or via patchSession). Without it, the endpoint returns 400 because permission requests cannot be resolved interactively over a synchronous call.
Close the session
await sdk.acpAgent.closeSession(session.session_key);Streaming Events
The streamChat method returns an async iterator yielding events:
| Event Type | Fields | Description |
|---|---|---|
text_delta | text, stream | Agent text output. stream is "output" for the response or "thought" for reasoning |
tool_call | text, title, toolCallId, status | Tool usage (file reads, writes, commands). status is "running" or "completed" |
permission_request | requestId, action, target, options, expiresAt | Agent needs approval for an action |
done | stopReason | Turn complete |
error | message, code | Turn failed |
close | success | SSE stream ended |
Handling thought vs output
Agents like Qwen and Claude emit reasoning as thought chunks before the actual response. Display them separately for a better UX:
for await (const event of sdk.acpAgent.streamChat(key, message)) {
if (event.type === 'text_delta') {
if (event.data.stream === 'thought') {
// Show in a muted/italic style
renderThought(event.data.text);
} else {
// Show as the main response
renderOutput(event.data.text);
}
}
}Permission Flow
CLI agents can read files, write code, and execute commands. The approvalPolicy controls which actions require explicit approval.
Approval Policies
| Policy | Behavior |
|---|---|
approve-all | All tool calls auto-approved. Best for trusted automation. |
approve-reads | File reads auto-approved. Writes and commands require approval. |
| (none set) | Agent's default behavior applies (varies by agent). |
Interactive Permissions (Streaming Only)
When using approve-reads, write operations trigger a permission_request event. Your app must resolve it while the SSE stream is still active:
for await (const event of sdk.acpAgent.streamChat(key, 'Create hello.txt')) {
if (event.type === 'permission_request') {
const { requestId, action, target, options } = event.data;
console.log(`Agent wants to ${action}: ${target}`);
console.log(`Options: ${options}`);
// Resolve the permission (while stream is still open)
await sdk.acpAgent.resolvePermission(key, {
requestId: requestId as string,
optionId: 'allow_once', // or pick from options array
});
} else if (event.type === 'text_delta') {
process.stdout.write(event.data.text as string);
}
}Model Selection
Some agents support multiple models. Discover available models and select one at session creation:
// List agents with their models
const agents = await sdk.acpAgent.listAgents({ includeModels: true });
const claude = agents.find(a => a.id === 'claude-cli');
console.log(claude?.models);
// [{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet" }, ...]
// Create session with specific model
const session = await sdk.acpAgent.createSession({
agent_id: 'claude',
model: 'claude-sonnet-4-20250514',
cwd: '/my/project',
approvalPolicy: 'approve-all',
});The model is injected as a CLI flag at agent launch time (e.g., --model claude-sonnet-4-20250514). It cannot be changed after session creation.
Provider Forwarding
Some agents (like Qwen) support provider forwarding — using credentials from another AI provider instead of their native one. This lets you run models like claude-sonnet-4-6 through the Qwen CLI agent by routing requests through RealTimeX AI's API.
Discover forwarding support
const agents = await sdk.acpAgent.listAgents();
const qwen = agents.find(a => a.id === 'qwen-cli');
console.log(qwen?.supportsProviderForwarding); // true
console.log(qwen?.forwardableProviders);
// ["openai", "anthropic", "gemini", "realtimexai"]Run Claude Sonnet via Qwen CLI + RealTimeX AI
Combine forwardedProvider with model to run any supported model through the forwarded provider's API:
const session = await sdk.acpAgent.createSession({
agent_id: 'qwen',
forwardedProvider: 'realtimexai', // Route through RealTimeX AI
model: 'claude-sonnet-4-6', // Use Claude Sonnet 4.6
cwd: '/path/to/project',
approvalPolicy: 'approve-all',
});
// Qwen CLI now uses Claude Sonnet 4.6 via RealTimeX AI's API
for await (const event of sdk.acpAgent.streamChat(session.session_key, 'Explain this codebase')) {
if (event.type === 'text_delta') process.stdout.write(event.data.text as string);
}At spawn time, the server reads REALTIMEX_AI_API_KEY from its environment, maps it to the CLI agent's expected format, and injects --auth-type openai (RealTimeX AI uses the OpenAI-compatible protocol). The model flag tells the agent which model to request from the forwarded provider.
Provider forwarding requires the target provider's API key to be configured on the RealTimeX server (e.g., REALTIMEX_AI_API_KEY for RealTimeX AI, OPEN_AI_KEY for OpenAI). If credentials are missing, session creation will fail.
Attachments
Send images to vision-capable agents alongside your message:
import fs from 'fs';
const imageBuffer = fs.readFileSync('screenshot.png');
const base64 = imageBuffer.toString('base64');
for await (const event of sdk.acpAgent.streamChat(
session.session_key,
'What do you see in this image?',
[{ contentString: `data:image/png;base64,${base64}`, mime: 'image/png' }]
)) {
if (event.type === 'text_delta') process.stdout.write(event.data.text as string);
}Session Management
Runtime Options
Update session configuration between turns:
await sdk.acpAgent.patchSession(session.session_key, {
timeoutSeconds: 300,
approvalPolicy: 'approve-all',
});Available options: model, cwd, timeoutSeconds, runtimeMode, approvalPolicy, extras.
List & Inspect Sessions
const sessions = await sdk.acpAgent.listSessions();
for (const s of sessions) {
console.log(`${s.session_key} — ${s.agent_id} [${s.state}]`);
}
const status = await sdk.acpAgent.getSession(session.session_key);
console.log(status.runtime_options);Cancel Active Turn
await sdk.acpAgent.cancelTurn(session.session_key, 'user_cancelled');Session Lifecycle
Understanding how sessions work helps optimize your app's performance:
- One process per session —
createSessionspawns the CLI agent. All subsequentchat/streamChatcalls reuse the same process. - Multi-turn context — the agent maintains conversation history across turns within a session. No need to re-send context.
- Idle eviction — after 5 minutes of inactivity, the process is stopped to free resources. The next turn transparently re-spawns it (with a brief startup delay).
- Explicit close — call
closeSessionwhen done to free resources immediately.
createSession → spawn process (once)
├─ chat/streamChat → reuse process (turn 1)
├─ chat/streamChat → reuse process (turn 2)
├─ ... idle 5 min ... → process stopped
├─ chat/streamChat → re-spawn process (turn 3)
└─ closeSession → stop process, cleanupError Handling
All SDK methods throw on failure. ACP-specific error codes:
| Code | HTTP | Meaning |
|---|---|---|
ACP_DISABLED | 503 | ACP feature not enabled on the server |
ACP_UNKNOWN_AGENT | 404 | Agent ID not recognized |
ACP_AGENT_NOT_ALLOWED | 403 | Agent blocked by server policy |
ACP_MAX_SESSIONS | 429 | Too many concurrent sessions |
ACP_SESSION_NOT_FOUND | 404 | Session key invalid or expired |
ACP_SESSION_INIT_FAILED | 502 | Agent process failed to start |
ACP_INVALID_OPTION | 400 | Invalid runtime option value |
APPROVAL_POLICY_REQUIRED | 400 | Sync chat requires approvalPolicy on session |
try {
await sdk.acpAgent.createSession({ agent_id: 'nonexistent' });
} catch (err) {
console.error(err.message); // "No ACP provider found for agent 'nonexistent'."
}API Reference
sdk.acpAgent / sdk.acp_agent
| Method | Description |
|---|---|
listAgents({ includeModels? }) | List available CLI agents |
createSession({ agent_id, cwd?, model?, label?, approvalPolicy?, forwardedProvider? }) | Create and start a session |
getSession(sessionKey) | Get session status |
listSessions() | List active sessions owned by this app |
patchSession(sessionKey, options) | Update runtime options |
closeSession(sessionKey, reason?) | Stop agent and close session |
chat(sessionKey, message, attachments?) | Synchronous turn |
streamChat(sessionKey, message, attachments?) | Streaming turn (async iterator) |
cancelTurn(sessionKey, reason?) | Cancel active turn |
resolvePermission(sessionKey, { requestId, optionId }) | Resolve permission request |
Types (TypeScript)
interface AcpSessionOptions {
agent_id: string;
cwd?: string;
model?: string;
label?: string;
approvalPolicy?: 'approve-all' | 'approve-reads' | 'deny-all';
forwardedProvider?: string;
}
interface AcpSession {
session_key: string;
agent_id: string;
state: 'initializing' | 'ready' | 'stale' | 'closed';
backend_id: string;
created_at: string;
}
interface AcpStreamEvent {
type: 'text_delta' | 'status' | 'tool_call' | 'permission_request'
| 'done' | 'error' | 'close';
data: Record<string, unknown>;
}
interface AcpAttachment {
contentString: string; // data URI: "data:image/png;base64,..."
mime: string; // "image/png"
}