Juracich Path · Documentation
Run it on your own machine.
Path puts an OpenAI-compatible and Anthropic-compatible API in front of the Claude Code and Codex CLIs you are already signed in to. Three commands and either SDK is talking to your own subscription.
How it works
Claude Code and Codex ship as command-line tools that run on your computer and are authenticated with your own login. Path wraps them in the HTTP shape every AI library already speaks, and serves it on 127.0.0.1.
A request arrives looking like OpenAI’s. Path turns it into a CLI invocation, feeds the prompt in on stdin, streams the output back as server-sent events, and hands you a normal OpenAI-shaped response. Your client cannot tell the difference; the work happened in a process on your own machine.
Runs locally
One process on your machine. No server to deploy.
Your login
Credentials stay in each CLI's own store.
Confined
Runs are limited to directories you name.
Install
Node 20 or newer. You also need Claude Code or Codex installed and signed in as the same user that will run Path, vendor credentials are per-user, and a mismatch here is the most common cause of “it worked in setup and fails in production”.
npm install -g @juracich/transporterThat installs two names for the same tool: transporter and juracich-path. Use whichever you prefer.
Sign in
Path needs a Juracich account before it will serve. This is a one-time browser sign-in.
transporter signinYour browser opens on a consent screen. Approving it hands a token back to the waiting terminal over a loopback address, so the token never passes through your clipboard or your shell history. It is written to ~/.local/state/juracich-transporter/account.json with mode 0600.
That token is scoped: it can confirm your licence and read your email address, and it cannot do anything else on your account. Revoke it any time from your devices page.
Signing in on a server
Over SSH, that loopback hand-back cannot work, and it is worth knowing why before it happens to you. The terminal opens a socket on the server’s 127.0.0.1. Your browser is on your laptop, where 127.0.0.1 means your laptop. Nothing is listening there and nothing will be.
So Path detects a remote session and does not pretend otherwise. It prints the URL for you to open on your own machine, and waits:
transporter signin
This machine (prod-web-1) has no browser of its own.
Open this on the computer you are sitting at:
https://www.juracich.com/dashboard/authorize?app=path&…
Approve it there. The browser will then land on a
"This site can't be reached" page. That is expected, because
127.0.0.1 is your laptop, not this box.
Copy the whole URL from that page's address bar and paste it here:The connection error is the expected step, not the failure. The token is in that page’s address bar and is perfectly good. Copy the whole URL, paste it into the waiting terminal, and you are signed in.
If the terminal has already given up. You closed it, or the sign-in timed out. That URL is still valid. Recover it with transporter signin --paste, which asks for the URL and nothing else.
Serve
transporter serve
✓ claude 2.1.225 (Claude Code)
✓ codex codex-cli 0.135.0
✓ Serving locally 2 runners
base_url http://127.0.0.1:8787/v1
api_key not required (loopback)
models claude, codex
account you@example.comIt binds 127.0.0.1 only. Nothing outside your machine can reach it, which is why no API key is required by default. Use --port if 8787 is taken.
Using it
Point any OpenAI-compatible client at the printed base_url. Nothing else changes.
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8787/v1", api_key="unused")
r = client.chat.completions.create(
model="claude/opus",
messages=[{"role": "user", "content": "Say OK"}],
)
print(r.choices[0].message.content)Streaming works the same way, pass stream=True and read deltas as usual. The same swap works for the Node SDK, LangChain, Cursor, or anything else that accepts a custom base URL.
Model strings
The model field splits at the slash. The half before it picks the runner; everything after is handed to the vendor CLI untouched, so new model aliases work the day they ship without waiting on us.
To see what a machine can actually serve, run transporter runners or call GET /v1/models. That list is Claude’s own aliases plus Codex’s live model catalogue, and it exists to fill a picker. It is not a list of permitted values. Anything after the slash is forwarded either way.
Both vendors define that route with envelopes that are not compatible, so Path answers in whichever one you asked for. Send anthropic-version, as every Anthropic SDK does, and you get Anthropic’s shape; anything else gets OpenAI’s. The contents are identical either way, so an id cannot show up in one client’s picker and be missing from the other’s.
| model | Runs |
|---|---|
| claude | Claude Code, default model |
| claude/opus | Latest Opus |
| claude/sonnet | Latest Sonnet |
| claude/haiku | Latest Haiku |
| claude/opus[1m] | Latest Opus, 1M context |
| claude/claude-opus-5 | A full model id, passed through |
| codex | Codex, default model |
| codex/gpt-5.5 | GPT-5.5 |
| codex/gpt-5.4-mini | GPT-5.4-Mini |
Path options
OpenAI’s schema has no field for some things a local CLI can do, so requests may carry an extra path object. Clients that don’t know about it are unaffected, and responses carry one back with session_id, cost_usd and duration_ms.
{
"model": "claude",
"messages": [{ "role": "user", "content": "Refactor this module" }],
"path": {
"session_id": "…", // resume the vendor's own session
"cwd": "/home/you/project", // must sit inside a workspace root
"permission_mode": "auto",
"allowed_tools": ["Read", "Edit", "Grep"],
"max_turns": 30,
"timeout_ms": 600000
}
}On multi-turn: passing a full messages array works, history is flattened into a transcript. But the CLIs have real sessions, so threading path.session_id from the previous response is higher fidelity: it keeps the vendor’s native context and prompt caching, which the flattened form throws away.
Anthropic SDK
As well as the OpenAI shape, Path serves POST /v1/messages in Anthropic’s. Point the official SDK at it and nothing else changes.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: "sk-path-…",
baseURL: "https://path.example.com",
});
const m = await client.messages.create({
model: "claude",
max_tokens: 512,
messages: [{ role: "user", content: "Say OK" }],
});x-api-key works as well as a bearer token, and the wire shape is held to Anthropic’s own in detail: named SSE events in the documented order, ping keep-alives on a long run, thinking and tool_use blocks with their real delta types, and a stop_reason drawn only from the values the real API can return. An SDK typed against /v1/messages should not be able to tell the difference from the bytes.
Streaming holds even when you declare tools. A tool call is only visible once the whole message exists, so such a run is buffered and replayed as a stream rather than answered with a JSON body your client is no longer expecting. You get the same frames either way, just later. This is the one place the two surfaces differ: on /v1/chat/completions, sending tools still turns streaming off.
| Route | Does |
|---|---|
| POST /v1/messages | A turn, streaming or not |
| POST /v1/messages/count_tokens | An estimate, flagged as one |
| GET /v1/models | The catalogue, in Anthropic's shape |
| GET /v1/models/:id | One entry from it |
count_tokens is a character-based estimate and says so in its own response. The real count depends on the vendor tokeniser plus whatever system prompt the CLI prepends, neither of which is visible from here. Refusing outright would break SDK clients that call it before every request; a confident wrong number would be worse.
One thing to understand before you build on it. Behind this endpoint is Claude Code, which is an agent, not a raw model. It runs its own tool loop and returns a finished answer. So temperature, top_p, max_tokens and stop_sequences have nothing to act on and are accepted and ignored rather than pretended at, and system is added to the CLI’s own prompt rather than replacing it. The wire shape is honest; the behaviour is an agent. Don’t point something that needs true single-turn completions at it.
Declaring tools also switches off the CLI’s own web search, file and shell tools for that request, unless you pin path.allowed_tools yourself. That is deliberate and load-bearing: given a problem and its own toolbox, the agent will solve the problem rather than call back to you. Removing the alternatives is what makes your function the one it reaches for.
Long-running work
/v1/chat/completions holds the connection open for the whole turn. That suits a chat client and not an agent: work that edits a repo runs for minutes, outlives proxy timeouts, and is lost if your client drops. And when the machine is already busy, a second caller is simply turned away.
/v1/runs queues instead. You get an id straight back, a busy machine means you wait rather than fail, and a dropped connection costs nothing because the run was never tied to it.
curl $BASE/v1/runs -H "Authorization: Bearer $KEY" -d '{
"model": "claude",
"prompt": "Review the auth module and list correctness bugs",
"path": { "cwd": "/srv/workspaces/api",
"allowed_tools": ["Read", "Grep", "Glob"],
"max_turns": 30, "timeout_ms": 900000 }
}'
# → 202 {"id": "run_…", "status": "queued", "queue_position": 2}| Route | Does |
|---|---|
| POST /v1/runs | Submit. Takes prompt or messages |
| GET /v1/runs/:id | State, plus the text so far |
| GET /v1/runs/:id?wait=60000 | Block until it finishes (max 120s) |
| POST /v1/runs/:id/cancel | Stop it, queued or running |
| GET /v1/runs | Your recent runs |
Status goes queued → running → succeeded, failed or cancelled. A 429 here means the queue is full, not that the machine is busy, busy is the case the queue absorbs.
One thing to design around: runs are held in memory for an hour and lost on restart. Poll until done and keep your own record of anything you need later.
Named agents
An agent is otherwise something you reassemble on every request, a system prompt, a model, a directory, a tool list, a permission mode, and they all have to agree or it quietly behaves like a different agent. Name that bundle once in your config file instead.
[agents.reviewer]
description = "Reviews code and lists correctness bugs"
model = "opus"
system = "You review code for correctness bugs. Cite line numbers."
cwd = "/srv/workspaces/api"
allowed_tools = ["Read", "Grep", "Glob"]
permission_mode = "plan"
max_turns = 30
timeout_seconds = 900Then call it as a model: "model": "agent/reviewer", on either endpoint. Named agents also show up in GET /v1/models, so a client that reads that list finds them in its own picker without learning anything new.
A preset sets defaults, and anything explicit on the request wins, so you can adjust the agent without breaking a caller that overrode one field. Two exceptions on purpose: system is combined rather than replaced, so adding a line of context cannot erase the agent’s definition, and timeout_ms takes whichever is shorter.
Worth knowing: your system text is added to the CLI’s own system prompt rather than replacing it. Style and role instructions land reliably; instructions that fight the CLI’s defaults sometimes don’t.
Workspaces
Runs may only work inside directories you have allowed. Workspaces are how you create those directories over the API instead of logging in to the machine.
curl $BASE/v1/workspaces -H "Authorization: Bearer $KEY" \
-d '{"name": "api", "git_url": "https://github.com/you/api.git"}'
# → 201 {"name":"api","path":"/srv/workspaces/api",
# "git":{"branch":"main","dirty":false}}GET to list, DELETE /v1/workspaces/:name to remove. New workspaces are created under the first root in your [workspaces] config, which is the same root cwd is checked against, anywhere else and every workspace you created would be rejected by the next run that tried to use it.
Be clear-eyed about what this endpoint grants: creating and deleting directories, and pulling arbitrary repositories onto the machine. That is a good deal more than asking a model a question. Names are strictly validated and the resolved path must be a direct child of the root, so a name that tries to escape is refused rather than quietly rewritten into a different directory.
Set [workspaces].roots deliberately. Left unset it falls back to the service user’s home directory, which puts workspaces next to your credentials.
Tool calling
tools and tool_choice work in the standard OpenAI shape, so an unmodified client gets back finish_reason: "tool_calls" and a tool_calls array, runs your function, and sends the result back as a tool message. Thread path.session_id across the round trip to keep context.
Understand what this is before you rely on it. The CLI runs its own tool loop and cannot pause mid-run to call your function. It only returns a final message. So the contract is carried in the prompt: your functions are described to the model and it is told exactly how to emit a call.
That means compliance is model behaviour, not a guarantee: it may answer in prose where you expected a call. If you need a hard guarantee, drive the CLI’s own tools with allowed_tools, which the runner enforces directly.
A call is only visible once the whole message exists, so on /v1/chat/completions sending tools turns streaming off for that request. On /v1/messages it does not: the run is buffered and replayed as a stream instead, so the response is still SSE. If you need both tools and a streaming client, use the Anthropic endpoint.
What leaves your machine
Worth being precise about, since it is the whole point of running Path locally.
Your prompts and completionsnever leaves your machine
Go from your client to the CLI on your machine, and back. They never reach us.
Your Claude or OpenAI credentialsnever leaves your machine
Stay in each vendor CLI's own credential store. Path never reads them.
Your filesnever leaves your machine
Read by the CLI locally, under the workspace limits you set.
A licence checkleaves your machine
On start-up and roughly every 72 hours, Path asks juracich.com whether your account is still active. It sends your scoped token and nothing else.
If juracich.com is unreachable, Path keeps working on its last successful check for two weeks before it asks again. An outage on our side, or a laptop with no signal, does not stop you using your own subscription on your own computer.
Security
Loopback by default. Serving beyond 127.0.0.1 is refused unless you also pass --api-key, because anything that can reach the port can execute runs on the machine.
Workspace confinement. A run’s cwd must resolve inside a root you have configured; anything else is rejected with a 400. This holds on loopback too: cwd arrives from whoever sent the request, and local does not mean trusted.
transporter workspace list
transporter workspace add ~/projectsLeast privilege. Claude runs with --permission-mode auto and Codex with -s read-only unless you widen them.
No accidental metered billing. If ANTHROPIC_API_KEY or OPENAI_API_KEY is in the environment, Path strips it before spawning, otherwise a stray key silently diverts runs to metered API billing, defeating the point.
More than one machine
serve covers the common case: the client and the subscription on the same box. If calls come from somewhere else, or several machines should sit behind one endpoint, Path can instead run as a gateway with agents dialling out to it.
Agents always dial out, so the machines need no inbound ports and the gateway never holds SSH credentials. It routes to whichever machine has the runner available and quota left, and skips any whose five-hour window is spent.
transporter login --agent-id <id> --token <secret> --gateway wss://…
transporter startThe gateway serves the same two API shapes as serve does, so a client written against a local install needs only its base_url changed to point at the fleet instead.
Running it as a service
start stays in the foreground, which is what a service manager wants. On Linux, install writes a systemd user unit that runs it, reloads, and enables it now.
transporter install # write the unit, enable and start it
transporter logs -f # follow the output
transporter uninstall # stop, disable, remove the unitA systemd user unit stops when your last session ends unless lingering is on, so install checks and tells you to run loginctl enable-linger if it is not. That is the difference between a service that works during setup and one that is still running tomorrow.
logs reads whichever place the output actually went: the journal when a unit is installed, and ~/.local/state/juracich-transporter/transporter.log when you started it with start --detach. Both take -f to follow and -n for a line count. On macOS there is no unit to manage, so run start under launchd, pm2 or Docker instead.
Gateway mode is private beta and set up by hand. Get in touch if you need it.
Troubleshooting
doctor checks everything that can be wrong and prints the fix for anything that is: Node, the effective user, both vendor CLIs and their auth state, file permissions, clock skew, workspace roots.
transporter doctor # what is broken, and how to fix it
transporter doctor --fix # apply the safe repairs
transporter run "hello" # one run, no server, isolates CLI problemsrun is the fastest way to tell “the vendor CLI is broken” apart from “Path is broken”, which otherwise look identical.
Port 8787 is already in use
Something else is on that port, pass --port, or stop it.
No usable runner on this machine
Neither CLI is installed and signed in as this user. Run transporter auth.
cwd … is outside the allowed workspace roots
Add it with transporter workspace add, or drop path.cwd from the request.
Not signed in
Run transporter signin. Path needs an account before it will serve.
This site can't be reached, 127.0.0.1 refused to connect
Expected when signing in to a server over SSH: that port is on the server, not your laptop. Copy the whole URL from the address bar and paste it into the waiting terminal, or use transporter signin --paste.
at capacity (1 concurrent runs)
The machine is already running its limit. Use POST /v1/runs to queue instead of being turned away, or raise max_concurrent_runs if it has the memory for it.
Port 8787 is already in use, after installing the service
The service is already serving on it. Manage it with systemctl rather than running transporter serve by hand.
Worth being clear-eyed about
Routing a consumer Claude or Codex subscription through an API-shaped endpoint sits outside both vendors’ plan terms, and Claude Code has proxy detection. Path runs on your machine under your own login, so that decision, and its consequences, are yours. We keep the runner adapters deliberately thin, so when something upstream changes it is one adapter to fix rather than the whole product.
Stuck on something not covered here?
Get in touch