Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 23 additions & 32 deletions __tests__/components/onboarding/onboarding-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,26 +283,16 @@ describe("OnboardingModal", () => {
expect(settings.contains(next)).toBe(false);
});

it("skips the step-2 slide for an ACP agent with no credentials to collect", async () => {
it("renders the credentials step for Gemini CLI (no longer skipped)", async () => {
renderModal();
const user = userEvent.setup();

// Pick Gemini CLI: it authenticates via an interactive OAuth login
// (no env-var API key), so it has no credentials step and slide 2 is
// skipped — unlike Claude Code / Codex, which now render one there.
// Gemini CLI now exposes GEMINI_API_KEY (+ optional GEMINI_BASE_URL) like
// Claude Code and Codex, so slide 2 is the credentials step rather than
// being skipped. (Its Google OAuth login still takes precedence at runtime;
// the fields are optional.)
await user.click(screen.getByTestId("onboarding-agent-option-gemini-cli"));
await user.click(screen.getByTestId("onboarding-agent-next"));
await waitFor(
() =>
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
"data-current-step",
"1",
),
{ timeout: 3000 },
);

// Advancing again should jump straight to Say Hello (index 3) and
// bypass slide 2 — Gemini owns its own auth via the OAuth login.
await waitFor(
() =>
expect(
Expand All @@ -312,35 +302,36 @@ describe("OnboardingModal", () => {
);
await user.click(screen.getByTestId("onboarding-backend-next"));

// Slide 2 is active (not bypassed to Say Hello), all 4 progress segments
// remain, and the Gemini credential fields are offered.
await waitFor(
() =>
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
"data-current-step",
"3",
"2",
),
{ timeout: 3000 },
);
// All four slides remain mounted (the rail just translates them);
// the assertion that the LLM step was skipped is that slide 3 (Say
// Hello) is the active one immediately after the backend step,
// *not* slide 2 (LLM).
expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute(
"data-active",
"false",
);
expect(screen.getByTestId("onboarding-slide-3")).toHaveAttribute(
"data-active",
"true",
);

// Progress bar reflects the *visited* step count, not the slide
// index — 3 segments total (not 4), and segment 2 is current (not
// segment 3, which would imply LLM was completed). Without this
// mapping, picking an ACP agent makes the bar show segment 2 as
// "completed" despite the user never visiting it.
expect(
screen.queryByTestId("onboarding-progress-step-3"),
).not.toBeInTheDocument();
screen.getByTestId("onboarding-step-setup-acp-secrets"),
).toBeInTheDocument();
expect(
screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
).toBeInTheDocument();
expect(
screen.getByTestId("onboarding-acp-secret-GEMINI_BASE_URL"),
).toBeInTheDocument();
// All 4 progress segments remain (slide 2 isn't collapsed), and the bar's
// state machine is correct: slide 2 is the *current* segment with the
// earlier ones *completed* — so it can't mark a segment the user hasn't
// reached.
expect(
screen.getByTestId("onboarding-progress-step-3"),
).toBeInTheDocument();
expect(screen.getByTestId("onboarding-progress-step-2")).toHaveAttribute(
"data-state",
"current",
Expand Down
11 changes: 11 additions & 0 deletions __tests__/components/onboarding/setup-acp-secrets-step.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ describe("SetupAcpSecretsStep", () => {
).toHaveAttribute("type", "text");
});

it("renders GEMINI_API_KEY and GEMINI_BASE_URL for Gemini CLI", () => {
renderStep("gemini-cli");

expect(
screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"),
).toHaveAttribute("type", "password");
expect(
screen.getByTestId("onboarding-acp-secret-GEMINI_BASE_URL"),
).toHaveAttribute("type", "text");
});

it("flags a credential that already exists as a saved secret", async () => {
const { apiKey } = await renderWithSavedApiKey();

Expand Down
138 changes: 138 additions & 0 deletions docs/ACP_AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 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)](https://agentclientprotocol.com/protocol/overview)
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.

```mermaid
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
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`](../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](#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.

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 |
|---|---|---|
Comment thread
simonrosenberg marked this conversation as resolved.
| **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 — leave them
blank to rely on a subscription login. A few provider-specific notes:

- **Codex and Gemini CLI** — the SDK detects the cached login file and **prefers
it over an API key**. Gemini's free Google login is the common no-key path
locally: sign in once and it **just works**, no key required.
- **Claude Code** — the login is auto-detected too (the macOS Keychain, or
`~/.claude/.credentials.json` on Linux); `CLAUDE_CONFIG_DIR` is **not** required
for it. `CLAUDE_CONFIG_DIR` only relocates Claude Code's config directory
(default `~/.claude`, which holds settings and session history, not the token) —
e.g. for containers or multiple accounts. The one difference from the others: if
`ANTHROPIC_API_KEY` is set, Claude Code uses it **instead of** the login. A
headless setup that wants to force the login despite a key in the environment
sets `CLAUDE_CONFIG_DIR`, which signals the SDK to strip a conflicting
`ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`.
- **Gemini base URL caveat:** `GEMINI_BASE_URL` only takes effect on the API-key
path (it's passed as the ACP gateway endpoint); under the Google login it's
ignored.

## 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 API key (and, optionally, a
custom base URL for a proxy or gateway). All three providers — Claude Code,
Codex, and Gemini CLI — collect these here, and every field is optional.
4. **Say hello** — creates your first conversation and closes the modal.

> [!NOTE]
> 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.

## 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**.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
This directory contains the project documentation.

- [Architecture](./architecture.md): system boundaries, runtime modes, and quality gates.
- [Using ACP agents](./ACP_AGENTS.md): onboard and configure external agents (Claude Code, Codex, Gemini CLI).
- [Development guide](./DEVELOPMENT.md)
- [Self-hosting guide](./SELF_HOSTING.md)
25 changes: 13 additions & 12 deletions src/components/features/onboarding/onboarding-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,21 +90,22 @@ export function OnboardingModal({ onClose }: OnboardingModalProps) {

// Slide index 2 is the "provider credentials" slot. Its content depends on
// the chosen agent:
// * OpenHands → the LLM-setup form (its own LLM config).
// * Claude Code / Codex → the ACP secrets form (API key + base URL), since
// these providers authenticate via env-var keys.
// * Gemini CLI → nothing: it authenticates through an interactive
// OAuth login, so there's no key to enter and we
// skip the slide entirely.
// ``getAcpProviderSecrets`` returns the field list (empty for Gemini), which
// is what distinguishes the ACP-with-secrets case from the skip case.
// * OpenHands → the LLM-setup form (its own LLM config).
// * Claude Code/Codex/Gemini → the ACP secrets form (API key + base URL):
// all three built-in providers expose an
// env-var key. The fields are optional, so a
// user on a subscription / OAuth login just
// leaves them blank.
// ``getAcpProviderSecrets`` returns the field list, which is what
// distinguishes the ACP-with-secrets case from the skip case below.
const isOpenHands = selectedAgentId === "openhands";
const acpSecretFields = getAcpProviderSecrets(selectedAgentId);
const showAcpSecretsStep = !isOpenHands && acpSecretFields.length > 0;
// Skip slide 2 only when there's nothing to show there (an ACP provider
// with no credentials to collect). Skipping keeps the rest of the flow
// intact in both directions (back from SayHello returns to CheckBackend,
// not a dead-end blank page).
// Skip slide 2 only when there's nothing to show there (an ACP provider with
// no credentials to collect). Kept generic for any future OAuth-only
// provider; no built-in provider triggers it today (all three collect a key).
// Skipping keeps the flow intact in both directions (back from SayHello
// returns to CheckBackend, not a dead-end blank page).
const skipStep2 = !isOpenHands && !showAcpSecretsStep;
const goNext = React.useCallback(
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,27 @@ interface SetupAcpSecretsStepProps {
/** ACP provider whose credentials we're collecting (e.g. ``"claude-code"``).
* Typed as {@link OnboardingAgentId} — the same type the onboarding modal
* tracks — so a mistyped key is a compile error rather than a silently empty
* form. Providers without a credentials entry (``"openhands"``,
* ``"gemini-cli"``) simply yield no fields. */
* form. Providers without a credentials entry (``"openhands"``) simply yield
* no fields. */
providerKey: OnboardingAgentId;
onBack: () => void;
onNext: () => void;
}

/**
* Onboarding credentials step for ACP providers that authenticate via an
* env-var API key (Claude Code, Codex). The fields are derived from
* Onboarding credentials step for ACP providers that expose an env-var API key
* (Claude Code, Codex, Gemini CLI). The fields are derived from
* {@link getAcpProviderSecrets}; each one maps 1:1 to a **global secret**
* whose name equals the env var the agent-server exports into the provider
* subprocess. Saving here is therefore the same as adding the secret under
* Settings → Secrets — it shows up there afterwards.
*
* The step is intentionally skippable: a user may authenticate Claude Code via
* a subscription login, or already have the env var set on the backend, so we
* never block "Next" on a value. Empty fields are simply not written; a field
* whose secret already exists shows an "already saved" placeholder and is left
* untouched unless the user types a replacement.
* The step is intentionally skippable: a user may authenticate via a
* subscription / OAuth login (e.g. a Claude login, or Gemini's Google login),
* or already have the env var set on the backend, so we never block "Next" on a
* value. Empty fields are simply not written; a field whose secret already
* exists shows an "already saved" placeholder and is left untouched unless the
* user types a replacement.
*/
export function SetupAcpSecretsStep({
providerKey,
Expand Down Expand Up @@ -115,6 +116,9 @@ export function SetupAcpSecretsStep({
provider: providerName,
})}
</p>
<p className="text-sm text-[var(--oh-muted)]">
{t(I18nKey.ONBOARDING$ACP_SECRETS_SUBSCRIPTION_NOTE)}
</p>
</header>

<div className="flex flex-col gap-5">
Expand Down
Loading
Loading