Build, launch, and drive shunt — the Claude Code LLM gateway (a Rust/axum Anthropic-Messages proxy). Use to run, start, smoke-test, or curl-drive the gateway, exercise /v1/models discovery and /v1/messages proxying, or connect Claude Code to a local shunt instance.
Pro scans all 2 files and shows the line behind each finding
Scanned 9/23/2026
npx -y skills add pleaseai/shunt --skill run-shunt --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Run Shunt?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/pleaseai-run-shunt)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: run-shunt
description: Build, launch, and drive shunt — the Claude Code LLM gateway (a Rust/axum Anthropic-Messages proxy). Use to run, start, smoke-test, or curl-drive the gateway, exercise /v1/models discovery and /v1/messages proxying, or connect Claude Code to a local shunt instance.
---
# Run shunt
`shunt` is a Rust ([axum](https://github.com/tokio-rs/axum)) HTTP server — a Claude Code LLM gateway. It has **no GUI**: you drive it with `curl`. It listens on `127.0.0.1:3001` by default and serves an Anthropic-Messages surface (`GET /v1/models`, `POST /v1/messages`, `POST /v1/messages/count_tokens`, `HEAD /`). For each mapped `model` id it diverts inference to another provider (OpenAI / Codex / ChatGPT via the OpenAI Responses API); everything else passes through to Anthropic.
**Primary agent path:** run the committed driver [`.claude/skills/run-shunt/smoke.sh`](smoke.sh). It builds the binary, spins up a local mock upstream (so no real API key is needed), launches the gateway, and drives every route end to end with assertions. That is the way to confirm a change works.
All paths below are relative to the repo root (the `shunt/` directory).
## Prerequisites
Everything the driver needs is already standard on macOS/Linux dev boxes:
```bash
cargo --version # Rust stable (built with 1.94); toolchain via rust-toolchain.toml if present
python3 --version # smoke.sh uses http.server as a stand-in upstream
curl --version
jq --version # smoke.sh asserts JSON responses with jq
lsof -v
```
On a bare Ubuntu container: `apt-get install -y curl jq lsof python3` and install Rust via `rustup` if `cargo` is missing.
## Build
```bash
BIN="$(cargo build --locked --message-format=json-render-diagnostics | jq -sr '[.[] | select(.reason == "compiler-artifact" and .target.name == "shunt" and (.target.kind | index("bin"))) | .executable | select(. != null)] | last // empty')"
test -x "$BIN"
```
`BIN` resolves Cargo's actual executable path, including `CARGO_TARGET_DIR` and a configured target triple.
## Run (agent path) — the smoke driver
This is what you run to see shunt working. It is hermetic (no network, no credentials) and exits non-zero on the first failed assertion.
```bash
.claude/skills/run-shunt/smoke.sh
```
Expected tail:
```
PASS shunt check -> config ok
PASS HEAD / -> 200 (server live)
PASS GET /v1/models returns configured model
PASS POST /v1/messages proxied to upstream and returned its body
PASS malformed request -> 400 invalid_request_error
PASS GET /health reports ok
PASS GET /protocol describes the anthropic-messages contract
PASS GET /routes resolves the configured route
PASS POST /v1/messages/count_tokens proxied to upstream on its own path
All smoke checks passed.
```
What it covers: every route the server registers unconditionally. Config validation (`shunt check`), liveness (`HEAD /`), health (`GET /health`), the gateway contract (`GET /protocol`), model discovery (`GET /v1/models`), the resolved route table (`GET /routes`), both proxy forward paths (`POST /v1/messages` and `POST /v1/messages/count_tokens`, routed to a local mock that stands in for `api.anthropic.com` and asserted by the path it forwarded to), and the routing error path (a body with no `model` field → `400 invalid_request_error`). Routes that only an optional config section registers (the Codex endpoint, the usage surfaces) are out of scope, since the smoke config does not enable them.
`SHUNT_PORT` and `MOCK_PORT` default to `31711` and `31712`. Set either to `0` to bind an ephemeral port instead, which is the conflict-free way to run the driver beside a live gateway.
The driver has its own regression, [`test_smoke.sh`](test_smoke.sh). Run it after editing `smoke.sh`. It proves the driver refuses a port outside `0..65535` by name; refuses a port another listener already holds, where the real process dies at bind and the driver says which one died; and refuses a shunt listening on a port it never requested, which is the case the pid-derived readiness check catches. It then runs the normal path. The stand-in listener answers every assertion the driver makes, so a driver that trusted `$SHUNT_PORT` over the process it started would go green against it and get caught.
### Drive it by hand
Write a config, launch, and curl it yourself:
```bash
BIN="$(cargo build --locked --message-format=json-render-diagnostics | jq -sr '[.[] | select(.reason == "compiler-artifact" and .target.name == "shunt" and (.target.kind | index("bin"))) | .executable | select(. != null)] | last // empty')"
test -x "$BIN"
"$BIN" run --config ./shunt.toml # or copy shunt.toml.example first
```
Then, against the running server:
```bash
curl -s "http://127.0.0.1:3001/v1/models?limit=1000" | jq .
# => {"data":[{"id":"claude-opus-via-codex","display_name":"Opus (via Codex)"}]}
```
Validate a config without starting the server:
```bash
BIN="$(cargo build --locked --message-format=json-render-diagnostics | jq -sr '[.[] | select(.reason == "compiler-artifact" and .target.name == "shunt" and (.target.kind | index("bin"))) | .executable | select(. != null)] | last // empty')"
test -x "$BIN"
"$BIN" check --config ./shunt.toml # prints "config ok" or a precise error
```
CLI shape: `shunt run|check [--config <path>]` (also `shunt --check`). Default config path is `./shunt.toml`; `SHUNT_`-prefixed env vars override (with `__` for nesting).
## Direct invocation (internal logic — most PRs touch this)
The interesting code is the **Anthropic Messages ⇄ OpenAI Responses translation** (`src/adapters/`, `src/model/`) and routing (`src/routing.rs`). These are covered by unit + integration tests — the fastest inner loop for a PR touching them:
```bash
cargo test --workspace # all tests
cargo test --test responses_translate # the translation integration suite
```
Full pre-PR gate (matches CI):
```bash
cargo fmt --all --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features --workspace
```
## Run (human path) — connect Claude Code to a local shunt
Point Claude Code at the running gateway. shunt does **not** validate the credential, but Claude Code still needs one set or it drops to its login wizard. Per the [gateway-connect docs](https://code.claude.com/docs/en/llm-gateway-connect):
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
export ANTHROPIC_AUTH_TOKEN=local-dummy # any string; shunt ignores it
export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 # opt-in: pull /v1/models into the picker
export ANTHROPIC_CUSTOM_MODEL_OPTION=gpt-5.2-codex # add a non-claude id to /model (see Gotchas)
claude # started from the same shell; /status shows the base URL
```
Verify the wiring without opening Claude Code (this is the docs' own check):
```bash
curl -X POST "$ANTHROPIC_BASE_URL/v1/messages" \
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
-H "anthropic-version: 2023-06-01" -H "content-type: application/json" \
-d '{"model":"claude-opus-via-codex","max_tokens":1,"messages":[{"role":"user","content":"."}]}'
```
With a default (anthropic-routed) config this forwards to `api.anthropic.com` and needs a real Anthropic key in the header to get a `200`; a mapped model routed to `openai`/`codex` needs that provider's credential instead. For a credential-free run, use `smoke.sh` (mock upstream) rather than this path.
## Gotchas
- **No GUI, no `npm start`.** It's a Rust HTTP server. "Running it" means launch + `curl`. The driver is the smoke script.
- **Model discovery drops non-`claude`/`anthropic` ids.** Claude Code's `/v1/models` importer ignores any `id` not starting with `claude` or `anthropic`, so to route to e.g. `gpt-5.2-codex` you either alias it under a `claude…` discovery id **or** add it via `ANTHROPIC_CUSTOM_MODEL_OPTION` (the primary way). This is a Claude Code constraint, not a shunt bug.
- **shunt ignores the request credential.** `GET /v1/models` reads `authorization`/`x-api-key` but discards it (`src/discovery.rs`), and the proxy just forwards headers upstream. So any dummy token works for local driving — the real key only matters to the *upstream* provider.
- **`shunt check` is strict about provider shape.** `providers.openai.adapter` and `providers.codex.adapter` must be `"responses"`; `openai.auth` must be `api_key`, `codex.auth` must be `chatgpt_oauth` — otherwise `check` fails with a specific error (`src/config.rs`). Partial TOML is fine: figment merges your file over built-in defaults, so you only need to specify what differs.
- **A discovery `[[models]]` entry with no matching `[[routes]]`** logs a `WARN` at startup/`check` but is not fatal.
- **`GET /protocol` is the machine-readable gateway contract.** It is unauthenticated and reports shunt's package version, Anthropic-Messages format, supported endpoints, header handling, attribution behavior, and model-discovery constraints.
- **zsh quoting:** quote URLs containing `?` (globbing) and mind `noclobber` on `>` redirects when driving by hand in this repo's shell.
## Troubleshooting
- **`smoke.sh` reports `shunt exited during startup` or `mock upstream exited during startup`**: the dumped log says `Address already in use`, so a stale process holds that test port. Run `lsof -nP -iTCP:${MOCK_PORT:-31712} -sTCP:LISTEN` for the mock port or replace it with `${SHUNT_PORT:-31711}` for the gateway port. Stop the reported PID, then re-run. `SHUNT_PORT=0 MOCK_PORT=0` skips the conflict outright by binding ephemeral ports.
- **`502 Bad Gateway` / `api_error: error sending request for url (...)`** — shunt reached routing but the upstream `base_url` was unreachable (wrong host/port, or the mock/provider isn't up). This is the correct error mapping (`src/error.rs`), not a crash. Check the target `base_url`.
- **`400 invalid_request_error: request body must include a JSON model field`** — the request body isn't JSON with a `model` key. Routing happens before forwarding (`src/routing.rs`).
- **`cargo clippy` fails the build** — CI sets `RUSTFLAGS=-D warnings`; warnings are errors. Fix them before a PR (`cargo clippy --all-targets --all-features -- -D warnings`).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!