ACPAgent lets you use any Agent Client Protocol server as the backend for an OpenHands conversation. Instead of calling an LLM directly, the agent spawns an ACP server subprocess and communicates with it over JSON-RPC. The server manages its own LLM, tools, and execution — your code just sends messages and collects responses.
from openhands.sdk.agent import ACPAgentfrom openhands.sdk.conversation import Conversation# Point at any ACP-compatible serveragent = ACPAgent(acp_command=["npx", "-y", "@agentclientprotocol/claude-agent-acp"])conversation = Conversation(agent=agent, workspace="./my-project")conversation.send_message("Explain the architecture of this project.")conversation.run()agent.close()
The acp_command is the shell command used to spawn the server process. The SDK communicates with it over stdin/stdout JSON-RPC.
Key difference from standard agents: With ACPAgent, you don’t need an LLM_API_KEY in your code. The ACP server handles its own LLM authentication and API calls. This is delegation — your code sends messages to the ACP server, which manages all LLM interactions internally.
ACPAgent supports agent_context for prompt-only extensions — skills, repository context, current datetime, and system/user message suffixes are appended to the user message before it reaches the ACP server. This lets you inject the same skill catalog and repo-specific guidance that the built-in Agent receives, without interfering with the server’s own tools or execution model.
from openhands.sdk.agent import ACPAgentfrom openhands.sdk import AgentContextfrom openhands.sdk.context import Skillcontext = AgentContext( skills=[ Skill( name="code-style", content="Always use type hints in Python.", trigger=None, # always active ), ], system_message_suffix="You are reviewing a Python project.",)agent = ACPAgent( acp_command=["npx", "-y", "@agentclientprotocol/claude-agent-acp"], agent_context=context,)
The prompt assembly works as follows:
The conversation layer builds the user MessageEvent, including any per-turn extended_content (e.g. triggered-skill injections).
ACPAgent._build_acp_prompt() collects all text blocks from the message and appends the rendered AgentContext prompt (datetime, repo context, available skills, system suffix) via to_acp_prompt_context().
The combined text is sent as a single user message to the ACP server.
user_message_suffix is an ACP-compatible field, but it is not duplicated in to_acp_prompt_context() because the conversation layer already applies it through MessageEvent.to_llm_message().
Each AgentContext field is tagged as ACP-compatible or not. At initialization, validate_acp_compatibility() rejects any context that uses unsupported fields.
Field
ACP Compatible
Notes
skills
✅
Skill catalog and trigger-based injections
system_message_suffix
✅
Appended to the prompt context
user_message_suffix
✅
Applied by the conversation layer
current_datetime
✅
Included in the rendered prompt
load_user_skills
✅
Load skills from ~/.openhands/skills/
load_public_skills
✅
Load skills from the public extensions repo
marketplace_path
✅
Filter public skills via marketplace JSON
secrets
❌
ACP subprocesses do not use OpenHands secret injection
Passing secrets (or any future field marked acp_compatible: False) raises NotImplementedError.
ACPAgent also works with remote agent-server deployments such as APIRemoteWorkspace, DockerWorkspace, and other RemoteWorkspace-backed setups.When RemoteConversation detects an ACPAgent, it automatically uses the ACP-capable conversation routes for:
conversation creation
conversation info reads
conversation counting
The rest of the lifecycle, including events, runs, pauses, and secrets, continues to use the standard agent-server routes. This keeps the existing remote execution flow intact while isolating the schema-sensitive ACP contract under /api/acp/conversations.
If you attach to an existing conversation by conversation_id, use ACPAgent for ACP-backed conversations. Attaching with a regular Agent to an ACP conversation ID is rejected explicitly to avoid mixing the standard and ACP conversation contracts.
When the ACP server advertises authentication methods, ACPAgent automatically selects a credential source:
ChatGPT subscription login — If the server supports a chatgpt auth method and ~/.codex/auth.json exists (created by LLM.subscription_login()), this is selected first. This enables ACP-backed workflows to use device-code login credentials without an explicit API key.
API key environment variables — Falls back to checking for ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY depending on which auth methods the server supports.
If no supported credential source is found, the server may proceed without authentication (some servers don’t require it).
"""Example: Using ACPAgent with Claude Code ACP server.This example shows how to use an ACP-compatible server (claude-agent-acp)as the agent backend instead of direct LLM calls. It also demonstrates``ask_agent()`` — a stateless side-question that forks the ACP sessionand leaves the main conversation untouched — and sending an image alongsidetext to verify multimodal (vision) input support.Prerequisites: - Node.js / npx available - ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY set (can point to LiteLLM proxy)Usage: uv run python examples/01_standalone_sdk/40_acp_agent_example.py"""import osfrom openhands.sdk import ImageContent, Message, TextContentfrom openhands.sdk.agent import ACPAgentfrom openhands.sdk.conversation import ConversationIMAGE_URL = "https://github.com/OpenHands/docs/raw/main/openhands/static/img/logo.png"agent = ACPAgent(acp_command=["npx", "-y", "@agentclientprotocol/claude-agent-acp"])try: cwd = os.getcwd() conversation = Conversation(agent=agent, workspace=cwd) # --- Main conversation turn (text only) --- conversation.send_message( "List the Python source files under openhands-sdk/openhands/sdk/agent/, " "then read the __init__.py and summarize what agent classes are exported." ) conversation.run() # --- Image input turn (text + image) --- print("\n--- image input ---") conversation.send_message( Message( role="user", content=[ TextContent( text="Describe what you see in this image in one sentence." ), ImageContent(image_urls=[IMAGE_URL]), ], ) ) conversation.run() # --- ask_agent: stateless side-question via fork_session --- print("\n--- ask_agent ---") response = conversation.ask_agent( "Based on what you just saw, which agent class is the newest addition?" ) print(f"ask_agent response: {response}") # Report cost (ACP server reports usage via session_update notifications) cost = agent.llm.metrics.accumulated_cost print(f"EXAMPLE_COST: {cost:.4f}")finally: # Clean up the ACP server subprocess agent.close()cost = conversation.conversation_stats.get_combined_metrics().accumulated_costprint(f"\nEXAMPLE_COST: {cost}")print("Done!")
This example uses ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY environment variables to configure the Claude Code ACP server.
Running the Example
# Set up environment variables (can point to LiteLLM proxy)export ANTHROPIC_BASE_URL="https://your-proxy.example.com"export ANTHROPIC_API_KEY="your-api-key"cd software-agent-sdkuv run python examples/01_standalone_sdk/40_acp_agent_example.py
"""Example: ACPAgent with Remote Runtime via API.This example demonstrates running an ACPAgent (Claude Code via ACP protocol)in a remote sandboxed environment via Runtime API. It follows the same patternas 04_convo_with_api_sandboxed_server.py but uses ACPAgent instead of thedefault LLM-based Agent.Usage: uv run examples/02_remote_agent_server/09_acp_agent_with_remote_runtime.pyRequirements: - LLM_BASE_URL: LiteLLM proxy URL (routes Claude Code requests) - LLM_API_KEY: LiteLLM virtual API key - RUNTIME_API_KEY: API key for runtime API access"""import osimport timefrom openhands.sdk import ( Conversation, RemoteConversation, get_logger,)from openhands.sdk.agent import ACPAgentfrom openhands.workspace import APIRemoteWorkspacelogger = get_logger(__name__)# ACP agents (Claude Code) route through LiteLLM proxyllm_base_url = os.getenv("LLM_BASE_URL")llm_api_key = os.getenv("LLM_API_KEY")assert llm_base_url and llm_api_key, "LLM_BASE_URL and LLM_API_KEY required"# Set ANTHROPIC_* vars so Claude Code routes through LiteLLMos.environ["ANTHROPIC_BASE_URL"] = llm_base_urlos.environ["ANTHROPIC_API_KEY"] = llm_api_keyruntime_api_key = os.getenv("RUNTIME_API_KEY")assert runtime_api_key, "RUNTIME_API_KEY required"# SDK_SHA is the canonical commit SHA set by CI workflows (avoids the# built-in GITHUB_SHA which resolves to the merge-commit on PRs).server_image_sha = os.getenv("SDK_SHA") or os.getenv("GITHUB_SHA") or "main"server_image = f"ghcr.io/openhands/agent-server:{server_image_sha[:7]}-python-amd64"logger.info(f"Using server image: {server_image}")with APIRemoteWorkspace( runtime_api_url=os.getenv("RUNTIME_API_URL", "https://runtime.eval.all-hands.dev"), runtime_api_key=runtime_api_key, server_image=server_image, image_pull_policy="Always", target_type="binary", # CI builds binary target images forward_env=["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"],) as workspace: agent = ACPAgent( acp_command=["claude-agent-acp"], # Pre-installed in Docker image ) received_events: list = [] last_event_time = {"ts": time.time()} def event_callback(event) -> None: received_events.append(event) last_event_time["ts"] = time.time() conversation = Conversation( agent=agent, workspace=workspace, callbacks=[event_callback] ) assert isinstance(conversation, RemoteConversation) try: conversation.send_message( "List the files in /workspace and describe what you see." ) conversation.run() while time.time() - last_event_time["ts"] < 2.0: time.sleep(0.1) # Report cost cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost print(f"EXAMPLE_COST: {cost:.4f}") finally: conversation.close()
Running the Example
export LLM_BASE_URL="https://your-litellm-proxy.example.com"export LLM_API_KEY="your-litellm-api-key"export RUNTIME_API_KEY="your-runtime-api-key"export RUNTIME_API_URL="https://runtime.eval.all-hands.dev"cd software-agent-sdkuv run python examples/02_remote_agent_server/09_acp_agent_with_remote_runtime.py
On the agent-server side, the ACP-capable REST surface lives under /api/acp/conversations, including POST, GET, search, batch get, and count.