API Reference
REST + SSE API for the dollama network. Base URL: https://api.dollama.net
REST + SSE API for the dollama network. Base URL: https://api.dollama.net
This page is the reference — every endpoint, parameter, and event. For the concept doc (how priority, privacy, and routing actually work), see Docs.
This page covers the routes you'll call day to day. For the full wire-level contract, see the engineering references in the repo: Relay HTTP API reference (the raw wire shape — auth internals, error shapes, for a non-Go client or a curl-level debugging session) and Building a new client from scratch (the reverse content-tunnel walkthrough for writing a minimal client in any language).
All authenticated endpoints require a Bearer token in the Authorization header. Tokens are a bare 64-character hex string — no prefix.
Authorization: Bearer your_api_key_here
Get a key automatically by running dollama private (auto-provisions on first run) or dollama login for GitHub-linked auth. You can also call POST /v1/auth/register or use the POST /v1/auth/device GitHub Device Auth flow directly.
sess_ prefix) are single-use and expire after 5 minutesEvery token below is minted server-side by the relay — a client never constructs one itself, only receives and presents it. The prefix lets log readers tell token classes apart at a glance; it is not a security mechanism.
| Prefix | Token | Format | Minted by | Lifetime |
|---|---|---|---|---|
| (none) | User API key | 64 hex chars, no prefix | POST /v1/auth/register, or dollama login/dollama private | Long-lived — the relay stores only sha256(key) |
sess_ | Single-use session token | sess_ + hex | POST /v1/auth/session | 1 hour, Redis-only, consumed atomically on first use |
ct_ | CLI tunnel id | ct_ + 32 hex chars | The relay, on a successful GET /ws/cli handshake — see Content Tunnel | Lifetime of that WebSocket connection |
tt_ | Per-request tunnel bearer | tt_ + 64 hex chars | The relay, at dispatch time — bound to (worker_id, session_id, request_id, cli_tunnel_id) | Short-lived; revoked on completion, failure, or a rate-limit trip |
dol_inv_ | Group invite | dol_inv_ + random suffix | The group-invite endpoint, by a group owner | 7 days default, 30 days max |
You only ever present the bare API key and (optionally) a single-use session token — both go in the Authorization: Bearer header, including on the /ws/cli handshake itself. ct_ and tt_ you only receive and validate: ct_ arrives in the tunnel handshake's ack frame, and tt_ arrives attached to each content_request frame your tunnel handler gets — see Content Tunnel below for the full mechanism.
A naming collision worth knowing about: the single-use auth session token above (sess_, presented in an Authorization header) and the unrelated conversation session id that goes on the wire envelope (RequestEnvelope.session_id, also sess_-prefixed) are two different concepts minted by different code for different purposes. A sess_... value in an Authorization header is never interchangeable with a sess_... value in a request body's session_id field.
Send a prompt to the network and receive a streaming SSE response. The request is routed to an available node based on priority tier.
Requires a content tunnel. The relay is content-free — it rejects any request without an X-Dollama-CLI-Tunnel-ID header, and rejects large content inlined directly in the payload. A raw client must hold an open GET /ws/cli tunnel and serve content_request frames back to the relay; see the minimal-client guide for the full mechanism, or use dollama gateway if you don't want to implement the tunnel yourself.
| Field | Type | Description |
|---|---|---|
| protocol_version | integer | Wire-format version — currently 3 |
| envelope_type | string | "request" |
| request_id | string | req_ + 32 hex chars, minted by you |
| session_id | string | Conversation key — SHA-256 of the first message, truncated to 16 hex chars |
| model | string | Model identifier, e.g. network:qwen3.5:9b |
| privacy_mode | string | "public" (default) or "private" |
| system_prompt | object | Tiered system-prompt sections: {"sections": [...]} |
| exchanges | array | Ordered conversation turns. Large content (tool results, attachments) must be offloaded as a content_ref and served over the tunnel — not inlined. |
| tools | object | {"catalogue": [...], "active_set": [...]} |
| parameters | object | Provider-neutral model parameters (max_tokens, temperature, …) |
| response_format | object | Optional — see Structured outputs |
The body is a protocol.RequestEnvelope — a structured, provider-neutral shape, not an Anthropic Messages API body wrapped in a field. Full field reference: the wire-format spec §4 and docs/api-relay.md. If you'd rather send an ordinary Anthropic body, point your client at the local CLI proxy instead — it does this translation for you.
| Header | Type | Description |
|---|---|---|
| Content-Type | text/event-stream | SSE stream |
| X-Routing-Tier | string | Routing tier used: own_node, group, priority, or best_effort |
curl --no-buffer -X POST https://api.dollama.net/v1/messages \
-H "Authorization: Bearer your_api_key_here" \
-H "X-Dollama-CLI-Tunnel-ID: ct_your_tunnel_id" \
-H "Content-Type: application/json" \
-d '{
"protocol_version": 3,
"envelope_type": "request",
"request_id": "req_0123456789abcdef0123456789abcdef",
"session_id": "a1b2c3d4e5f67890",
"model": "network:qwen3.5:9b",
"privacy_mode": "public",
"system_prompt": { "sections": [] },
"exchanges": [
{
"exchange_id": "ex1",
"sequence": 1,
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": "Explain monads in one sentence." }
]
}
]
}
],
"tools": { "catalogue": [], "active_set": [] },
"parameters": { "max_tokens": 1024 }
}'
import httpx
url = "https://api.dollama.net/v1/messages"
headers = {
"Authorization": "Bearer your_api_key_here",
"X-Dollama-CLI-Tunnel-ID": "ct_your_tunnel_id",
"Content-Type": "application/json",
}
body = {
"protocol_version": 3,
"envelope_type": "request",
"request_id": "req_0123456789abcdef0123456789abcdef",
"session_id": "a1b2c3d4e5f67890",
"model": "network:qwen3.5:9b",
"privacy_mode": "public",
"system_prompt": {"sections": []},
"exchanges": [
{
"exchange_id": "ex1",
"sequence": 1,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Explain monads in one sentence."}
],
}
],
}
],
"tools": {"catalogue": [], "active_set": []},
"parameters": {"max_tokens": 1024},
}
with httpx.stream("POST", url, headers=headers, json=body) as r:
for line in r.iter_lines():
if line.startswith("data: "):
print(line[6:])
const res = await fetch("https://api.dollama.net/v1/messages", {
method: "POST",
headers: {
"Authorization": "Bearer your_api_key_here",
"X-Dollama-CLI-Tunnel-ID": "ct_your_tunnel_id",
"Content-Type": "application/json",
},
body: JSON.stringify({
protocol_version: 3,
envelope_type: "request",
request_id: "req_0123456789abcdef0123456789abcdef",
session_id: "a1b2c3d4e5f67890",
model: "network:qwen3.5:9b",
privacy_mode: "public",
system_prompt: { sections: [] },
exchanges: [
{
exchange_id: "ex1",
sequence: 1,
messages: [
{
role: "user",
content: [
{ type: "text", text: "Explain monads in one sentence." }
],
}
],
}
],
tools: { catalogue: [], active_set: [] },
parameters: { max_tokens: 1024 },
}),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split("\n")) {
if (line.startsWith("data: ")) {
console.log(line.slice(6));
}
}
}
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
func main() {
body, _ := json.Marshal(map[string]any{
"protocol_version": 3,
"envelope_type": "request",
"request_id": "req_0123456789abcdef0123456789abcdef",
"session_id": "a1b2c3d4e5f67890",
"model": "network:qwen3.5:9b",
"privacy_mode": "public",
"system_prompt": map[string]any{"sections": []any{}},
"exchanges": []map[string]any{
{
"exchange_id": "ex1",
"sequence": 1,
"messages": []map[string]any{
{
"role": "user",
"content": []map[string]any{
{"type": "text", "text": "Explain monads in one sentence."},
},
},
},
},
},
"tools": map[string]any{"catalogue": []any{}, "active_set": []any{}},
"parameters": map[string]any{"max_tokens": 1024},
})
req, _ := http.NewRequest("POST", "https://api.dollama.net/v1/messages", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer your_api_key_here")
req.Header.Set("X-Dollama-CLI-Tunnel-ID", "ct_your_tunnel_id")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
fmt.Println(line[6:])
}
}
}
The relay never sees your conversation's plaintext (root CLAUDE.md, "Key Architecture") — it is a matchmaker, not a content store. That means POST /v1/messages alone isn't enough: whichever node the relay picks needs a way to reach back and ask your process for the actual bytes of any offloaded content, since your process is otherwise a normal outbound-only HTTP client. The reverse tunnel is that path. This section documents it in enough depth to implement a compliant client in any language, without reading Go source.
A WebSocket upgrade. Dial wss://api.dollama.net/ws/cli with the same Authorization: Bearer <api-key-or-session-token> header you use on every HTTP route. There is no dedicated content-fetch HTTP endpoint — every content exchange happens as a frame over this one persistent connection, deliberately, so the relay never has (and structurally cannot offer) a path that would let it read your content at rest.
Authorization header.handshake frame (payload shape below) as the first message.handshake frame back whose outer tunnel_id field is your new ct_... CLI tunnel id.POST /v1/messages via X-Dollama-CLI-Tunnel-ID. A request without it is rejected with 400 before any routing happens.binding_announce and close are control-plane (handle internally, never surface to "user" code); content_request is what you actually need to answer.TunnelFrameEvery message in either direction, over the whole lifetime of the connection, is one JSON object shaped like this. The relay routes by (tunnel_id, sequence) and never deserializes payload — it is opaque to the relay by construction, not by convention.
| Field | Type | Notes |
|---|---|---|
| protocol_version | int | Always 3 |
| frame_type | string | One of handshake, binding_announce, content_request, content_response, rate_limit, close |
| tunnel_id | string | On the handshake ack, your ct_... CLI tunnel id. On every other frame after that, this field instead carries the per-request bearer (tt_...) — a close/rate_limit frame revokes one in-flight binding, not the whole CLI tunnel |
| sequence | int | Per-direction monotonic counter. Echo the inbound frame's sequence back on its response so the other side can correlate |
| encrypted | bool | Reserved for a future end-to-end-encryption layer. Always false today — accept true for forward compatibility but don't attempt decryption; there is no E2E handshake yet |
| key_id | string | Reserved companion to encrypted. Empty today |
| payload | object | The inner frame body — one of the five payload shapes below, depending on frame_type |
Your first frame, payload shaped as a TunnelHandshake:
{
"protocol_version": 3,
"frame_type": "handshake",
"sequence": 0,
"payload": {
"endpoint_type": "cli",
"bearer_token": "<your 64-hex API key or sess_... token>",
"user_id": "<your user id, from /v1/whoami or registration>"
}
}
The relay replies with a handshake frame of its own; its outer tunnel_id is the ct_... id to save and advertise on /v1/messages.
Before any worker can ask you for content on a given request, the relay sends you a binding_announce declaring the scope you must check requests against:
{
"protocol_version": 3,
"frame_type": "binding_announce",
"sequence": 12,
"payload": {
"bearer_token": "tt_9f2c...(64 hex)",
"session_id": "sess_1a2b3c4d5e6f7890",
"request_id": "req_a1b2c3d4e5f6...",
"worker_id": "3f1e2d3c-...",
"expires_at_unix_ms": 1751500000000
}
}
Install this in a local map keyed by bearer_token. A tunnel client that has never seen an announce for a bearer must reject any request under it — there is nothing to validate against.
content_requestWhen the matched worker needs a content_ref's bytes, the relay forwards this frame from the worker to you. Note the outer tunnel_id here is the bearer (tt_...), not your ct_... CLI tunnel id:
{
"protocol_version": 3,
"frame_type": "content_request",
"tunnel_id": "tt_9f2c...(64 hex)",
"sequence": 13,
"payload": {
"session_id": "sess_1a2b3c4d5e6f7890",
"request_id": "req_a1b2c3d4e5f6...",
"content_ref": "01J8ZQK3N8V9X4T6Y7W2E5R1B0",
"tier": "full"
}
}
Run the safeguard chain below, then reply with a content_response echoing the same outer tunnel_id and sequence:
{
"protocol_version": 3,
"frame_type": "content_response",
"tunnel_id": "tt_9f2c...(64 hex)",
"sequence": 13,
"payload": {
"content_ref": "01J8ZQK3N8V9X4T6Y7W2E5R1B0",
"tier": "full",
"tier_actual": "full",
"data": "<base64-encoded bytes>",
"hash": "<sha256 hex digest of the raw (pre-base64) bytes>",
"error": ""
}
}
hash is an integrity sidecar, not the primary identifier — content_ref is an opaque ULID, deliberately not a content hash, so two identical tool outputs on different turns don't correlate. On genuine "nothing on disk for this ref" (as opposed to a safeguard rejection), return empty data/hash/tier_actual and an empty error string — never silently drop the frame.
Run these six checks, in this order, before ever touching disk. This is the load-bearing part of a compliant client — skipping or reordering any step is a real security regression, not a style choice.
tunnel_id (the bearer) must resolve to a known, unexpired binding you installed from a binding_announce. Unknown or past expires_at_unix_ms → reject with error: "tunnel_unauthorized".session_id/request_id must match what that binding declared. Any mismatch → tunnel_unauthorized. This is what stops a worker from using one request's bearer to fish for content belonging to a different request.rate_limit frame back to the relay (reason: "request_rate", outer tunnel_id = the offending bearer) — the relay revokes the bearer and docks the worker's reputation — then reject the triggering request with tunnel_unauthorized.content_ref must appear in a ledger entry your own client tagged with this request_id when it originally offloaded that content. A worker cannot request an arbitrary ref from elsewhere in your session history — only refs that were genuinely part of this request's context pass.content_ref against your on-disk store. If you don't hold the exact tier requested, walk the canonical fallback order full → extracted → summary → breadcrumb and set tier_actual to whichever tier you actually served.rate_limit signal (reason: "bytes_per_request") + reject path as step 3.close frameThe relay can revoke a single bearer at any time (claim released, request completed, or a rate-limit trip) by sending a close frame whose outer tunnel_id carries the bearer being revoked — drop that one binding from your map; the persistent CLI tunnel connection itself stays open. Any content request under an already-revoked bearer fails server-side with reason tunnel_revoked.
| Tier | Meaning |
|---|---|
full | Original content, unmodified |
extracted | Schema-extracted slice |
summary | Compressed summary |
breadcrumb | Minimal one-line reference |
Fallback always walks in that order (most to least complete) — a worker asking for full when you only hold a summary gets the summary back with tier_actual: "summary", not an error.
Not a production implementation — it shows the frame shapes and the safeguard order in the smallest form that actually runs, using the ws package. Fill in rateLimited/isRefAllowed/fetchWithFallback against your own session store.
import WebSocket from "ws";
import crypto from "crypto";
const bindings = new Map(); // bearer_token -> binding_announce payload
let cliTunnelId = null; // ct_... — send as X-Dollama-CLI-Tunnel-ID
const ws = new WebSocket("wss://api.dollama.net/ws/cli", {
headers: { Authorization: `Bearer ${apiKey}` },
});
const send = (frame) => ws.send(JSON.stringify(frame));
ws.on("open", () => {
send({
protocol_version: 3,
frame_type: "handshake",
sequence: 0,
payload: { endpoint_type: "cli", bearer_token: apiKey, user_id: userId },
});
});
ws.on("message", (raw) => {
const frame = JSON.parse(raw.toString());
if (frame.frame_type === "handshake") {
cliTunnelId = frame.tunnel_id; // save for X-Dollama-CLI-Tunnel-ID
} else if (frame.frame_type === "binding_announce") {
bindings.set(frame.payload.bearer_token, frame.payload);
} else if (frame.frame_type === "close") {
bindings.delete(frame.tunnel_id); // tunnel_id = the revoked bearer here
} else if (frame.frame_type === "content_request") {
handleContentRequest(frame);
}
});
function handleContentRequest(frame) {
const bearer = frame.tunnel_id;
const binding = bindings.get(bearer);
const req = frame.payload;
const reply = (payload) => send({
protocol_version: 3, frame_type: "content_response",
tunnel_id: bearer, sequence: frame.sequence, payload,
});
const reject = () => reply({
content_ref: req.content_ref, tier: req.tier,
tier_actual: "", data: "", hash: "", error: "tunnel_unauthorized",
});
// 1. binding lookup
if (!binding || binding.expires_at_unix_ms < Date.now()) return reject();
// 2. scope check
if (binding.session_id !== req.session_id || binding.request_id !== req.request_id) return reject();
// 3. rate limit
if (rateLimited(bearer)) {
send({ protocol_version: 3, frame_type: "rate_limit", tunnel_id: bearer,
sequence: frame.sequence, payload: { bearer_token: bearer, reason: "request_rate", request_count: 0, bytes: 0 } });
return reject();
}
// 4. allowlist check
if (!isRefAllowed(req.request_id, req.content_ref)) return reject();
// 5. fetch, walking full -> extracted -> summary -> breadcrumb
const { bytes, tierActual } = fetchWithFallback(req.content_ref, req.tier);
// 6. byte ceiling
if (bytes.length > MAX_BYTES_PER_REQUEST) {
send({ protocol_version: 3, frame_type: "rate_limit", tunnel_id: bearer,
sequence: frame.sequence, payload: { bearer_token: bearer, reason: "bytes_per_request", request_count: 0, bytes: bytes.length } });
return reject();
}
reply({
content_ref: req.content_ref, tier: req.tier, tier_actual: tierActual,
data: bytes.toString("base64"),
hash: crypto.createHash("sha256").update(bytes).digest("hex"),
error: "",
});
}
Returns live network statistics including online nodes, capacity, request totals, and reliability metrics.
{
"status": "ok",
"version": "v0.19.14",
"network_model": "qwen3.5:9b",
"network": {
"online_nodes": 15,
"total_nodes": 15,
"total_capacity": 300,
"used_capacity": 45,
"totalRequests": 5000,
"totalTokens": 2500000,
"contributor_nodes": 15,
"chat_users": 250,
"reliability": {
"total_requests": 5000,
"success_rate": 0.98,
"avg_ttft_ms": 1200.5,
"avg_tps": 25.3
}
}
}
Returns your current token balance and active request count. Balance determines your priority tier.
{
"tokens_served": 100000,
"tokens_self_served": 50000,
"tokens_consumed": 75000,
"balance": 75000,
"active_requests": 2
}
| Field | Type | Description |
|---|---|---|
| tokens_served | int | Tokens earned by running nodes |
| tokens_self_served | int | Tokens earned from own-node usage (unmetered) |
| tokens_consumed | int | Tokens spent making requests |
| balance | int | Net balance (served + self_served - consumed) |
| active_requests | int | Currently in-flight requests |
Cancel an in-flight inference request. The node is notified and the request transitions to a terminal state.
{
"request_id": "req_..."
}
{
"status": "ok"
}
| Status | Type | Description |
|---|---|---|
| 200 | OK | Cancelled successfully or already in terminal state |
| 400 | Bad Request | Missing request_id |
| 403 | Forbidden | Request belongs to a different user |
Constrain a reply to a JSON Schema so you get a parseable object instead of prose you have to scrape — the equivalent of OpenAI structured outputs with Pydantic.
This is enforced during decoding, not validated afterwards. The serving node hands your schema to Ollama, which compiles it to a decoding grammar, so a conforming reply is the only reachable output. Nothing re-prompts, retries, or repairs malformed JSON. The relay never inspects the schema — it stays content-free and passes the bytes through unchanged.
| Field | Type | Description |
|---|---|---|
| response_format.type | string | json_schema, or json_object for schemaless JSON |
| response_format.name | string | Optional label for the schema. Diagnostics only — nothing routes on it |
| response_format.schema | object | A JSON Schema, passed to the serving node verbatim. Required when type is json_schema |
| response_format.strict | boolean | Optional; mirrors OpenAI's flag. Informational — grammar-constrained decoding is always strict |
response_format is a top-level field on the request envelope, alongside model — not nested inside parameters. That placement is deliberate: it lets the field survive verbatim through a relay running an older protocol build, rather than being silently dropped and returning unconstrained prose with no error. See the wire-format spec §4.6.
curl --no-buffer -X POST https://api.dollama.net/v1/messages \
-H "Authorization: Bearer your_api_key_here" \
-H "X-Dollama-CLI-Tunnel-ID: ct_your_tunnel_id" \
-H "Content-Type: application/json" \
-d '{
"model": "network:qwen3.5:9b",
"response_format": {
"type": "json_schema",
"name": "Person",
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" },
"job": { "type": "string" }
},
"required": ["name", "age", "job"],
"additionalProperties": false
}
}
}'
The JSON arrives as ordinary text_delta events — there is no separate structured-output SSE event. Concatenate the text and parse it.
If you're pointing an existing tool at the CLI proxy rather than implementing the relay wire protocol, the same feature is available on three dialects — Anthropic output_config.format on /v1/messages, OpenAI response_format on /v1/chat/completions, and OpenAI text.format on /v1/responses. The Chat Completions endpoint is what the OpenAI SDK's chat.completions.parse(response_format=YourPydanticModel) helper calls, and it is the one endpoint that answers non-streaming. See the local-proxy wire API.
Tools are dropped when a schema is set. A decoding grammar forces every sampled token to advance the schema, so the model physically cannot emit tool-call syntax. Sending both does not raise an error — the model would simply never call a tool — so a structured request has its tool catalogue stripped. Structured output and tool use are separate turns, not one.
Thinking stays on and shares the output budget. Ollama ignores the schema if thinking is explicitly disabled, so it is left enabled for structured requests. Thinking tokens are not budgeted separately from output, so a prompt that fights its schema — asking for narrative prose while imposing a rigid shape — can consume the whole token budget thinking and return empty content. Schema-shaped prompts (extraction, classification) are cheap: roughly 30 output tokens and about 7 seconds on qwen3.5:9b for the example above. An empty reply usually means the prompt and the schema are asking for different things.
The /v1/messages endpoint streams Anthropic-compatible SSE events. Events are pre-formatted by the node and passed through the relay.
event: message_start data: {"type":"message_start","message":{"id":"msg_...","type":"message","role":"assistant","content":[],"model":"qwen3.5:9b","stop_reason":null,"usage":{"input_tokens":25,"output_tokens":0}}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"A monad"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" is a design pattern"}} event: content_block_stop data: {"type":"content_block_stop","index":0} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":42}} event: message_stop data: {"type":"message_stop"}
| Event | When | Key fields |
|---|---|---|
| message_start | First event in the stream | message.id, message.model, usage.input_tokens |
| content_block_start | New content block begins | index, content_block.type (text or tool_use) |
| content_block_delta | Each token/chunk | delta.text (text) or delta.partial_json (tool use) |
| content_block_stop | Content block complete | index |
| message_delta | Message metadata update | delta.stop_reason, usage.output_tokens |
| message_stop | Stream complete | (none) |
| error | Inference error | error.type, error.message |
Errors split into two classes, depending on whether they happen before or after the SSE stream has started. Getting this distinction right matters for retry logic: a connection-level error means the request was never admitted, while a mid-stream error means the relay already returned 200 and you have to look inside the stream to find out it failed.
A real non-2xx HTTP status with a plain-JSON or plain-text body. Rate-limit and overload responses (429/503) include a Retry-After header.
| Status | Class | Reason | When |
|---|---|---|---|
| 400 | permanent-ish (fix the request) | Bad Request | Missing X-Dollama-CLI-Tunnel-ID; a fat envelope (tunnel advertised but content inlined anyway); malformed JSON; missing model field; unsupported/non-routable model |
| 401 | permanent | Unauthorized | Missing or invalid API key / session token |
| 403 | permanent | Forbidden | Account banned or request belongs to another user |
| 408 | transient | Request Timeout | Admission/queue timeout — no node accepted the request within the assignment deadline |
| 413 | permanent | Payload Too Large | Request body exceeds the 10 MB envelope limit |
| 429 | transient | Too Many Requests | Rate limit exceeded, or per-user concurrency cap hit (Retry-After set) |
| 503 | transient | Service Unavailable | Global queue full, or admission control itself degraded (e.g. Redis unavailable) — Retry-After set |
Retry guidance: don't retry permanent/permanent-ish classes as-posed — fix the credential or the request shape first. Retry transient classes with backoff, honoring a Retry-After header when present rather than inventing your own schedule.
Once the relay writes the initial 200 OK and opens the SSE stream, it can no longer signal failure with a status code — every failure past that point arrives as an error-typed event inside the stream itself, and the stream still terminates normally (message_end + done/message_stop) so a client can always tell a failed stream is structurally complete, not just cut off.
If you're using the CLI proxy's Anthropic-translated SSE (the shape shown in SSE Events above), this arrives as an Anthropic-shaped error event. If you're talking to the relay directly — using dollama.net/client or your own implementation of the content tunnel — it arrives as the relay's own raw frame shape instead:
{
"protocol_version": 3,
"frame_type": "error",
"request_id": "req_a1b2c3d4e5f6...",
"payload": "{\"type\":\"timeout_error\",\"message\":\"...\"}",
"is_final": true
}
payload is itself a JSON string (not a nested object) whose type field is the specific error. Values in the wild include api_error, timeout_error, overloaded_error, and occasional more specific ad hoc types set by a particular failure path (e.g. request_too_large). The frame taxonomy: an error frame is always followed by message_end then done, mirroring the same permanent/transient split as the connection-level table above — a client can apply the same retry guidance once it has parsed the inner type.
When a limit is hit, the response includes a Retry-After header with the number of seconds to wait.
| Timeout | Value | Description |
|---|---|---|
| Stream timeout | 90s | Maximum total time for a streaming response |
| Idle timeout | 15s | Maximum time without receiving a token before error |
| Assignment timeout | 5s | Maximum time to assign the request to a node |