Files
paseo/docs/PROVIDERS.md
Mohamed Boudra a854096c35 feat: ACP base provider, Copilot integration, eliminate hardcoded provider unions (#170)
* feat(server): add ACP base provider with Claude ACP integration

Implement a generic Agent Client Protocol (ACP) base provider that any
ACP-compatible agent can extend with minimal code. Includes a concrete
Claude ACP implementation via @agentclientprotocol/claude-agent-acp
with full parity to the existing Claude Code provider.

The base handles subprocess lifecycle, streaming translation, permission
bridging, terminal/fs callbacks, listModels, loadSession/resume, and
mode/model management. The concrete class only specifies the command,
modes, and availability check.

* fix: update lockfile signatures and Nix hash

* feat: add Copilot ACP provider, eliminate hardcoded provider unions, fix ACP streaming bugs

Add Copilot as an ACP provider (copilot --acp), with real modes and models
discovered from the ACP server. Fix two ACP base bugs: duplicate assistant
text (emit deltas not cumulative) and idle→running→stuck (fire-and-return
startTurn). Replace all hardcoded provider string unions with string/manifest-
derived values so adding a provider only requires: impl class, manifest entry,
registry factory, icon, and E2E config. Add provider docs and Copilot icon.

* fix: update lockfile signatures and Nix hash

* feat(app): add OpenCode provider icon

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-02 23:20:01 +08:00

12 KiB

Adding a New Provider to Paseo

This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.

Two Integration Patterns

Extend ACPAgentClient. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override isAvailable() for auth checks.

Existing ACP providers: claude-acp, copilot.

Direct

Implement the AgentClient and AgentSession interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.

Existing direct providers: claude, codex, opencode.


ACP Provider Checklist

1. Create the provider class

Create packages/server/src/server/agent/providers/{name}-agent.ts.

Define capabilities, modes, and a thin subclass of ACPAgentClient:

import type { Logger } from "pino";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { ACPAgentClient } from "./acp-agent.js";

const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
  supportsStreaming: true,
  supportsSessionPersistence: true,
  supportsDynamicModes: true,
  supportsMcpServers: true,
  supportsReasoningStream: true,
  supportsToolInvocations: true,
};

const MY_PROVIDER_MODES: AgentMode[] = [
  {
    id: "default",
    label: "Default",
    description: "Standard agent mode",
  },
  // Add more modes as needed
];

type MyProviderClientOptions = {
  logger: Logger;
  runtimeSettings?: ProviderRuntimeSettings;
};

export class MyProviderACPAgentClient extends ACPAgentClient {
  constructor(options: MyProviderClientOptions) {
    super({
      provider: "my-provider",        // Must match the ID used everywhere else
      logger: options.logger,
      runtimeSettings: options.runtimeSettings,
      defaultCommand: ["my-agent-binary", "--acp"],  // CLI command to spawn
      defaultModes: MY_PROVIDER_MODES,
      capabilities: MY_PROVIDER_CAPABILITIES,
    });
  }

  // Override isAvailable() if the provider needs specific auth/env vars
  override async isAvailable(): Promise<boolean> {
    if (!(await super.isAvailable())) {
      return false;  // Binary not found
    }
    return Boolean(process.env["MY_PROVIDER_API_KEY"]);
  }
}

The super.isAvailable() call checks that the binary from defaultCommand is on $PATH. Override only to add credential checks on top.

For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:

export class CopilotACPAgentClient extends ACPAgentClient {
  constructor(options: CopilotACPAgentClientOptions) {
    super({
      provider: "copilot",
      logger: options.logger,
      runtimeSettings: options.runtimeSettings,
      defaultCommand: ["copilot", "--acp"],
      defaultModes: COPILOT_MODES,
      capabilities: COPILOT_CAPABILITIES,
    });
  }

  override async isAvailable(): Promise<boolean> {
    return super.isAvailable();
  }
}

2. Add to the provider manifest

In packages/server/src/server/agent/provider-manifest.ts, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.

First, define the modes with visual metadata:

const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
  {
    id: "default",
    label: "Default",
    description: "Standard agent mode",
    icon: "ShieldCheck",
    colorTier: "safe",
  },
  {
    id: "autonomous",
    label: "Autonomous",
    description: "Runs without prompting",
    icon: "ShieldOff",
    colorTier: "dangerous",
  },
];

Available colorTier values: "safe", "moderate", "dangerous", "planning". Available icon values: "ShieldCheck", "ShieldAlert", "ShieldOff".

Then add to the AGENT_PROVIDER_DEFINITIONS array:

export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
  // ... existing providers ...
  {
    id: "my-provider",
    label: "My Provider",
    description: "Short description of the provider",
    defaultModeId: "default",
    modes: MY_PROVIDER_MODES,
    // Optional: enable voice
    voice: {
      enabled: true,
      defaultModeId: "default",
      defaultModel: "some-model",
    },
  },
];

3. Add the factory to the provider registry

In packages/server/src/server/agent/provider-registry.ts, import your class and add a factory entry:

import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";

const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
  // ... existing factories ...
  "my-provider": (logger, runtimeSettings) =>
    new MyProviderACPAgentClient({
      logger,
      runtimeSettings: runtimeSettings?.["my-provider"],
    }),
};

4. Add a provider icon (app)

Create packages/app/src/components/icons/my-provider-icon.tsx following the pattern from existing icons (e.g., claude-icon.tsx):

import Svg, { Path } from "react-native-svg";

interface MyProviderIconProps {
  size?: number;
  color?: string;
}

export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
  return (
    <Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
      <Path d="..." />
    </Svg>
  );
}

Then register it in packages/app/src/components/provider-icons.ts:

import { MyProviderIcon } from "@/components/icons/my-provider-icon";

const PROVIDER_ICONS: Record<string, typeof Bot> = {
  claude: ClaudeIcon as unknown as typeof Bot,
  codex: CodexIcon as unknown as typeof Bot,
  "my-provider": MyProviderIcon as unknown as typeof Bot,
};

If no icon is registered, the app falls back to a generic Bot icon from lucide.

5. Add E2E test config

In packages/server/src/server/daemon-e2e/agent-configs.ts, add your provider:

export const agentConfigs = {
  // ... existing configs ...
  "my-provider": {
    provider: "my-provider",
    model: "default-model-id",
    modes: {
      full: "autonomous",   // Mode with no permission prompts
      ask: "default",       // Mode that requires permission approval
    },
  },
} as const satisfies Record<string, AgentTestConfig>;

Add an availability check in isProviderAvailable():

case "my-provider":
  return (
    isCommandAvailable("my-agent-binary") &&
    Boolean(process.env.MY_PROVIDER_API_KEY)
  );

Add to the allProviders array:

export const allProviders: AgentProvider[] = [
  "claude",
  "claude-acp",
  "codex",
  "copilot",
  "opencode",
  "my-provider",
];

6. Run typecheck

npm run typecheck

This is required after every change per project rules.


Direct Provider Checklist

If your agent does not speak ACP, implement the interfaces from agent-sdk-types.ts directly.

Interfaces to implement

AgentClient -- factory for sessions and model listing:

interface AgentClient {
  readonly provider: AgentProvider;
  readonly capabilities: AgentCapabilityFlags;
  createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
  resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
  listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
  isAvailable(): Promise<boolean>;
  // Optional:
  listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}

AgentSession -- a running agent conversation:

interface AgentSession {
  readonly provider: AgentProvider;
  readonly id: string | null;
  readonly capabilities: AgentCapabilityFlags;
  run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
  startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
  subscribe(callback: (event: AgentStreamEvent) => void): () => void;
  streamHistory(): AsyncGenerator<AgentStreamEvent>;
  getRuntimeInfo(): Promise<AgentRuntimeInfo>;
  getAvailableModes(): Promise<AgentMode[]>;
  getCurrentMode(): Promise<string | null>;
  setMode(modeId: string): Promise<void>;
  getPendingPermissions(): AgentPermissionRequest[];
  respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
  describePersistence(): AgentPersistenceHandle | null;
  interrupt(): Promise<void>;
  close(): Promise<void>;
  // Optional:
  listCommands?(): Promise<AgentSlashCommand[]>;
  setModel?(modelId: string | null): Promise<void>;
  setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}

Steps

  1. Create packages/server/src/server/agent/providers/{name}-agent.ts implementing both interfaces
  2. Add to the provider manifest (same as ACP step 2 above)
  3. Add factory to the registry (same as ACP step 3 above)
  4. Add icon (same as ACP step 4 above)
  5. Add E2E config (same as ACP step 5 above)
  6. Run typecheck

Testing

Manual testing with the CLI

Start the daemon if not already running, then:

# Launch an agent with your provider
paseo run --provider my-provider

# Launch with a specific model and mode
paseo run --provider my-provider --model some-model --mode default

# List running agents
paseo ls -a -g

# Check if the provider reports models
paseo models --provider my-provider

E2E test patterns

The E2E configs in agent-configs.ts expose two helpers:

  • getFullAccessConfig(provider) -- returns config for a session with no permission prompts
  • getAskModeConfig(provider) -- returns config for a session that triggers permission requests

Tests use isProviderAvailable(provider) to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.


Gotchas

Mode IDs can be URIs. ACP providers like Copilot use full URIs as mode IDs (e.g., "https://agentclientprotocol.com/protocol/session-modes#agent"). Never assume mode IDs are simple strings. The manifest defaultModeId must match exactly.

Models and modes are discovered dynamically. ACP providers report available models and modes at runtime via the protocol. The static definitions in provider-manifest.ts are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.

AgentProvider is always string. The type alias is type AgentProvider = string. Provider IDs are validated against the manifest at runtime, not at the type level.

Auth patterns vary. Some providers need API keys in env vars (ANTHROPIC_API_KEY, OPENAI_API_KEY), some use OAuth tokens (CLAUDE_CODE_OAUTH_TOKEN), some use auth files (~/.codex/auth.json), and some handle auth entirely in their CLI binary (Copilot). Your isAvailable() method should check whatever is needed.

The manifest mode list and the agent class mode list are separate. The manifest in provider-manifest.ts includes UI metadata (icon, colorTier). The agent class defines modes without UI metadata (just id, label, description). Keep them in sync.

defaultCommand is a tuple. The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.

Runtime settings can override the command. Users can configure custom binary paths or environment variables per provider via ProviderRuntimeSettings. Your factory in the registry should pass runtimeSettings?.["your-provider"] through to the constructor.