Files
OpenHands/docs/ACP_AGENTS.md
T
ec4616c1c7 feat(acp): containerized + cloud ACP — onboarding, secrets, and recycled-sandbox resume (#1013/#1014/#988) (#1102)
* feat(acp): containerized ACP — credential onboarding + inline secrets (#1013/#1014)

Wire the canvas halves of agent-canvas#1014 (Docker) and #1013 (credential
onboarding) so a user can run an ACP agent (Codex / Claude Code / Gemini)
against a containerized agent-server through Canvas, with credentials supplied
in the UI.

Credential onboarding UX (#1013):
- Extend the ACP secrets step beyond the API key to the per-provider reserved
  credentials a fresh container needs: Codex CODEX_AUTH_JSON, Claude
  CLAUDE_CODE_OAUTH_TOKEN, Gemini GOOGLE_APPLICATION_CREDENTIALS_JSON +
  GOOGLE_CLOUD_PROJECT/LOCATION + GOOGLE_GENAI_USE_VERTEXAI. File-content blobs
  render as multiline fields.
- Make the step capability-driven: required on a backend with no host login
  (cloud, or a logged-out local/Docker backend per the auth probe), optional
  when a login is detected or the probe can't classify (native dev).
- Fix the orphaned-secret bug: warn instead of toasting "Saved" when the active
  backend can't consume the credential (cloud can't yet read file secrets).

Send secrets + model (start request):
- buildStartConversationRequest emits reserved ACP credentials inline as
  StaticSecrets (overriding any same-named LookupSecret) and mirrors them onto
  agent_context.secrets, so the SDK's acp_file_secrets defaults materialise the
  *_JSON blobs before the CLI spawns. The orchestrator reads back the saved
  reserved values for the active provider (local backends only).
- Preselect a Vertex-safe acp_model for Gemini (gemini-2.5-flash) so a fresh
  container doesn't hit gemini-cli's preview default that 404s on Vertex.
- Never auto-promote *_BASE_URL to an inline secret (an inherited base URL
  breaks the Claude OAuth token's bearer auth).

Docker setup + docs:
- examples/acp-docker/ docker-compose (persistent volume + canvas_ui tool mount
  + credential notes); .env.sample + docs point VITE_BACKEND_BASE_URL at it.
- docs/ACP_AGENTS.md gains a "Running ACP agents in a Docker container" section.

Per-conversation isolation (acp_isolate_data_dir) left as a documented TODO —
the field isn't exposed on ACPAgentSettings in the released typescript-client.

Tests + e2e:
- Unit tests for the StaticSecret emission, reserved-credential sets, Vertex
  model default, getSecretValues read-back, and the required-credentials matrix.
- tests/e2e/live-acp/: a vite-node harness that builds each provider's request
  via buildStartConversationRequest and POSTs it to a real container. Validated
  with REAL API calls against agent-server c950fdb-python: Codex ✅, Claude ✅,
  Gemini ✅ (materialise ADC -> vertex-ai -> real reply). Gemini's default-config
  init is blocked by an SDK/gemini-cli set_session_mode("yolo") issue (documented
  caveat, not a credential problem).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(acp): make containerized credentials survive the real conversation-start path

Validating end-to-end through the application's own orchestrator
(buildStartConversationRequestWithEncryptedSettings) against a live container —
rather than the request builder in isolation — surfaced two real bugs that would
have broken the feature in the product:

1. secrets_encrypted mangled the plaintext reserved StaticSecrets. The app always
   fetches settings in encrypted mode, so the start request carried
   secrets_encrypted=true. The agent-server then runs every secret value through
   cipher.decrypt() during validation — including our reserved ACP creds, which
   are read back as PLAINTEXT. Result: the credential was silently dropped
   (decrypt fails → None) on a cipher backend, or a hard 500 ("cipher not
   configured") on a fresh container with no OH_SECRET_KEY. Fix: don't set
   secrets_encrypted for ACP conversations — an ACP agent has no encrypted agent
   secret (no LLM api_key), and its provider creds ride as plaintext StaticSecrets.

2. A different provider's leftover file-content secret broke the active provider.
   A CODEX_AUTH_JSON saved while onboarding Codex leaks into a later Claude
   conversation via the global-secrets → LookupSecret path. The SDK materialises
   file secrets eagerly at spawn by resolving the secret source, and a LookupSecret
   resolution stalls → ReadTimeout → "Failed to start ACP server: timed out". Fix:
   reserved file-content blobs (the multiline *_JSON creds) never travel as
   LookupSecrets — the active provider's is sent inline as a StaticSecret, any
   other provider's is dropped (getAllReservedAcpFileSecretNames).

Re-validated through the app orchestrator against agent-server c950fdb-python
(onboarding createSecret → buildAcpAgentSettingsDiff PATCH → orchestrator
read-back → real reply): Codex ✅, Claude ✅ (leftover CODEX_AUTH_JSON correctly
dropped). Gemini's app path is correct (StaticSecrets emitted, vertex-ai auth
reached); this run hit the documented invalid_rapt stale-ADC caveat (host ADC
expired since the prior fresh-ADC pass) — an environment issue, not code.

Adds regression tests (secrets_encrypted suppressed for ACP / kept for non-ACP;
leftover file blob dropped not LookupSecret'd; getAllReservedAcpFileSecretNames)
and the app-path e2e harness (tests/e2e/live-acp/acp-docker-app-e2e.mts). Notes
OH_SECRET_KEY as optional (secret persistence) in the compose example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: address PR review feedback (#1102)

- retag acp_isolate_data_dir TODO #1014 (this PR) -> #1019 (the
  per-conversation isolation follow-up the knob serves)
- note the Gemini Vertex scalars (PROJECT/LOCATION/USE_VERTEXAI) are
  plain config / a routing flag, not secrets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(acp): order subscription credential before API key in onboarding

Show each provider's reserved subscription/Vertex credential first
(Claude CLAUDE_CODE_OAUTH_TOKEN, Codex CODEX_AUTH_JSON, Gemini Vertex SA),
then the API key, then the base URL — the subscription token is the
primary auth path for ACP providers, with the API key as the fallback.
Display order only; getAcpProviderSecrets consumers are order-independent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(i18n): disable i18next value escaping so React handles it

i18next's default escapeValue double-escapes interpolated values on top
of React's own escaping, rendering paths like ~/.codex/auth.json as
~&#x2F;.codex&#x2F;auth.json. Set interpolation.escapeValue=false (the
standard react-i18next config); React still escapes at render time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(acp): unify secret wire-delivery; keep "reserved" as onboarding-only

Drop the reserved-vs-custom split in how secrets reach the agent-server.
Previously, provider credentials ("reserved") rode inline as StaticSecrets
while user secrets rode as loopback LookupSecrets — a fork introduced only
to dodge a deadlock: the SDK resolved an ACP agent's secrets synchronously
on its event loop at CLI spawn, so a loopback LookupSecret self-deadlocked.

That deadlock is fixed at the source in software-agent-sdk#3510 (ACP
cold-start runs off the event loop), so the workaround is no longer needed.
Now every secret — env-var credential, file-content blob, or user secret —
ships uniformly as a LookupSecret, for ACP and non-ACP alike. The SDK
resolves and (for file blobs) materialises them off the loop, so the
loopback fetch is safe.

"Reserved" survives only as an onboarding/validation concept (which fields
to prompt for per provider, capability-driven required steps) — it no
longer affects the wire.

Removed: StaticSecret type, acpStaticSecrets option + the inline path, the
file-blob lookupSkip, SecretsService.getSecretValues, and the reserved-name
value read-back. Kept: secrets_encrypted suppression for ACP (an ACP
request carries no encrypted payload, and a fresh ACP container may have no
OH_SECRET_KEY cipher).

Note: getReservedAcpSecretNames / getAllReservedAcpFileSecretNames in
constants/acp-providers.ts are now unused by the wire; the former is still
useful for validation, the latter can be pruned.

Depends on software-agent-sdk#3510.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(acp): prune now-dead reserved-secret wire helpers

Follow-up to the wire-delivery unification: getReservedAcpSecretNames and
getAllReservedAcpFileSecretNames were only ever consumed by the inline
StaticSecret / file-blob-skip path, which is gone. They have no remaining
production callers, so remove them (and their tests). The reserved-credential
field definitions (ACP_RESERVED_CREDENTIALS, getAcpProviderSecrets) and the
``reserved`` / ``multiline`` flags stay — onboarding still reads them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(acp): re-point containerized ACP at SDK 1.25.0 (#3510) + fix e2e harnesses

The unified LookupSecret delivery (e076e9bb) depends on software-agent-sdk#3510
(ACP cold-start off the event loop), which first ships in v1.25.0. The example
compose/docs/e2e all still defaulted to agent-server:c950fdb-python, which
predates #3510 and deadlocks the first ACP turn ("Failed to start ACP server:
timed out"). Bump every default to 1.25.0-python and document it as the minimum.

Also realign the live-acp e2e harnesses, which still encoded the removed
StaticSecret API (the PR's headline evidence predated the unification):
- acp-docker-e2e.mts: store each credential via SecretsService.createSecret,
  send name-only customSecrets, assert every emitted secret is a LookupSecret.
- acp-docker-app-e2e.mts: flip the assertion StaticSecret -> LookupSecret; drop
  the stale getSecretValues reference.
- Both: fix a polling bug where "idle" (the transient pre-run state) was treated
  as terminal, so the loop bailed before the agent ran and read an empty reply.
  Terminal is now {finished, error, stuck, stopped}.

Correct the stale StaticSecret doc comments in constants/acp-providers.ts
(reserved is now an onboarding/validation marker, not a wire distinction).

Re-validated in-container against agent-server:1.25.0-python: Codex and Claude
pass end-to-end on both harnesses (LookupSecret resolves off-loop, no deadlock,
even with leftover cross-provider file-secrets present). Gemini's credential
path is proven (vertex-ai auth reached) but the turn is blocked by gemini-cli
0.45.x ignoring the requested acp_model and running gemini-3-flash — an SDK
model-selection concern tracked in software-agent-sdk#3532, not a Canvas bug;
the docs/e2e notes are corrected accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(acp): improve credential hint text with fetch commands

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(acp): show provider credentials in Settings → Agent

Adds a Credentials section to /settings/agent when an ACP provider is
selected, so users can set or rotate tokens/keys after onboarding without
hunting through Settings → Secrets. Mirrors the onboarding fields exactly
(same hints, same already-saved placeholders, Optional tag on multiline
fields) with its own Save button that writes directly to the secret store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(acp): drop the agent_context.secrets mirror — request.secrets is the sole channel

The mirror's justification ("ACPAgent's spawn-time env loop reads from
agent_context.secrets, not the registry") predates the pinned minimum
agent-server: 1.25.0 already injects the ACP spawn env from
secret_registry, seeded from request.secrets (sdk#3299/#3464), and
sdk#3528 removes the agent_context drain entirely. Keeping the mirror
preserved a second, dead credential channel — the exact coupling
agent-canvas#1039 is eliminating.

Canvas now sends every credential in top-level request.secrets only.
Tests inverted to pin the single-channel contract; adapter/type
comments updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(acp): non-flash Gemini default, shared credential form, review cleanups

- ACP_VERTEX_SAFE_MODEL → gemini-2.5-pro: gemini-cli 0.45.x re-resolves any
  *-flash id at generation time to its current default flash (sdk#3532), so a
  flash pin is never honored; docs + e2e defaults updated to match
- extract AcpSecretField + useSaveAcpSecrets and move AcpCredentialsSection
  to components/ — onboarding and Settings → Agent share one field renderer
  and one save flow (incl. the orphaned-file-credential warning on cloud)
- a required credentials step is only satisfied by an actual credential (a
  masked `secret` field) — a base URL or GCP scalar alone no longer unblocks
- warn inline when CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_BASE_URL are both
  set (typed or saved) — the pair silently breaks bearer auth
- drop the near-dead `reserved` field flag; collapse the leftover two-block
  secrets scaffolding in buildStartConversationRequest
- sync 14 stale locales on the OAuth/file-blob hints; fix issue refs
  (TODO #1019→#1014 — #1019 is closed; OpenHands#1016→agent-canvas#1016)
- tests: settings credentials-section coverage, non-flash pin, conflict
  matrix, tightened-gate cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(acp): unify default-model surfaces + dedupe credential forms and e2e harness

Review-pass cleanups:

- Route ALL three default-model surfaces (onboarding diff builder,
  Settings -> Agent seeding, start-request null fallback, + chat-input
  display) through getAcpPreferredDefaultModel, so the Vertex-safe
  Gemini override can't diverge between surfaces. New regression tests
  pin the diff-builder and start-request fallbacks to it.
- Extract useAcpCredentialForm + AcpConflictWarnings: the onboarding
  step and the Settings credentials section now share the values state,
  existing-secret lookups, conflict pairs, and save flow.
- Extract tests/e2e/live-acp/harness.mts: provider plans, host
  credential collectors, and HTTP/poll helpers shared by both live
  scripts (a model default can no longer drift between them).
- Restore the TODO(#1019) retag (accidentally reverted to the
  self-referencing #1014 in the last cleanup commit); same fix in
  docs/ACP_AGENTS.md.
- Drop the tautological ACP_VERTEX_SAFE_MODEL literal assertion, fix a
  dead key-ternary in getAcpProviderSecrets, TODO(#1016) on the
  cloud file-credential capability check, and document that baked .env
  creds don't satisfy the onboarding login probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: address PR review feedback (#1102)

- Restore package-lock.json to main — the npm-install churn (29 dropped
  "dev": true flags) was never meant to ship with this PR
- Note why global escapeValue:false is safe (React escapes at render;
  no translated string hits dangerouslySetInnerHTML)
- Note the non-macOS skip path in the e2e claudeOAuthToken collector

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(acp): tighten the credential gate + clarify base-URL docs (#1102 review)

- A file blob no longer satisfies the required credential step on a
  backend that can't materialise it (cloud, #1016) — the save flow
  already warned it was orphaned, so it can't be what opens the gate.
  consumesFileCredentials moves into useAcpCredentialForm so the gate
  and the save warning share one capability check.
- Next stays disabled while the login probe is still classifying a
  local backend, so a fast click can't slip past a gate about to come
  up "unauthenticated". A probe that completes as "unknown" stays
  permissive.
- Docs: a saved *_BASE_URL secret does ride along on every start
  request like any other saved secret; Canvas only never derives one
  from LLM settings. Reword the two claims that suggested otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(e2e): record 2026-06-07 re-validation — all three providers pass

Fresh 1.25.0-python container + fresh volume at the branch tip: Codex and
Claude pass both scripts; Gemini's full turn now passes too (fresh ADC +
gemini-2.5-pro + session-mode override), upgrading the previous
"blocked on model selection" row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(acp): resume a recycled cloud ACP conversation via bootstrap prompt (#988)

A cloud ACP conversation whose sandbox was recycled (STOPPED/MISSING, e.g. the
runtime idle-stopped or hit its TTL) was a read-only dead end: the chat input
was replaced by the archived banner, and cloud createConversation never
re-provisions an existing conversation_id. The backend already supports
resuming such a conversation — re-issuing the start with the same
conversation_id rebuilds it and, for ACP, replays the durable event store as a
bootstrap prompt (OpenHands#14640) — but nothing in canvas triggered it.

Surface it:
- AppConversationStartRequest.conversation_id so the cloud start path can target
  an existing conversation.
- wakeRecycledCloudConversation(id, repoSelection): re-POST /api/v1/app-conversations
  with the conversation_id (and repo selection, so the rebuilt working dir
  matches the original cwd an ACP resume keys off).
- useWakeConversation mutation: wakes + invalidates the conversation queries so
  the active-conversation poll reconnects once the fresh sandbox is RUNNING.
- A Resume button in the archived banner for an ACP conversation whose sandbox
  is MISSING (ERROR stays read-only).

Validated e2e against a local SaaS-equivalent stack (OpenHands main app_server +
a main-built agent-server image, Docker sandboxes): create an ACP conversation,
docker rm -f the sandbox, wake → fresh sandbox + bootstrap-prompt resume, the
agent recalls prior context (codeword) and the <<RESUMED CONVERSATION>> marker
is present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(acp): consume file-content credentials on cloud too (#988)

Cloud now materialises reserved file-content credentials (Codex auth.json,
Gemini Vertex SA) from the per-user encrypted secret store via
agent_context.secrets at conversation start (the cloud backend pins an SDK that
materialises reserved file secrets), so a pasted blob is consumable on every
supported backend — not just local. Drop the local-only gate on
consumesFileCredentials: a Codex/Gemini file blob now satisfies the onboarding
credential gate on cloud and saving it toasts success instead of the
orphaned-credential warning.

Folds the remaining cloud-enablement piece in from the native-resume canvas
branch (the wake/bootstrap-resume path landed separately); native session/load
is a backend-only concern (SDK + OpenHands), so canvas needs nothing further.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:14:14 +02:00

12 KiB

Using ACP agents

Agent Canvas can drive your conversations with the built-in OpenHands agent or with an external ACP agent — Claude Code, Codex, or Gemini CLI. This guide explains what ACP agents are, how to onboard one, and how to switch agents or models later.

What is an ACP agent?

The Agent Client Protocol (ACP) is a standard for talking to coding agents over JSON-RPC on stdio. Instead of Agent Canvas calling an LLM directly, the Agent Server spawns the agent's own CLI as a subprocess and relays each turn to it. The external agent manages its own LLM, tools, and execution; Agent Canvas sends messages and renders what comes back.

flowchart LR
    canvas["Agent Canvas<br/>(this UI)"]
    server["Agent Server"]
    acp["ACP subprocess<br/>(e.g. claude-agent-acp)"]
    llm["LLM provider<br/>(Anthropic / OpenAI / Google)"]
    canvas -- "PATCH /api/settings<br/>(agent_kind, acp_*)" --> server
    canvas -- "conversation turns" --> server
    server -- "spawn + JSON-RPC over stdio" --> acp
    acp -- "API calls" --> llm

The Agent Server owns the subprocess and the credentials; Agent Canvas only records which agent to run and surfaces a form for the secrets it needs. The agent choice is stored per backend, so switching backends can switch agents.

Supported providers

The provider list is sourced from the SDK registry (openhands.sdk.settings.acp_providers, mirrored into @openhands/typescript-client) and enriched with Canvas UI metadata in src/constants/acp-providers.ts. Adding or changing a provider happens upstream in the SDK, not here.

Provider Default command
Claude Code npx -y @agentclientprotocol/claude-agent-acp
Codex npx -y @zed-industries/codex-acp
Gemini CLI npx -y @google/gemini-cli --acp

See Authentication for how each one authenticates.

Authentication

Important

ACP agents authenticate two ways: a subscription login, or an API key — and the onboarding fields are optional. If you're already signed in to the provider's CLI on the machine the agent runs on, it reuses that login automatically, so locally you often don't need a key at all. The login takes priority over an API key: while you're signed in, a key set in the environment isn't used — so the onboarding key fields do nothing and can be left blank.

A "subscription login" is the credential the provider's own CLI stores when you sign in once — a file in your home directory, or, for Claude Code on macOS, the system Keychain. When the Agent Server runs on that same machine (a local or self-hosted backend), the provider CLI finds that login automatically — no API key required. On a clean cloud sandbox there's no stored login, so an API key is needed instead.

Provider Subscription login (auto-detected) API key
Claude Code A Claude Code login (Pro/Max), from Claude Code's own credential store: the macOS Keychain, or ~/.claude/.credentials.json on Linux ANTHROPIC_API_KEY (onboarding)
Codex A ChatGPT login (codex login) cached at ~/.codex/auth.json OPENAI_API_KEY (onboarding)
Gemini CLI Your Google login (gemini/gemini --acp) cached at ~/.gemini/oauth_creds.json GEMINI_API_KEY (onboarding)

All three collect an optional API key (+ base URL) in onboarding. As noted above, a subscription / OAuth login takes priority over an API key — when the provider's CLI is signed in, a key set in the environment is not used. Verified per provider:

  • Codex — codex login status keeps reporting the ChatGPT login even with OPENAI_API_KEY set.
  • Gemini CLI — uses the OAuth auth type chosen at gemini login; GEMINI_API_KEY is only consulted if you switch the auth type. The free Google login is the common no-key path locally — sign in once and it just works.
  • Claude Code — with both present, claude auth status reports it is authenticated via the subscription (claude.ai), not the key. The login is auto-detected from the macOS Keychain (or ~/.claude/.credentials.json on Linux); CLAUDE_CONFIG_DIR is not required for it — it only relocates Claude Code's config directory (settings/history, not the token; e.g. for containers or multiple accounts) and signals the SDK to strip a conflicting ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL.

The one exception is the base URL (*_BASE_URL): a custom value points the CLI at a different endpoint (a proxy or gateway) and does take effect even under a login — for Gemini it rides the ACP gateway param. It's an advanced override, not needed for normal use.

Onboarding an ACP agent

First-time users get a four-step onboarding modal. To onboard an ACP agent:

  1. Choose agent — pick Claude Code, Codex, or Gemini CLI instead of OpenHands. The choice is saved immediately to your backend's settings.

  2. Check backend — confirms Agent Canvas can reach the Agent Server.

  3. Set up credentials — enter the provider's credentials. Beyond the API key (+ optional base URL), this step also collects the credentials a containerized backend needs, since a fresh container has no host login:

    • Codex — CODEX_AUTH_JSON (the contents of ~/.codex/auth.json).
    • Claude Code — CLAUDE_CODE_OAUTH_TOKEN (a Pro/Max OAuth token).
    • Gemini CLI — GOOGLE_APPLICATION_CREDENTIALS_JSON (Vertex SA / ADC JSON) plus GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION, and GOOGLE_GENAI_USE_VERTEXAI.

    On a local backend the step is optional (a host login is reused automatically); on a Docker / cloud backend it's required, because there's no host login to fall back on. When the login probe detects an existing session, the step shows a "you're already signed in" banner and stays skippable.

  4. Say hello — creates your first conversation and closes the modal.

Note

On a local backend every credential field is optional and the step is skippable. Leave a field blank to reuse a key already set on the backend, or to authenticate the agent through a subscription / OAuth login instead.

How credentials reach the agent

Each credential you enter is saved as a global secret whose name is exactly the environment variable the Agent Server exports into the ACP subprocess (e.g. ANTHROPIC_API_KEY). Saving in onboarding is identical to adding the secret under Settings → Secrets, where you can edit or remove it anytime. Keeping the secret name equal to the env var is what makes a saved key actually reach the provider CLI.

Running ACP agents in a Docker container

The walkthrough above assumes the Agent Server runs on your own machine, where the provider CLIs reuse a host login. You can also run the Agent Server in a container — Canvas drives it the same way, but since a fresh container has no host login, you supply credentials through the UI and Canvas sends them inline on the conversation start request.

A ready-to-run setup lives in examples/acp-docker/ (docker compose up, then point Canvas at it). In short:

# 1. Agent Server in a container (CORS allows localhost, so the browser talks
#    to it directly). The image pre-installs the ACP CLI wrappers. The
#    canvas_ui tool is mounted so the agent-server can import the module Canvas
#    references in every start request.
# Minimum image: 1.25.0-python (first release with software-agent-sdk#3510;
# older images deadlock the first ACP turn). Override SHA with a newer build.
docker run -d --name oh-acp -p 8010:8000 -v acp-data:/workspace \
  -v "$(pwd)/tools:/canvas-tools:ro" -e OH_EXTRA_PYTHON_PATH=/canvas-tools \
  ghcr.io/openhands/agent-server:1.25.0-python

# 2. Canvas pointed at the container.
VITE_BACKEND_BASE_URL=http://localhost:8010 npm run dev:frontend

How credentials reach a containerized agent

In onboarding's Set up credentials step, the credentials you enter are saved as global secrets in the agent-server's secret store (as usual). The start request then references each as a LookupSecret — uniformly for ACP and non-ACP — and the agent-server resolves the value back from its own store at spawn time. For ACP this resolution runs off the event loop (software-agent-sdk#3510), so the loopback fetch does not self-deadlock. The SDK's acp_file_secrets defaults then:

  • materialise CODEX_AUTH_JSON back to auth.json under CODEX_HOME and point Codex at it;
  • materialise GOOGLE_APPLICATION_CREDENTIALS_JSON to a file referenced by GOOGLE_APPLICATION_CREDENTIALS and route Gemini through Vertex AI;
  • export the rest (CLAUDE_CODE_OAUTH_TOKEN, project/location, API keys) as env vars for the CLI.

Canvas just sends the secrets — it does not hand-roll the file materialisation. The npx -y <pkg> command is rewritten to the pinned pre-installed binary inside the container by the SDK, so no command change is needed.

Important

Do not set ANTHROPIC_BASE_URL alongside the Claude OAuth token. An inherited LiteLLM base URL silently breaks the token's bearer auth (it routes the request away from Anthropic). Canvas never derives a base-URL secret from your LLM settings — but a base URL you save yourself rides along on every start request like any other saved secret, which is why the credential forms warn when both are set. Only set it deliberately, and not with the OAuth path.

Important

Gemini Vertex needs a fresh ADC. Run gcloud auth application-default login before copying ~/.config/gcloud/application_default_credentials.json — a stale token surfaces as invalid_rapt, which is a credential problem, not a Canvas bug.

Note

Pick a non-flash Gemini model. gemini-cli 0.45.x re-resolves any *-flash model id at generation time to its current default flash (e.g. gemini-2.5-flash silently ran gemini-3-flash, which 404s on projects that don't serve it — software-agent-sdk#3532). Only a non-flash id sticks, so Canvas preselects gemini-2.5-pro. If a Gemini turn fails with Publisher Model … was not found, check the selected model isn't a flash id.

Per-conversation isolation

Concurrent same-provider conversations in one container share a HOME, so they can race on the CLI's auth/config/lock files. The SDK supports opting into a per-conversation data dir (acp_isolate_data_dir, software-agent-sdk#3492), but the released @openhands/typescript-client does not yet expose it on ACPAgentSettings, so Canvas can't send it without risking a validation error on older servers. This is tracked as a follow-up (agent-canvas#1019); cloud grouping isolation is separate (agent-canvas#1016).

Switching agent or model later

Open Settings → Agent at any time:

  • Agent — switch between OpenHands and ACP.
  • Preset — pick a built-in provider (Claude Code, Codex, Gemini CLI) or Custom to point at any other ACP server.
  • Command — the command line used to spawn the subprocess. Selecting a preset fills this in; editing it to match another preset re-detects that provider. API keys are not entered here — they live in the Secrets panel.
  • Model — choose a suggested model for the provider or enter a custom model override. Built-in providers save a concrete model rather than leaving it blank.

Saving writes an agent_settings_diff (agent_kind, acp_server, acp_command, acp_model) to PATCH /api/settings. A running conversation keeps the agent it started with; the new choice applies to conversations you start afterward.

Custom ACP servers

Any stdio ACP server works: choose Custom in Settings → Agent and enter its launch command. Custom servers have no curated model list, so enter the model ID the server expects (if any) as a custom model. Pass credentials by adding the env vars the server reads as global secrets under Settings → Secrets.