Skip to content

Agent runners

Pitchbox's AgentRunner interface (shared/src/agents/base.ts) is the contract for any process that can execute a markdown playbook and stream events. Local runners go through a single AcpRunner (shared/src/agents/acp/runner.ts) built on top of the open Agent Client Protocol (see Zed's reference adapter at zed-industries/claude-agent-acp); adding a new ACP-compatible backend is a small data change, no new runner class. The one exception is the managed Pitchbox Cloud runner (below), which drives an ordinary AI SDK tool-calling loop instead of spawning a local CLI.

Pitchbox Cloud (default)

cloud (Pitchbox Cloud) is the first, default-selected runner and needs no local agent CLI. Instead of spawning a CLI, it runs an ordinary tool-calling loop (shared/src/agents/sdk/runner.ts, built on the Vercel AI SDK) in the same process as the rest of the app, reaching the model through the Vercel AI Gateway (AI_GATEWAY_API_KEY) - there is no separate compute service. The Pitchbox MCP server it calls also runs in-process, over an in-memory transport, so your data and credentials never leave your box. Token + USD usage is metered exactly like the local runners and written to the runs row. Full design: Cloud runner.

Supported backends

Four of the six backends speak ACP natively (a CLI flag or sub-command on the agent's own binary). Two of them, Claude Code and Codex, do NOT expose an ACP server mode on their primary CLI; for those, Pitchbox launches a thin adapter package via npx that wraps the agent's SDK and re-exposes it as an ACP server. Authentication still flows through each agent's own local state (e.g. claude login), so no extra auth step is required.

SlugDisplay nameHow Pitchbox launches itInstall / auth hint
claude-codeClaude Codenpx -y @agentclientprotocol/claude-agent-acpInstall Anthropic Claude Code CLI and run claude login, or set ANTHROPIC_API_KEY. Adapter is fetched on demand and cached after the first run.
codexCodexnpx -y @zed-industries/codex-acpInstall OpenAI Codex CLI and run codex login, or set OPENAI_API_KEY. Adapter is fetched on demand and cached after the first run.
geminiGemini CLIgemini --acpInstall @google/gemini-cli and authenticate, or set GEMINI_API_KEY / GOOGLE_API_KEY. ACP is native.
copilotGitHub Copilot CLIcopilot --acpInstall GitHub Copilot CLI and run copilot auth login. ACP is native (public preview).
opencodeopencodeopencode acpInstall opencode-ai and configure a provider. ACP is native via the acp sub-command.
qwen-codeQwen Codeqwen --acpInstall Qwen Code CLI and configure DashScope credentials. ACP is native (replaces the deprecated --experimental-acp flag).

Backend specs (binary name, ACP args, env passthrough, install notes) live as data in shared/src/agents/acp/backends.ts and are surfaced verbatim in Settings → Status → Agent runners.

How it works

AcpRunner spawns the backend binary in ACP mode, frames JSON-RPC over stdio, and listens for session/update notifications. The event normalizer (shared/src/agents/acp/event-normalizer.ts) converts those notifications into ParsedEvents consumed by the runlog UI. Permission requests from the backend are auto-allowed by default through AutoAllowPolicy (shared/src/agents/acp/permission.ts), matching the previous --dangerously-skip-permissions behaviour.

Detection

On boot, Pitchbox probes each registered runner's CLI by running <binary> --version. Results are cached for the process lifetime and surfaced in Settings → Status → Agent runners with a Re-detect button. The campaign-creation form disables runners that aren't installed, and POST /api/run refuses to dispatch a campaign whose runner is unavailable.

http
GET /api/runners
POST /api/runners        # clears the cache and re-probes

Configuration

Per-runner configuration lives in app_config.runner_configs.<slug>. Edit inline under each runner in Settings, or:

http
GET /api/settings/runner-config
PUT /api/settings/runner-config       # { slug, config }

The dispatch path loads the config via loadRunnerConfig() and passes it to createAgentRunner(slug, config). Per-backend config maps to that backend's documented flags/env vars; details live in RUNNER_CONFIG_SCHEMA (shared/src/agents/meta.ts). For claude-code, the configured model (and maxTurns) reach the backend through the ACP session's _meta.claudeCode.options (buildClaudeCodeMeta in acp/runner.ts). The cloud runner has no entry in RUNNER_CONFIG_SCHEMA: its model is resolved per function by resolveFunctionModel / resolveModelForRun (shared/src/ai/model-functions.ts), configurable from the instance admin area rather than per-runner settings.

Failure taxonomy

Whenever a run transitions to failed, Pitchbox classifies the failure into one of the structured reasons below and writes it to runs.failure_reason. The classifier is classifyFailure(events, exitCode) in shared/src/runlog/classify-failure.ts, a pure TypeScript function so the taxonomy can grow without a DB migration. The campaigns detail page surfaces the value as a chip on each failed run and lets you filter the run history by reason.

ReasonHeuristic
runner_missingExit non-zero with command not found / ENOENT
cancelledSDK run aborted by the client (normalizeStopReason('cancelled', ...))
step_limit_reachedSDK run hit stopWhen(stepCountIs(n)) before the agent finished
agent_timeoutACP backend did not respond within opts.timeoutMs, or an SDK run's own AbortSignal timeout fired
auth_expiredAny event mentioning auth, 401, 403, token expired
quota_exhaustedAny event mentioning quota or rate limit
networkECONNREFUSED, ECONNRESET, ENOTFOUND, ETIMEDOUT, getaddrinfo, fetch failed, socket hang up
provider_errorAn SDK error stream part closed the run without an auth/quota/network match
content_filteredSDK run stopped on a refusal/content-filter finish reason
playbook_errorExit non-zero with a Node-style or Python stack trace
playbook_incompleteExit zero, but the playbook never called the finish tool that commits its result (see below)
agent_crashedACP backend subprocess exited unexpectedly without a stop_reason
unknownDefault, nothing else matched

The classifier order matters: runner_missing wins over playbook_error because an ENOENT will otherwise look like a generic stack trace. Order beyond that is stable and covered by shared/tests/runlog/classify-failure.test.ts.

playbook_incomplete is the one reason that does not come from the classifier, because it needs no heuristic. Every playbook commits its result through a finish tool (project_extract_finish, skill_generate_finish, draft_regen_finish, reply_draft_finish, project_insights, run_finish for the campaign scouts) and each of those writes the run's terminal status in the same transaction as the artifact. A run whose agent exits 0 while the row is still running therefore proves the tool never ran and nothing was saved, so the dispatcher records it as failed instead of success (playbookContractError in shared/src/runlog/contract.ts). Campaign runs are judged on their drafts rather than the run status, since drafts_create writes the artifact and run_finish is only bookkeeping: a run that drafted something stays a success, one that drafted nothing and never closed the run is a failure.

Cost tracking

Each runs row captures the runner's reported token usage and USD cost, for both the local ACP runners and the cloud runner (whose own normalizer, shared/src/agents/sdk/event-normalizer.ts, prices Gateway usage against its model catalogue). Cost extraction for the ACP runners happens in the ACP event normalizer (shared/src/agents/acp/event-normalizer.ts) when a stop_reason event arrives with a usage block: it writes input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, and cost_usd. When the backend reports total_cost_usd, that value is trusted as-is; otherwise the cost is computed locally from the token columns using a price table resolved from the run's actual configured backend and model (resolvePricingForRunner in shared/src/runlog/usage.ts) - Claude Code with no model configured (or claude-sonnet-4-6) uses Sonnet's list pricing ($3 / $15 per 1M input/output, $0.30 / $3.75 per 1M cache read/creation), claude-opus-4-7 and claude-haiku-4-5 use their own tables, and any other backend or unrecognized model leaves cost_usd null rather than silently pricing it at the Sonnet rate. Aggregates surface on the Home page (24h / 7d spend) and in the per-run "Cost" column on the campaign detail page.

Adding a backend

  1. Add an entry to ACP_BACKENDS in shared/src/agents/acp/backends.ts with slug, binary, ACP flag, and install hint.
  2. Add the slug to AgentRunnerSlug and AGENT_RUNNER_META in shared/src/agents/meta.ts.
  3. Add the slug to AGENT_RUNNERS in shared/src/agents/registry.ts (one line wrapping AcpRunner).
  4. Optional: add a config schema entry in RUNNER_CONFIG_SCHEMA (model list etc).

No new files, no parser, no runner subclass.

AGPL-3.0-or-later · Pitchbox