Skip to content

Backend Matrix

A backend is an LLM driver that produces autonomous Conversations. Orcats ships four backend tags — claude, codex, opencode, pi — behind a shared LlmBackend<B> contract. Signatures are transcribed from src/backends/ and verified by bun run docs:symbols.

type BackendTag = "claude" | "codex" | "opencode" | "pi";
BackendTagConstructorReturnsRuntime
Claudeclaudeclaude(options?)LlmBackend<"claude">claude stream-json subprocess by default; ACP is explicit opt-in.
Codexcodexcodex(options?)LlmBackend<"codex">Subprocess JSONL over codex exec --json.
OpenCodeopencodeopencode(options?)OpenCodeBackendManaged opencode serve over HTTP/SSE.
Pipipi(options?)LlmBackend<"pi">Subprocess RPC JSONL over the pi CLI.
export function claude(options?: ClaudeBackendOptions): LlmBackend<"claude">;
export function codex(options?: CodexBackendOptions): LlmBackend<"codex">;
export function opencode(options?: OpenCodeBackendOptions): OpenCodeBackend;
export function pi(options?: PiBackendOptions): LlmBackend<"pi">;

All constructors default to {} and return immediately — they do not start a process or perform IO. The backend process is spawned lazily on the first autonomous() call.

codex({ ignoreUserConfig: true }) passes --ignore-user-config to codex exec, keeping Codex auth while skipping user config such as MCP servers. Use it for hermetic automation; leave it unset when a flow should honor the operator’s normal Codex setup.

For subprocess JSONL turns, BackendConfig<"codex">.reasoningEffort accepts low, medium, high, xhigh, max, or ultra. The value is scoped to one request and passed to codex exec as model_reasoning_effort. The experimental Codex ACP path does not expose this setting.

Orcats forwards all six declared values to Codex without a local model catalog. Actual acceptance depends on the selected model and Codex CLI version. Unsupported combinations return a backend failure.

claude() uses the authenticated claude CLI through stream-json by default. Select ACP explicitly with ORCA_CLAUDE_TRANSPORT=acp or claude({ transport: "acp" }); ORCA_CLAUDE_ACP_COMMAND overrides its adapter executable. Constructor selection wins over the environment. Orcats does not automatically retry another transport after failure because the prompt may already have caused side effects. ACP remains opt-in pending installed-version compatibility proof. See Environment Variables.

Stream-json supports model selection and session resume. Explicit ACP with either setting returns a transport-aware BackendFailed before spawning a process; select stream-json for those runs.

type CodexReasoningEffort =
| "low"
| "medium"
| "high"
| "xhigh"
| "max"
| "ultra";
interface BackendConfig<B extends BackendTag = BackendTag, Output = unknown> {
readonly model?: string;
readonly reasoningEffort?: B extends "codex" ? CodexReasoningEffort : never;
readonly systemPrompt?: string;
readonly approvalPolicy?: BackendApprovalPolicy;
readonly readOnly?: boolean;
readonly sandbox?: BackendSandboxMode;
readonly selfManagedGit?: boolean;
readonly retry?: BackendRetryConfig;
readonly structuredOutput?: StructuredOutputConfig<Output>;
readonly resumeSessionId?: SessionId<B>;
readonly interactive?: boolean;
}
interface AutonomousRequest<Output = unknown, B extends BackendTag = BackendTag> {
readonly prompt: string;
readonly schema?: z.ZodType<Output>;
readonly config?: BackendConfig<B, Output>;
}
interface LlmBackend<B extends BackendTag = BackendTag> {
readonly tag: B;
autonomous<Output = unknown>(request: AutonomousRequest<Output, B>): Conversation<B>;
}

autonomous(request) starts a run and returns a Conversation<B> synchronously — it does not await the result. Consume events with events() and the terminal state with awaitResult() (which never rejects; see Errors and Results). When schema is provided, the run validates structured output against it; a validation failure surfaces as a StructuredOutputValidationFailed RuntimeError (see Runtime Errors).

opencode() is the only constructor that returns a subtype:

interface OpenCodeBackend extends LlmBackend<"opencode"> {
shutdown(signal?: NodeJS.Signals): Promise<void>;
}

OpenCode owns a managed opencode serve process. Call shutdown() when done (typically in a finally). When selected through selectBackend(), the shutdown is exposed on SelectedBackend.shutdown.

interface SelectBackendOptions {
readonly default: BackendTag;
readonly config?: PortableBackendConfig;
readonly perBackend?: Partial<Record<BackendTag, PortableBackendConfig>>;
readonly env?: NodeJS.ProcessEnv;
}
interface SelectedBackend {
readonly tag: BackendTag;
readonly backend: LlmBackend;
readonly model?: string;
readonly shutdown?: () => Promise<void>;
}
export function selectBackend(options: SelectBackendOptions): SelectedBackend;

selectBackend resolves the backend synchronously and throws on an invalid ORCA_BACKEND. The chosen tag is process.env.ORCA_BACKEND when set, otherwise options.default. An unrecognized value throws Unsupported backend "<value>" (expected one of: claude, codex, opencode, pi) — wrap the call if you prefer a Result-style boundary.

The optional env supplies selector values and Claude transport overrides. Claude child processes inherit process.env with those overrides applied.

import { selectBackend } from "@twelvehart/orcats";
const selected = selectBackend({ default: "claude" }); // reads ORCA_BACKEND
try {
const convo = selected.backend.autonomous({ prompt: "ship it" });
const outcome = await convo.awaitResult();
} finally {
await selected.shutdown?.(); // present for opencode
}

Each backend takes optional inactivityTimeoutMs and wallClockTimeoutMs; OpenCode additionally takes startupTimeoutMs. When omitted, shared defaults apply (defined in src/backends/subprocess-run.ts and src/backends/opencode-run.ts):

TimeoutDefaultApplies toEffect when exceeded
Inactivity120_000 ms (120s)claude, codex, opencode, piNo event for the window → run aborted.
Wall-clock600_000 ms (600s)claude, codex, opencode, piAbsolute per-turn cap, even if events keep flowing.
Startup30_000 ms (30s)opencode onlyopencode serve did not print a listening URL → BackendFailed.

A backend timeout stops the transport and resolves awaitResult() to { type: "failed", error }; it does not abort the conversation’s signal. The signal aborts only when cancel() is requested.

Autonomous conversations are intended to complete without asking the human for input. Configure credentials, approvals, and login state before running a live flow. See Backend Setup and Backend Auth troubleshooting.

Gemini is not a supported backend in this release. Future Google support should use a new agy backend tag rather than reviving the Gemini path.