Local Apps
Developer Guide
CLI Agent Sessions

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 TypeFieldsDescription
text_deltatext, streamAgent text output. stream is "output" for the response or "thought" for reasoning
tool_calltext, title, toolCallId, statusTool usage (file reads, writes, commands). status is "running" or "completed"
permission_requestrequestId, action, target, options, expiresAtAgent needs approval for an action
donestopReasonTurn complete
errormessage, codeTurn failed
closesuccessSSE 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

PolicyBehavior
approve-allAll tool calls auto-approved. Best for trusted automation.
approve-readsFile 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 sessioncreateSession spawns the CLI agent. All subsequent chat/streamChat calls 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 closeSession when 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, cleanup

Error Handling

All SDK methods throw on failure. ACP-specific error codes:

CodeHTTPMeaning
ACP_DISABLED503ACP feature not enabled on the server
ACP_UNKNOWN_AGENT404Agent ID not recognized
ACP_AGENT_NOT_ALLOWED403Agent blocked by server policy
ACP_MAX_SESSIONS429Too many concurrent sessions
ACP_SESSION_NOT_FOUND404Session key invalid or expired
ACP_SESSION_INIT_FAILED502Agent process failed to start
ACP_INVALID_OPTION400Invalid runtime option value
APPROVAL_POLICY_REQUIRED400Sync 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

MethodDescription
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"
}