A Python wrapper for running Claude Code headless (claude -p) on your Claude Code
subscription (OAuth). There is no ANTHROPIC_API_KEY anywhere. Calls go through the
OmniRoute gateway, which picks the route. The wrapper handles the flags,
the isolation, the error classification and the parsing that every project calling
claude -p ends up writing again. Six of my projects had their own copy of this code; this
library replaced them.
Standard library only, Python 3.12+.
uv add claude-subprocess # or: pip install claude-subprocessThe PyPI name is claude-subprocess, the import name is claude_cli. At runtime you need
omniroute and claude on PATH, with Claude Code logged in.
from claude_cli import run_claude, extract_json
res = run_claude('Reply with {"ok": true}', model="haiku", effort="low", timeout=60)
res.text # the model's answer
res.cost_usd # cost the envelope reported (may be None)
res.duration_ms
res.raw # the full envelope dict
extract_json(res.text) # {'ok': True}extract_json tolerates code fences and prose around the object:
>>> extract_json('Sure! Here it is:\n```json\n{"ok": true}\n```\nAnything else?')
{'ok': True}Tool-less run_claude / arun_claude calls are POSTed to OmniRoute's /v1/chat/completions.
The key comes from OMNIROUTE_API_KEY or ~/.omniroute/.env, the URL from OMNIROUTE_URL
(default http://localhost:20128). Without a key the HTTP route is skipped. If it fails, the
wrapper logs a warning, falls back to the CLI and stops trying HTTP for 600 s in that process.
These stay on the CLI: calls with tools/allowed_tools, a project cwd (one holding
CLAUDE.md or .claude/), slash_commands, remote overrides, output_format="stream-json",
and ping. A tool-less stream_claude also goes over HTTP and then emits no events.
backend="cli"(orCLAUDE_CLI_BACKEND=cli, which wins over the kwarg) turns HTTP off;backend="api"uses HTTP only.- HTTP results have
cost_usd=0.0, andeffortis ignored there. - No
temperatureis sent, because it would turn on OmniRoute's semantic cache. - Test suites that fake the CLI must set
CLAUDE_CLI_BACKEND=cli, or they reach the live gateway.
Decisions, migration notes and what still runs on the CLI: docs/OMNIROUTE.md.
These are the reason the library exists.
--bareis never passed. It forces API-key-only mode and breaks subscription auth.build_argshas no way to emit it, and a test checks that.- OmniRoute is mandatory. Every Claude Code subprocess is launched via
omniroute run claude, and the HTTP route talks to the same gateway. - Isolated surface: an empty temp MCP config with
--strict-mcp-config,--setting-sources user,--disable-slash-commands,--no-session-persistence. - Throwaway cwd for every call, so no project
CLAUDE.mdgets loaded. --tools ""by default. A non-emptytoolsvalue goes to both--toolsand--allowed-tools, because headless-pdenies anything that isn't allow-listed.- Both output shapes:
--output-format json(one envelope) andstream-json(the final{"type":"result"}event) are normalized into the sameClaudeResult.
All errors derive from ClaudeCliError(RuntimeError), so existing except RuntimeError
fallbacks keep working.
"Not logged in"in stdout or stderr raisesNotLoggedInError.- Usage, rate and session-limit messages raise
UsageLimitErrorbefore the exit code is checked, so a batch stops at the first one instead of burning through the rest. The match is narrow on purpose:context limit exceededis not a usage limit. - Before classification, stdout and stderr are stripped of ANSI codes and OmniRoute's own
banner and warning lines, so the real error at the tail isn't buried.
ClaudeCliError.messagecarries the cleaned text.
When the failure looks like the gateway's, the message says so and lists the OmniRoute checks
that spend no quota. A 503 there is ambiguous: a spent quota and ALL_TARGETS_SKIPPED (no
provider matched) share the code, and only the first is worth waiting out.
docs/OMNIROUTE.md has the diagnosis order and the error strings.
Streaming envelope, for long runs:
res = run_claude(prompt, model="sonnet", output_format="stream-json")Live progress: stream_claude calls you back on each event while the run is going.
from claude_cli import stream_claude
res = stream_claude(prompt, lambda ev: print(ev["type"]), model="opus", timeout=3600)Project mode is opt-in and reverses the isolation above. It runs a project's own slash command
with its CLAUDE.md and settings loaded, and lets it write files:
res = stream_claude(
"/apply data/export/interested/acme.md", on_event,
cwd="~/projects/my-repo", slash_commands=True,
setting_sources="user,project", permission_mode="acceptEdits",
tools="Read,Write,Edit,Bash,WebFetch",
)Async:
from claude_cli import arun_claude
res = await arun_claude(prompt, model="opus")Cached:
from claude_cli import FileCacheStore, cached_run, run_claude
store = FileCacheStore("~/.cache/myapp/llm.db")
text = cached_run(run_claude, store, prompt, model="haiku",
purpose="classify", version="v3")cache_key(model, prompt, schema=..., version=..., purpose=..., tools=...) is a sha256 over
the normalized prompt. Empty optional parts never change a key, so adding a dimension doesn't
invalidate old rows. cached_run doesn't store the prompt by default (store_prompt=False):
prompts often carry private data, so only the key touches disk. For hit/miss visibility use
store.get / store.put directly. DictCacheStore is the in-memory store for tests.
Batch, where one bad chunk never aborts the run:
from claude_cli import run_chunks
results, cost, failed = run_chunks(chunks, summarize, label=str, workers=4)Picking a route: the combo is chosen by model alone. haiku/sonnet/opus go through
the gateway's model-to-combo mappings, or you pass a combo name directly. tier= and
profile= only set CLAUDE_CONFIG_DIR (a separate Claude Code settings dir), not the route.
res = run_claude(prompt, model="sub-first") # explicit combodirect=True skips OmniRoute: claude -p runs on the account the local Claude Code is
logged in to, model is a plain Claude model id, and a usage limit ends the session.
res = run_claude(prompt, model="claude-sonnet-5-5", direct=True)from claude_cli import health, doctor, quota_status, simulate, dry_run_plan
health() # omniroute health
simulate("hi", combo="free-stack")
dry_run_plan() # planned command, args and env key *names* onlyNone of these raise or spend quota; each returns (ok, text).
claude_available()checks thatomnirouteandclaudeare onPATH, for callers that fall back to a non-LLM path.ping(model="haiku", timeout=90)makes one real call ("reply with the single word pong") for doctor-style startup checks. It never raises:(True, "pong via omniroute, 6.2s")on success,(False, <cleaned error, up to 200 chars>)otherwise.ClaudeCliProvider/LLMProviderare a one-method seam (ask(prompt, *, model=None) -> str) for swappable backends.
- Mock seam: tests that patched their own
subprocess.runnow patchclaude_cli.runner.subprocess.run(sync) orclaude_cli.runner.asyncio.create_subprocess_exec(async), andclaude_cli.runner.shutil.whichforclaude_available. extract_jsonis stricter than the usual first-{/ last-}slice: it takes the first balanced object. A reply with two top-level objects now yields the first one instead of a decode error.- Per-project defaults: subclass
ClaudeCliProviderand overrideDEFAULT_MODEL/DEFAULT_TIMEOUT. No__init__needed.
uv run --extra dev pytest -qThe suite fakes subprocess.run and asyncio.create_subprocess_exec; it never runs the real
claude binary.
MIT. Contributions: CONTRIBUTING.md.