An OpenAI-compatible HTTP endpoint backed by the claude CLI.
Point any OpenAI client at it and talk to Claude through the CLI you already have authenticated — no API key required, no config file, one static binary.
It speaks /v1/chat/completions and /v1/models, and delegates every turn to
claude --print --input-format=stream-json --output-format=stream-json --system-prompt-file .... There is no config file: everything is a flag, and
every flag has an environment-variable twin.
Verified against claude CLI 2.1.251 and Go 1.24.
go install github.com/NaomiAmethyst/claude-proxy@latestOr build from a checkout:
git clone https://github.com/NaomiAmethyst/claude-proxy
cd claude-proxy && go build -o claude-proxy .No dependencies beyond the standard library.
Download binaries from a successful run of CI on GitHub Actions,
under Artifacts. CI runs on pushes, pull requests, and manual dispatches;
the Linux test suite and go vet must pass before binaries are published.
Artifacts are named claude-proxy-<os>-<arch>, with linux, windows, or
darwin (macOS) for the OS and amd64 (x86-64) or arm64 (including Apple
Silicon) for the architecture. Each download contains a .tar.gz archive
(.zip for Windows) and SHA256SUMS. Extract the inner archive to get the
binary, README, and license. The claude CLI must still be installed and
authenticated separately.
All six targets are cross-compiled with CGO disabled; CI runs the tests on Linux, so Windows and macOS runtime behavior is not verified by this workflow. Windows builds are experimental: credential symlinks may require Developer Mode or elevated privileges, and the email-scrubbing directory protection relies on Unix permission semantics. Do not rely on email scrubbing on Windows.
go test ./...The suite is offline and free: it never contacts the API and never runs the real
CLI. Instead the proxy is pointed at the test binary, which re-execs itself as a
canned claude — recording the arguments, the system-prompt file and the stdin
turn it was given, then replaying a scripted stream-json session. That covers the
whole HTTP path, including SSE framing, against fixed input.
What it pins down, beyond the ordinary request/response shapes:
- the system prompt reaches the CLI byte for byte, and
--system-prompt-fileis passed even when the request has none --restricted,--safe-modeand the rest of the isolation set are present by default and absent under-isolate=false- a leading
/is escaped, and#,!and mid-text slashes are not - thinking never leaks into
content, and subagent output never reaches the reply - a reply the deltas only partly covered is still delivered whole
- images survive in replayed history, in both
-historymodes - the synthesized transcript chains by
parentUuid, carries no thinking blocks, and lands where the CLI looks for it - the scrubbed config directory drops
emailAddress, seals both the file and the directory, and links credentials rather than copying them
# uses your logged-in claude session
./claude-proxy -port 8080 -key my-secret
# optional: --bare as well, which needs an API key but removes the
# injected user-email block (see "What still gets injected")
ANTHROPIC_API_KEY=sk-ant-... ./claude-proxy -port 8080 -barecurl localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"opus","messages":[{"role":"user","content":"hello"}]}'Any OpenAI client works by pointing base_url at it:
from openai import OpenAI
c = OpenAI(base_url="http://localhost:8080/v1", api_key="my-secret")
c.chat.completions.create(model="opus", messages=[{"role":"user","content":"hi"}])| Flag | Env | Default | Meaning |
|---|---|---|---|
-host |
CLAUDE_PROXY_HOST |
127.0.0.1 |
listen address |
-port |
CLAUDE_PROXY_PORT |
8080 |
listen port |
-key |
CLAUDE_PROXY_KEY |
(empty) | required bearer token; empty disables auth |
-claude |
CLAUDE_PROXY_CLAUDE |
claude |
path to the CLI |
-model |
CLAUDE_PROXY_MODEL |
(empty) | model used when a request omits one |
-tools |
CLAUDE_PROXY_TOOLS |
(empty) | CLI tools to expose; "" none, default all |
-permission-mode |
CLAUDE_PROXY_PERMISSION_MODE |
(empty) | only meaningful with -tools |
-isolate |
CLAUDE_PROXY_ISOLATE |
true |
disable CLAUDE.md, skills, plugins, hooks, MCP, settings |
-history |
CLAUDE_PROXY_HISTORY |
turns |
how prior turns reach the model: turns or flat |
-scrub-email |
CLAUDE_PROXY_SCRUB_EMAIL |
true |
hide the account email the CLI injects |
-bare |
CLAUDE_PROXY_BARE |
false |
also pass --bare; needs ANTHROPIC_API_KEY |
-cwd |
CLAUDE_PROXY_CWD |
(temp dir) | working directory for the CLI |
-timeout |
CLAUDE_PROXY_TIMEOUT |
10m |
per-request timeout |
-reasoning |
CLAUDE_PROXY_REASONING |
false |
expose thinking as reasoning_content |
-verbose |
CLAUDE_PROXY_VERBOSE |
false |
log each request |
The flag wins when both a flag and its environment variable are set.
POST /v1/chat/completions— streaming (SSE) and non-streamingGET /v1/models,GET /v1/models/{id}GET /health— never requires auth
Whatever the request puts in system (or developer) messages is written to a
temp file and handed to --system-prompt-file, which replaces the Claude Code
system prompt rather than appending to it. Multiple system messages are joined
with a blank line, in order. Nothing else is added to them.
The flag is passed even when the request has no system message, with an empty file. Omitting it would fall back to Claude Code's own agent prompt, which is neither empty nor neutral:
| request | input tokens |
|---|---|
| no system message, flag omitted (Claude Code's agent prompt) | 3519 |
no system message, empty --system-prompt-file |
230 |
So a request that sends no system prompt gets a blank one, as an OpenAI client would expect — not a coding agent's persona and tool instructions.
Measured overhead: a one-character system prompt costs ~230 input tokens total, and adding ~2200 tokens of system prompt adds ~2200 tokens — i.e. your prompt is forwarded verbatim, with a small fixed wrapper the CLI always sends.
The client sends the whole message list every time, as OpenAI clients do, and
the proxy is stateless: it holds nothing between requests. The interesting part
is how those prior turns are handed to the CLI, which has no supported way to
accept a conversation. Its stream-json stdin parser takes user turns, control
requests and a few notification types and silently drops the rest ("Ignoring
unknown message type"), so an {"type":"assistant"} line does not seed history
— it starts a real, billed generation.
Two modes, selected with -history:
turns (default) — the proxy writes the session transcript the CLI would
have written, and resumes it. The conversation reaches the model as genuine
alternating user/assistant turns with real roles, and images in history are
carried as real image blocks. The transcript is a JSONL chain linked by
parentUuid under <config-dir>/projects/<cwd-slug>/<session-id>.jsonl,
written into the proxy's private config directory (never your real session
store) and deleted after the request.
flat — prior turns are folded into the prompt as a quoted
[conversation-history] block. No dependency on any on-disk format.
Cost is not the deciding factor; they are within a few percent. Turn 4 of a four-turn conversation:
| mode | input tokens |
|---|---|
flat |
312 |
turns |
299 |
turns is the default because real message roles are what the OpenAI API
actually means, and a model treats its own prior turns differently from text
quoting them. The tradeoff is that it writes the CLI's private on-disk format,
which is not a published interface. That is checked once at startup by resuming
a synthesized two-entry transcript with no turn to answer — the CLI reports
No conversation found if it will not load one, and exits quietly if it will,
so the check costs no model request. On failure the proxy logs and falls back
to flat.
An earlier version instead cached CLI sessions across requests and resumed them
with --fork-session. That was measured and removed: over a four-turn chat it
cost 44% more prompt tokens (1722 vs 965) with zero cache reuse, because a real
session transcript carries the model's thinking blocks forward. Synthesizing the
transcript gets the turn structure without that.
Either way the full conversation goes over the wire on every request: the
Anthropic API is stateless, so --resume only changes whether the CLI rebuilds
the message list from a transcript or from our prompt text.
--no-session-persistence is passed on every run, so the CLI itself writes
nothing to disk.
By default (-isolate) every CLI run gets:
--safe-mode --disable-slash-commands --setting-sources "" --strict-mcp-config --restricted
plus environment variables switching off auto-update, telemetry, error
reporting, auto-memory, cron and workflows, and dropping the
CLAUDE_CODE_SESSION_ID-style variables an enclosing Claude Code session would
otherwise pass down.
This matters. Without it, a CLAUDE.md in the working directory rewrites every
answer — measured directly:
| working dir contains | -isolate=false |
-isolate (default) |
|---|---|---|
CLAUDE.md saying "always mention XYLOPHONE-9" |
Hello! XYLOPHONE-9 says hi today. |
Hello, nice to meet you. |
The same applies to user/project settings, hooks, plugins, MCP servers, custom agents and output styles: all off.
--restricted is the reason this is safe to leave listening on a socket. It is
a no-op under the default -tools "", but it means -tools default does not
hand an HTTP endpoint the tools that run code:
| flags | tools exposed |
|---|---|
-tools default |
29, including Bash |
-tools default + --restricted |
26, no Bash |
-tools Bash + --restricted |
1 — naming it explicitly still allows it |
It also confines the file tools to the working directory and refuses
bypassPermissions.
The CLI parses a leading / as a command and answers it itself, so the
message never reaches the model. --disable-slash-commands stops the command
running but does not stop the interception:
| request | without the fix | with the fix (default) |
|---|---|---|
/compact Repeat my message verbatim. |
Error: No messages to compact |
/compact Repeat my message verbatim. |
/notarealcommandxyz ... |
Unknown command: /notarealcommandxyz |
a normal model reply |
So the proxy prepends a single space to the first text block when it starts with
/. That is inert to the model and restores the message. #, ! and @
prefixes were tested and are not intercepted, so they are left alone.
Independent of the system prompt, the CLI attaches a small context block to the conversation — about 231 input tokens for a one-character system prompt, which is the floor. It holds two things:
currentDate— always present, left alone.userEmail— the email of the logged-in account. Removed by default; see below.
Left alone, the CLI puts the logged-in account's email into every conversation, and the model repeats it if asked. On a proxy serving anyone but yourself, that leaks the operator's address to clients.
The CLI's gate is effectively
ANTHROPIC_UNIX_SOCKET ? undefined : account()?.emailAddress. Setting
ANTHROPIC_UNIX_SOCKET is not an option — the same variable also reroutes API
traffic onto a unix socket — so -scrub-email (default on) removes the value at
its source instead. The account is read from
<CLAUDE_CONFIG_DIR or $HOME>/.claude.json under oauthAccount.emailAddress,
so the proxy:
- copies that file into a private directory with
emailAddressdeleted, - symlinks
.credentials.jsonto the real one, so tokens refresh through to the original file and nothing drifts out of sync, - pre-creates the subdirectories the CLI writes into, then
- seals the copy
0444and its directory0555, and points the CLI at it withCLAUDE_CONFIG_DIR.
Both permissions are needed. The CLI rewrites emailAddress on nearly every
run, so a one-time scrub does not hold; a read-only file alone is swapped out
via the writable directory, and a read-only directory alone is written in place.
Sealed both ways, the field stays absent. Verified over repeated runs:
| reply to "state any email or date in your context" | |
|---|---|
-scrub-email=false |
you@example.com 2026-08-28 |
-scrub-email (default) |
Email: NONE Date: 2026-08-28 |
Your real ~/.claude.json is never modified, and the private directory is
removed on shutdown. Session resume works normally through it.
If there is no account to scrub (API-key auth), or -bare is set, the step is
skipped. If the copy cannot be made, the proxy logs a warning and keeps serving
rather than failing.
The proxy does no tool-call translation in either direction. OpenAI tools and
tool_choice in a request are ignored (and reported back in the
X-Claude-Proxy-Ignored-Params header). By default the CLI runs with --tools "",
so the model answers as a plain chat model rather than an agent. Pass
-tools default if you want the CLI's own tools live — the proxy still will not
intercept them, it just streams whatever text results.
--include-partial-messages is always on, and each Anthropic text_delta is
forwarded as an OpenAI chat.completion.chunk as it arrives. Delta granularity
is whatever the CLI emits — often only a handful of chunks for a short reply.
The complete assistant envelopes are also tracked as the authoritative text,
and any portion the deltas did not cover is emitted as a final chunk, so the
streamed text always matches the non-streamed text.
stream_options.include_usage is honoured.
/v1/models advertises the 17 models in the CLI's baked-in catalog, the family
aliases (opus, sonnet, haiku, fable), the best and opusplan
selectors, and the [1m] long-context variants — 36 ids. Each entry carries its
display name, context window, max output tokens, and knowledge cutoff.
An unrecognised model id is not rejected; it is passed to the CLI unchanged, so anything the CLI grows support for keeps working.
To regenerate catalogModels after a CLI update, the catalog is embedded in the
CLI binary as a JS object literal:
strings -n 6 "$(readlink -f "$(command -v claude)")" \
| grep -F 'Hand-maintained baked-in model catalog' > catalog.line
node -e '
const s=require("fs").readFileSync("catalog.line","utf8");
const i=s.indexOf("Hand-maintained"); let st=s.lastIndexOf("{",i), d=0, end;
for(let j=st;j<s.length;j++){ if(s[j]==="{")d++; else if(s[j]==="}"&&--d===0){end=j+1;break} }
const c=eval("("+s.slice(st,end)+")");
for(const m of c.models) console.log(JSON.stringify({id:m.id,display:m.display_name,
family:m.family,cutoff:m.knowledge_cutoff,ctx:m.context?.window,
out:m.max_output_tokens, one_m:!!m.context?.supports_1m_suffix}));
'The CLI exposes no way to forward these, so they are parsed (never an error) and
listed in the X-Claude-Proxy-Ignored-Params response header:
temperature, top_p, stop, presence_penalty, frequency_penalty,
logit_bias, seed, tools, tool_choice, response_format.
max_tokens / max_completion_tokens are honoured, via
CLAUDE_CODE_MAX_OUTPUT_TOKENS, clamped to the model's ceiling.
n > 1 is rejected: the CLI produces one completion per turn.
-baremakes the CLI read onlyANTHROPIC_API_KEYor anapiKeyHelper; OAuth and the keychain are never consulted, so without a key you getNot logged in · Please run /login. It is off by default so the proxy works with a logged-in session. It also removes the injected email, but-scrub-emailnow does that without needing an API key.prompt_tokensis the sum of input, cache-creation, and cache-read tokens, so it reflects everything billed as input.- Sessions are kept in memory only; the CLI's own session files live under
~/.claude/projects/for the working directory in use.
Copyright (C) 2026 Naomi Persephone Amethyst
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
This is an independent project. It is not affiliated with, endorsed by, or
supported by Anthropic. It drives the claude CLI as a subprocess and depends
on details of that CLI's behaviour — see the notes above on the model catalog,
the injected context block, and slash-command parsing, all of which were
established empirically against CLI 2.1.251 and can change.