claude-subscription-to-api - a small API in front of the Claude CLI
Notes on a small container I run at home, which gives HTTP access to the Claude CLI, so I can test API clients without an API key.
I have a subscription plan, and I don't have an API key. But sometimes I want to test an app which I may wish to run with the real Claude API in future. The CLI on my machine is already logged in, so the access is there, it is just not shaped like an API. So I made a small container which gives HTTP access to that CLI, and I run it on my homelab.
Inside the container there is a Node process with NestJS and a React Router UI. Nothing of this is open to the internet.
Uploading the credentials
On Linux the CLI stores its OAuth credentials in ~/.claude/.credentials.json. On a machine where I am already logged in, I copy the contents of the credentials file, paste it into a box in the web UI, and save. The same for the CLI config file, which contains the onboarding state and the account.
Before anything is written to disk, the shape is validated, and then the file is written atomically with mode 600. And it is written into the real home directory of the host user, which is mounted into the container. The container and my SSH session use one set of credentials, not two copies. If I upload from the browser, the claude which I run over SSH is logged in too.
The status endpoint returns only metadata, which is the plan, the expiry and whether the credentials are still valid. It never returns the tokens back.
Keeping them alive
An access token lives a few hours, so uploading it one time and forgetting about it does not work. The obvious solution is to refresh the token in the app itself. I decided not to do this.
The CLI owns that file and rotates the refresh token every time it runs. If a second refresher does the same at the same moment, the tokens in the file become empty. And then nothing helps, the file does not repair itself. This happened to me one time. Then I logged in on another machine and uploaded the credentials again.
Every two hours the container runs the CLI with a one word prompt on the cheapest model, and the CLI decides itself whether to extend auth. The status card shows a short history of the last pings, with the time of the run, how long it took and whether the expiry was extended. Most records say that nothing was rotated, and this is the normal case, it only means the token still had a lot of time left.
Mapping the API onto the CLI
On the tokens page I create a token, and it goes into the x-api-key header, exactly where a real API key goes. Only the hash of the token is stored, so the token itself is visible one time, right after I create it.
A request comes in with the Messages API shape and is validated the same way the real API validates it. Then it is translated into a CLI call. The model string goes to the model flag. The system prompt goes to the system prompt flag. The messages array is the difficult part, because the CLI takes one prompt and not a conversation, so a multi-turn array becomes one transcript with human and assistant labels.
The CLI answers in JSON. I take this JSON and build from it a normal Messages API response. The text, the stop reason and the token numbers all come from there. When streaming is on, the partial messages of the CLI become server-sent events, with the same event names as in the real API.
Errors go into the same error envelope, and the type of the error comes from the status which the CLI reported. Without this the official SDK does not understand what happened. It cannot raise its own exception, and it does not retry.
So the Python or the TypeScript SDK needs one change, the base URL, and the code around it stays the same.
What is different from the real API
Behind the endpoint there is a CLI, and the CLI is a coding agent, not a plain model. So some things work in another way, and some do not work at all. The full list is in the docs.
- Text only. Images and documents are rejected.
- Tools are rejected too, because the CLI has its own tools.
max_tokensis validated but not enforced. The CLI decides how long to run.- Temperature, top_p, stop sequences and thinking config are accepted and then ignored, because there is nothing to map them to.
- Token counting, batches and files are not implemented.
- Billing goes to the subscription and not per token. The usage numbers in the answer are just a count from the CLI, nothing is charged for them.
Only two runs can go at the same time. If one more request comes, it gets a rate limit error, in the same shape as the real API sends it.
Why I do not open it to the internet
A token can do everything which the CLI can do. By default the CLI runs with its tools disabled, so a token gives only text. But there is a switch which turns the tools on, and then everybody with a token can run shell commands on that machine.
It solved my problem, which was testing API clients on a plan without an API key. It does not replace the API, and it is usable only for testing.
Source is on GitHub.