9.8 KiB
Claude Code gateway protocol
This is the wire contract the Claude Code CLI uses to talk to this gateway: sign-in, inference, managed settings, and telemetry. It's served from the gateway itself so it always matches the version you're running.
Stability: this protocol exists to give you a more stable target than proxying raw CLI traffic. Auth is standard OAuth 2.0, inference is the Messages API, and headers are the lowest common denominator across backends. We keep it backwards compatible within reason to support older clients, but not forever — expect changes, managed settings in particular, with notice.
A developer points Claude Code at your gateway's base URL via /login and
the client does the rest. All paths below are relative to that base URL, and
the client does not follow cross-origin redirects.
Flow
- Client fetches
GET {base}/.well-known/oauth-authorization-server. - On first contact, client fingerprints your TLS certificate and asks the user to trust it.
- Client runs the RFC 8628 device flow:
POST device_authorization_endpoint-> user approves in a browser atverification_uri-> client pollstoken_endpointuntil it gets a bearer token. - Client sends
Authorization: Bearer <token>on every subsequent request. - Client uses fixed paths under
{base}for inference (/v1/messages), policy (/managed/settings), model discovery (/v1/models), and telemetry (/v1/{metrics,logs,traces}). - Before the token expires, client silently calls
token_endpointwithgrant_type=refresh_token. If you didn't issue a refresh token, the user is sent back through the browser flow instead.
Discovery — required
GET /.well-known/oauth-authorization-server (unauthenticated)
RFC 8414 authorization server metadata. The client reads
device_authorization_endpoint and token_endpoint and ignores the rest;
both must be same-origin with {base}. authorization_endpoint is
intentionally absent.
{
"issuer": "https://gw.corp.example.com",
"device_authorization_endpoint": "https://gw.corp.example.com/oauth/device_authorization",
"token_endpoint": "https://gw.corp.example.com/oauth/token",
"grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"]
}
Device authorization — required
POST {device_authorization_endpoint} (unauthenticated)
RFC 8628 §3.2. The client opens verification_uri_complete in the user's
browser and polls token_endpoint every interval seconds.
{
"device_code": "AbK9-s3n4C8H...",
"user_code": "WDJB-MJHT",
"verification_uri": "https://gw.corp.example.com/device",
"verification_uri_complete": "https://gw.corp.example.com/device?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}
device_code should be >=256 bits, opaque, single-use. user_code should
use a base-20 charset (RFC 8628 §6.1).
Verification page — required
GET/POST {verification_uri} (browser-facing; the client never calls this)
Accept the user code, authenticate the user against your IdP, and mark the
matching device_code approved so the next token poll succeeds. Apply a
per-IP rate limit (RFC 8628 §5.1) and don't auto-submit a pre-filled code
(§5.4).
Token — required
POST {token_endpoint} (unauthenticated,
application/x-www-form-urlencoded)
Device grant (grant_type=urn:ietf:params:oauth:grant-type:device_code):
| Status | Body | Client reaction |
|---|---|---|
| 200 | {"access_token","token_type":"Bearer","expires_in","refresh_token"?} |
Login complete. refresh_token is optional; omit it and the client re-runs the device flow on expiry. |
| 400 | {"error":"authorization_pending"} |
Keep polling. |
| 400/429 | {"error":"slow_down"} |
Add 5s to the poll interval. |
| 400 | {"error":"access_denied"} |
Stop. |
| 400 | {"error":"expired_token"} |
Stop. |
Refresh grant (grant_type=refresh_token): return a fresh
{"access_token","token_type","expires_in","refresh_token"} on 200. Return
401 {"error":"invalid_grant"} to force re-login — this is your
deprovisioning hook.
Messages — required
POST /v1/messages and POST /v1/messages/count_tokens (bearer)
The Anthropic Messages API (https://docs.claude.com/en/api/messages),
unchanged. Proxy to your upstream and stream the response back. Enforce your
model allowlist here, returning 400 invalid_request_error for a denied
model. Don't buffer SSE on the stream: true path. The client always sets
Content-Length, so you may reject chunked-without-CL (411) and cap body
size (413). The client doesn't assume server-side tools are available. The
client also sends x-app and x-stainless-* headers — pass them through or
drop them, but don't reject the request because of them.
Managed settings — optional
GET /managed/settings (bearer)
The authenticated user's Claude Code managed-settings.json; see
https://code.claude.com/docs/en/settings for the key reference. The client
polls about once an hour; support ETag/If-None-Match -> 304 to keep
that cheap. Return 404 for "no managed policy"; 200 {} means "this user
has an empty policy" — they're not the same. This is the endpoint most
likely to change.
Models — optional
GET /v1/models (bearer)
Anthropic models-list shape: {"data":[{"id","display_name"},...]}. Use
Anthropic-style IDs (claude-{family}-{major}-{minor}) — the client's
model-family logic keys on that shape. The client only calls this when
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY is set on the client, which you
can push via the env block in /managed/settings. Return 404 to fall
back to the client's built-in list.
Telemetry — optional
POST /v1/metrics, /v1/logs, /v1/traces (bearer)
OTLP/HTTP (protobuf or JSON). When connected to a gateway the client sends
telemetry here and ignores OTEL_EXPORTER_OTLP_* env vars. Return 200
whether you forward or discard — 404 makes the client's exporter log an
error on every flush.
Errors
OAuth endpoints use {"error":"...","error_description":"..."}
(RFC 6749/8628). Bearer-authenticated endpoints use the Anthropic envelope so
the SDK surfaces the message to the user:
{"type":"error","error":{"type":"authentication_error","message":"..."}}
| HTTP | error.type | Use for |
|---|---|---|
| 400 | invalid_request_error |
Denied model, malformed body, policy violation |
| 401 | authentication_error |
Missing/expired/invalid bearer; client prompts re-login |
| 403 | permission_error |
Authenticated but not allowed |
| 413 | request_too_large |
Body over your cap |
| 429 | rate_limit_error |
Throttling; include Retry-After |
| 501 | not_supported |
Endpoint not available on this backend |
| 529 | overloaded_error |
Upstream at capacity; client backs off and retries |
| 5xx | api_error |
Anything else |
Bearer token
Your access_token is opaque to the client — it stores it, sends it, and
refreshes it before expires_in, but never inspects the payload. Encode the
user's identity and groups in the token (or in server-side state keyed by it)
so you can apply per-user RBAC at /v1/messages and per-group policy at
/managed/settings. The same token must work across every
bearer-authenticated endpoint.
TLS
https:// is required; http:// is accepted only for loopback during
development. The client pins the SHA-256 fingerprint of your TLS leaf
certificate per-hostname after the user confirms it on first connect, and
re-prompts on mismatch — rotating your certificate costs every user one
confirmation prompt.
Client guarantees
- OAuth endpoint paths come from your discovery document; the client never
hard-codes
/oauth/token. - Fixed-path endpoints are resolved against
{base}, never a redirect. - Every request body carries
Content-Length. - The OTLP exporter is locked to
{base}/v1/{signal}regardless of the user's environment. 404from/v1/modelsor/managed/settingsis a clean "not implemented", with no retry storm.
Proxying to Bedrock, Vertex, or Foundry
Proxying to api.anthropic.com is pass-through. Proxying to a cloud
provider's Claude endpoint needs translation:
- Model IDs. The client sends Anthropic-style IDs like
claude-sonnet-4-5; translate to the upstream's form (Bedrock model ID or inference-profile ARN; Vertex@-versioned ID), or advertise upstream-native IDs from/v1/models. anthropic-beta. Bedrock rejects some betas in the header; move them into the request body as"anthropic_beta": [...]. Vertex and Foundry accept the header.- Streaming. Bedrock's native stream is AWS binary event-stream, not SSE;
decode and re-emit Anthropic-shaped
text/event-stream. The provider SDKs handle this. count_tokens. Bedrock has no count-tokens API. Return501 not_supported; the client falls back to a Haikumax_tokens:1probe.- Headers. Forward
content-type,accept,accept-encoding,anthropic-version,anthropic-beta,user-agent, andx-stainless-*; strip the client'sAuthorizationand apply the upstream's own credentials. On the response, strip hop-by-hop headers (content-encoding,content-length,transfer-encoding,connection). - Errors. Upstream error messages can carry your cloud account
IDs/ARNs/project IDs — log them for the operator, return a generic
message, but keep
error.typeso the client's retry logic still works.
References
RFC 6749 (OAuth 2.0), RFC 8414 (AS metadata), RFC 8628 (device grant), Anthropic Messages API, Claude Code settings reference, OTLP spec.