Skip to content

Repository files navigation

claude-proxy

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.

Install

go install github.com/NaomiAmethyst/claude-proxy@latest

Or 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.

CI binaries

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.

Tests

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-file is passed even when the request has none
  • --restricted, --safe-mode and 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 -history modes
  • 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

Run

# 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 -bare
curl 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"}])

Flags

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.

Endpoints

  • POST /v1/chat/completions — streaming (SSE) and non-streaming
  • GET /v1/models, GET /v1/models/{id}
  • GET /health — never requires auth

How it works

The system prompt is passed through untouched

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.

Multi-turn

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.

Isolation: the CLI is stripped down to a chat endpoint

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.

Messages beginning with "/"

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.

What still gets injected

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.

Hiding the account email

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:

  1. copies that file into a private directory with emailAddress deleted,
  2. symlinks .credentials.json to the real one, so tokens refresh through to the original file and nothing drifts out of sync,
  3. pre-creates the subdirectories the CLI writes into, then
  4. seals the copy 0444 and its directory 0555, and points the CLI at it with CLAUDE_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.

Tool calls are not intercepted

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.

Streaming

--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.

Models

/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}));
'

Parameters that are accepted but ignored

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.

Notes

  • -bare makes the CLI read only ANTHROPIC_API_KEY or an apiKeyHelper; OAuth and the keychain are never consulted, so without a key you get Not 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-email now does that without needing an API key.
  • prompt_tokens is 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.

Licence

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.

Unaffiliated

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.

About

OpenAI-compatible /v1/chat/completions backed by the claude CLI. Use the login you already have — no API key, no config file, no dependencies. One Go binary; streaming and multi-turn.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages