CLI Adapters
Overview
Crewship runs each agent as a subprocess of a vendor coding CLI inside the crew container. The agent record’scli_adapter column picks which binary and which command line; the orchestrator’s BuildCLICommand (internal/orchestrator/exec.go) is the one place that knows the per-vendor flag conventions. Everything else in the orchestrator — prompt assembly, memory injection, journal writing, tool routing — is adapter-agnostic; the adapter layer is only responsible for translating Crewship’s uniform internal representation into whatever flags / files / stdio shape a given vendor CLI expects.
The adapter pattern exists because no two coding-CLI vendors agree on anything. Claude Code reads its system prompt from a --system-prompt flag; every other adapter has no equivalent flag, so Crewship prepends the preamble inline with [SYSTEM]/[USER] delimiters on turn 1 and lets the CLI’s own file-discovery path (AGENTS.md, CLAUDE.md, GEMINI.md, .cursor/rules/, .factory/AGENTS.md) carry the same content into turn 2+. Factory Droid additionally expresses autonomy as a tier (--auto low|medium|high) instead of a tool list. Rather than picking a winner, Crewship supports all of them — six adapters as of 2026-04 — and lets the operator choose per agent. The selection lives on the agent row, so a backend crew can run Claude Code while a data crew uses Gemini, with no orchestrator-wide config change.
Adding another vendor is intentionally small: one new case in BuildCLICommand for the command-line translation, plus a normalisation rule in agents_create.go for the default tool profile and any vendor-specific defaults. The adapter_hint field on the CLI pairing request is telemetry only — the backend never routes on it, and the frontend list of adapters lives in lib/cli-adapters.ts rather than in Go, so a new adapter is a TypeScript file plus a Go switch arm, not a schema migration.
Supported adapters
The default fallback when
cli_adapter is empty or unknown is claude --print <message> — minimal flags, no streaming, lowest-risk degradation.
Tool profiles & the built-in tool allowlist
An agent’stool_profile (FULL | CODING | MINIMAL, default CODING) curates which of the CLI’s built-in tools the agent may use. This is an allowlist, not a denylist: tools not on the list are removed from the model’s context entirely.
Why an allowlist matters: a headless Claude Code agent otherwise inherits the CLI’s entire default tool catalog — including harness-internal tools (TaskCreate/TaskUpdate/TaskList/TaskGet/TaskStop, TodoWrite, ToolSearch, Agent, Workflow, Cron*, ScheduleWakeup, RemoteTrigger, EnterPlanMode, AskUserQuestion, …) that have no Crewship backing. An agent that calls one (e.g. TaskCreate to “create a task”) writes to ephemeral in-process CLI state that persists nowhere the user can see, then can’t explain where the data went. A denylist would let any newly-added builtin leak back in on the next CLI upgrade; the allowlist is closed by default.
The per-profile built-in sets (single source of truth: builtinToolAllowlist in internal/orchestrator/tool_profiles.go):
ToolSearch is in every profile on purpose. Claude Code defers MCP tools by default (tool search enabled) and the model discovers them via the built-in ToolSearch tool — drop it and the agent can no longer see or call any MCP tool (crewship-memory, Composio/YouTube, everything). Verified: with ToolSearch allowed, MCP tools load on demand while a builtin that’s not in the allowlist (e.g. TaskCreate) stays unreachable even via ToolSearch. Keeping deferral also scales to large MCP catalogs (e.g. GitHub’s ~846 tools) without bloating context.
This governs only built-in tools. MCP tools (crewship-memory, Composio apps, …) come from the agent’s .mcp.json via --mcp-config and are unaffected by --tools, so they always resolve (discovered through ToolSearch). The real Crewship capabilities (missions, issues, pipelines, keeper, peer messaging) are sidecar HTTP endpoints advertised in the system prompt — not CLI tools — and are gated by the agent’s role capabilities.
Per-adapter enforcement (each CLI exposes a different lever):
Cursor (CURSOR_CLI)
cursor-agent -p is Cursor’s headless mode. -p (print) prevents the interactive TUI from spawning; --output-format stream-json aligns the JSONL stream with the format the chat-bridge already parses for Claude Code, so the same reader handles both.
Cursor reads its system instructions from files in the working directory: .cursor/rules/, AGENTS.md, and CLAUDE.md. SetupSystemPrompt writes those before exec, so there is no --system-prompt flag on the command line. The very first turn, however, has no file-discovery yet, so the orchestrator also prepends the preamble inline with [SYSTEM]/[USER] delimiters — turn-2+ then falls back to file-discovery so persona edits land without burning a re-prepend.
--mode=plan and --mode=ask are valid Cursor flags (added 2026-01-16) but are not exposed today; the default agent mode is what BuildCLICommand emits. If a tool profile needs read-only browsing, extend the CURSOR_CLI case to map tool_profile=CONSULTATIVE to --mode=ask.
Factory Droid (FACTORY_DROID)
droid exec is Factory’s headless single-shot mode. The --auto flag picks the autonomy tier:
The mapping lives in
internal/orchestrator/adapter_droid.go (BuildCommand): MINIMAL → low, FULL → high, and everything else (CODING or an unset profile) → medium.
The default is medium because the API normalises an empty ToolProfile to CODING (see internal/api/agents_create.go), and CODING agents are expected to write code. The previous “default = low” choice was theoretical; in production almost every agent would be told to write code anyway. The current inversion (default-medium, explicit-low) is honest about production behaviour.
OpenCode (OPENCODE)
opencode run --format json is OpenCode’s non-interactive mode. Output is JSONL — one flat event envelope per line. The adapter maps them as follows:
text/reasoningevents carry the part’s accumulated text so far (not deltas); the parser emits only the new suffix per part, so chat output never double-appends.tool_useevents surface tool lifecycle (pending/running→ tool call,completed/error→ tool result).step_finishcarries the run’s cost (USD) and token counts (input,output,reasoning,cache); Paymaster reads both, and the resolvedprovider/modelis surfaced onto the run record.- If the process exits before its final
step_finish(a known upstream bug,anomalyco/opencode#26855), the orchestrator synthesizes a terminal result so the run finalizes cleanly — cost records zero for that run.
provider/model form (e.g. anthropic/claude-sonnet-5) passed via --model. OpenCode is BYOK: the selected provider’s API key must be available to the agent (see the credentials guide). Note that Anthropic subscription OAuth (Claude Pro/Max) is not usable through OpenCode — Anthropic’s terms restrict subscription tokens to Claude Code, so Anthropic models via OpenCode require an API key.
In restricted network mode the sidecar’s default allowlist covers every provider the model picker offers (Anthropic, OpenAI, Google, OpenRouter, xAI, Groq, DeepSeek, Moonshot, Z.ai, MiniMax) — no manual allowed_domains entry needed for them.
Local models — no API key (Ollama)
OpenCode can route to a local, OpenAI-compatible model server instead of a cloud provider. This is the zero-subscription path: no API key, no per-token cost, data never leaves your network.-
Run a model server the containers can reach. With Ollama on the same host as
crewship:ollama pull qwen3-coder:30b && ollama serve. -
Store the endpoint as a credential — configured the same way as any API key, so it scopes per workspace (default) and per agent (override):
Leave it as a workspace credential to make it the default for every agent, or assign it to one agent to override:
crewship credential assign ollama-local <agent>. Because it’s anENDPOINT_URL(a destination, not a secret), the URL is shown bycrewship credential list/get.The URL is resolved from inside crew containers —The URL shape no longer matters.http://host:11434,.../v1,.../v1/chat/completionsand.../api/chatare all reduced to the same mount root, and each consumer appends the path its protocol needs. Previously the three consumers disagreed about which shape was correct, so a value that worked for an agent could 404 for the Keeper judge. A reverse-proxy mount prefix (https://gw.example.com/ollama) is preserved.Azure is addressed on two levels, and both are handled. Store the deployment URL you were given (https://acme.openai.azure.com/openai/deployments/gpt4o/chat/completions?api-version=…) — completions go to that deployment, while model discovery goes to the resource’s own list at.../openai/models, because Azure serves no model list under a deployment. The?api-version=query rides along on both.The host is not.host.docker.internalis right for an agent, which dials from inside its crew container. Anything dialling from the daemon — the Keeper judge,crewship doctor, model discovery — needslocalhostor the host’s LAN address instead. If one credential has to serve both, put the endpoint on an address that resolves from both sides.host.docker.internalreaches the Docker host (Crewship maps it viahost-gatewayon Linux too). Don’t uselocalhost; that’s the container itself. -
Pick a local model on an OpenCode agent:
ollama/qwen2.5-coder:7b(macOS-friendly),ollama/qwen3-coder:30b, orollama/devstral:24b. The onboarding wizard and agent form make the API key optional for these. At run time the orchestrator resolves the endpoint (per-agentENDPOINT_URLcredential → workspace default) and injects the generated provider block viaOPENCODE_CONFIG_CONTENT; in restricted network mode the endpoint’s host is auto-allowlisted for that run only.
crewship credential test-stored ollama-local actually probes the endpoint (/v1/models, falling back to Ollama’s /api/tags) and reports how many models it advertises — a real reachability check, not a stub. crewship doctor also probes the workspace’s ENDPOINT_URL credential and warns if it’s unreachable, so a stopped server surfaces up front instead of as an opaque mid-run failure. Set-time model validation (crewship agent update <slug> --llm-model ollama/…) lists against that same endpoint, so a typo’d model name is caught where the endpoint is reachable. Local runs record their token counts (at $0 cost) to the ledger, so crewship paymaster by-agent shows local usage too.
The server-global
CREWSHIP_LOCAL_MODEL_BASE_URL env var still works as a deprecated fallback — used only when no ENDPOINT_URL credential is configured. Prefer the credential: it’s per-workspace/per-agent and doesn’t require a server restart to change.Try it on macOS with a seeded demo
On a Mac (Docker Desktop + a local Ollama), one env var seeds a ready-to-run local-model crew — thelocal-ai crew with an ollie OpenCode agent on
ollama/qwen2.5-coder:7b and a workspace OLLAMA_ENDPOINT credential pointing
at host.docker.internal:11434:
scripts/test-harness/test-ollama-local.sh scenario drives this end-to-end
and self-skips when it isn’t macOS or Ollama isn’t reachable.
Authenticated endpoints (production)
A raw Ollama has no auth, so never expose it directly — front it with a reverse proxy (Caddy/nginx) or LiteLLM that terminates TLS and requires a bearer token. Attach that token to the sameENDPOINT_URL credential — endpoint + key stay one object:
crewship credential list/get shows only the base URL. They travel to OpenCode inside the generated OPENCODE_CONFIG_CONTENT (options.apiKey / options.headers), never as an agent environment variable. Existing bare-URL credentials keep working unchanged (no auth).
Private endpoints & the SSRF fence
A workspace-configured endpoint URL is a destination the sandbox will dial, so it’s guarded against pointing an agent at internal infrastructure (an SSRF vector, acute in multi-tenant/cloud). By default the fence blocks any endpoint whose host resolves into a private range — RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), IPv6 ULA (fc00::/7) — checked at credential-create time (literal IPs) and at dial time (the sidecar re-resolves the host and connects to the exact validated IP, defeating DNS-rebinding).
A legitimate on-prem / LAN Ollama (or host.docker.internal, whose Docker gateway is a private IP) lives in those ranges, so enable it per crew:
169.254.169.254, fe80::/10, and their IPv4-mapped forms) stay hard-blocked even with the opt-in — there is no legitimate model endpoint there. A crew in free network mode is unaffected (it already opted out of egress limits).
Which models actually work: we ship a curated shortlist — Qwen3 Coder 30B (A3B; runs on a single 24 GB GPU at 4-bit) and Devstral Small 24B (built for agent workflows). This is deliberate: below roughly this tier, community reports and upstream issues (anomalyco/opencode#1034, #4428) show local models “think about” tool calls but emit them as text instead of executing — the agent looks alive but nothing happens. Generic chat models under ~14B will frustrate you; frontier cloud models remain meaningfully stronger for hard tasks. Expect local models to handle routine, well-scoped work.
In-band failures: when exit 0 still means the run failed
Every supported CLI can end a turn with a refusal, an internal error, or an exhausted quota and still exit 0, saying so only inside its own event stream. Crewship treats that as a failed run: the run is recorded aserror (journal run.failed), RunAgent returns an error, and the chat shows the CLI’s own message instead of an empty assistant bubble.
Only two signals count as run-level:
A Gemini
error event with severity: "warning" is a soft block / advisory, not a failure; the parser demotes it to a system event and the run stays successful.
The reason shown to you is the CLI’s cause, never its answer. Gemini in particular reports both on the same event — error is why the turn failed, response is whatever the model had produced — and only the former is quoted. Same for a turn cap, where result holds unfinished work rather than an explanation.
What does not fail a run
A failed tool call is normal agent work and keeps the runcompleted. A grep that matched nothing, a build that failed and then got fixed, a 404 from a fetch — the agent sees the failure and works around it, and reddening the run for those would mark nearly every real run as failed. So none of these are run-level:
is_erroron a Claude Codetool_resultcontent blockisErroron a Factory Droidtool_resulteventstatus: "error"on a Geminitool_resultor on an OpenCodetool_usepart state- a non-zero
exit_codeon a Codexcommand_executionitem
is_error in its journal metadata and the run trace marks that sub-span errored.
Ordering
The check is sticky, not last-one-wins: once a CLI has reported a failed turn, a later successful envelope does not clear it. Adapters emit several terminal envelopes per run (Droid emits bothcompletion and result, OpenCode one step_finish per step, Codex one turn.completed per turn) and their ordering is not guaranteed across CLI versions.
What a failed run does downstream
- Chat — the turn is still persisted. The agent’s own text (a refusal is the agent speaking) is kept as the assistant turn, with an error part carrying the reason; when the agent produced nothing at all, the turn is a system notice whose body is the reason. A reload shows the failure either way — the same treatment a run that produced no output at all gets.
- Delegation / missions — the assignment is
FAILEDand the mission task fails, instead of the mission advancing on an empty answer. The sub-agent’s output is still written to the assignment’sresult_summary, so the delegating agent and the mission timeline can read what it managed to say before failing. - Routines / pipelines — the step fails and, under the default
on_fail: escalate_tier, walks to the next fallback tier. It is deliberately not retried on the same tier: an in-band failure is the agent’s verdict on its own work, not a transport fault, so a retry repeats a deterministic failure and bills for it. The classifier keys off the error’s identity, not its prose — a refusal reading “I cannot process a list of 500 items” does not become “transient” because it contains500. See Retry transient failures. - Memory — the post-run consolidator does not fire. We don’t learn from a run the agent called a failure.
error status never costs you text you could otherwise read. Other callers of the run API surface the error only.
Turn cap (error_max_turns)
Crewship always passes a turn cap to Claude Code (--max-turns): 50 for interactive runs, and a tighter 20 for unattended routine runs. An agent that reaches it stops mid-task and reports error_max_turns with exit 0, so it is the in-band failure you are most likely to meet. It gets its own message naming the cap and the turn count — “agent stopped at its turn cap (20 turns) before finishing the task — raise max_turns for this run, or split the work into smaller steps” — because the generic “check the journal” copy would leave you paying for a limit you didn’t know existed.
A turn-cap failure currently walks the fallback-tier chain like any other step failure, which means a task that flails for 20 turns on a cheap model can replay all 20 on a more expensive one. Whether the turn cap should escalate — a smarter model may need fewer turns, but the cap itself does not change — is under review; if you hit this, set an explicit
max_turns or on_fail: abort on the step.Where to look when a run fails this way
- The chat / API error message quotes the CLI’s own text (truncated at 300 characters).
- The journal
exec.commandend entry is raised to severitywarnand carriesin_band_error: truealongsideexit_code: 0— that pair is the fingerprint of an in-band failure. - The full terminal event, with usage and cost, is in the
resultevent’s journal metadata.
Adapters that are deliberately NOT supported
BuildCLICommand only ships adapters for CLI agents that meet four criteria. CLI agents that don’t meet all four are skipped today. Adding a new adapter is a one-line change once the upstream surface stabilises; open a GitHub issue to discuss before opening a PR.
The four inclusion criteria
The four inclusion criteria
- Stable headless /
--execsurface — single-shot invocation with deterministic stdout, no interactive REPL requirement - Settled flag set — no recent breaking changes to the command-line API
- Self-contained binary — no IDE extension or browser harness required at runtime
- Documented function-calling or MCP support — necessary for the memory + skill primitives to wire in
How the adapter signals the sidecar
The orchestrator stamps two environment variables on the container env (visible to every process running inside that crew container — both the agent CLI and the sidecar process). The sidecar reads them once at startup and tags everyPOST /api/v1/internal/cost/record it sends to the server:
CREWSHIP_BILLING_MODE—meteredfor API-key credentials,flat_ratefor subscription credentials (Claude Max, ChatGPT Plus, Cursor, Copilot).CREWSHIP_SUBSCRIPTION_PLAN— display label, currently"Anthropic Max"for the OAuth Claude Code path. Only set whenCREWSHIP_BILLING_MODE=flat_rate.
Picking an adapter for an agent
Setcli_adapter on the agent (UI: agent canvas → “Runtime” panel; CLI: crewship agent update <slug> --cli-adapter CURSOR_CLI; API: PUT /api/v1/agents/{id} with cli_adapter in the body). The string must match one of the constants above exactly.
Pick by what the credential supports:
- Anthropic API key or Claude Max OAuth →
CLAUDE_CODE. - OpenAI API key or ChatGPT Plus →
CODEX_CLI. - Google AI key or Pro/Ultra →
GEMINI_CLI. - Cursor subscription →
CURSOR_CLI. - Factory account →
FACTORY_DROID. - Anything else (BYOK) →
OPENCODE.
crewship runtimes info <runtime>).
Related
- Paymaster — billing modes, quota enforcement, sidecar usage parsing.
- Environment Variables —
CREWSHIP_BILLING_MODE,CREWSHIP_SUBSCRIPTION_PLAN. - Architecture — CLI Adapters — output processing and stream-JSON shape.
- Orchestration — agent lifecycle around the CLI exec.