Back to dollama.net

API Reference

REST + SSE API for the dollama network. Base URL: https://api.dollama.net

Looking for the "why," not the "what"?

This page is the reference — every endpoint, parameter, and event. For the concept doc (how priority, privacy, and routing actually work), see Docs.

Building your own client or SDK?

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).

Bearer token auth

All authenticated endpoints require a Bearer token in the Authorization header. Tokens are a bare 64-character hex string — no prefix.

Authorization header
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.

How it works
  • The relay stores a SHA-256 hash of your key — the plaintext is never persisted
  • Auth lookups are cached in Redis for 5 minutes
  • Session tokens (sess_ prefix) are single-use and expire after 5 minutes
The full token table

Every 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.

PrefixTokenFormatMinted byLifetime
(none)User API key64 hex chars, no prefixPOST /v1/auth/register, or dollama login/dollama privateLong-lived — the relay stores only sha256(key)
sess_Single-use session tokensess_ + hexPOST /v1/auth/session1 hour, Redis-only, consumed atomically on first use
ct_CLI tunnel idct_ + 32 hex charsThe relay, on a successful GET /ws/cli handshake — see Content TunnelLifetime of that WebSocket connection
tt_Per-request tunnel bearertt_ + 64 hex charsThe 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 invitedol_inv_ + random suffixThe group-invite endpoint, by a group owner7 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 an inference request

POST /v1/messages Authenticated

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.

Request body
FieldTypeDescription
protocol_versionintegerWire-format version — currently 3
envelope_typestring"request"
request_idstringreq_ + 32 hex chars, minted by you
session_idstringConversation key — SHA-256 of the first message, truncated to 16 hex chars
modelstringModel identifier, e.g. network:qwen3.5:9b
privacy_modestring"public" (default) or "private"
system_promptobjectTiered system-prompt sections: {"sections": [...]}
exchangesarrayOrdered conversation turns. Large content (tool results, attachments) must be offloaded as a content_ref and served over the tunnel — not inlined.
toolsobject{"catalogue": [...], "active_set": [...]}
parametersobjectProvider-neutral model parameters (max_tokens, temperature, …)
response_formatobjectOptional — 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.

Response headers
HeaderTypeDescription
Content-Typetext/event-streamSSE stream
X-Routing-TierstringRouting tier used: own_node, group, priority, or best_effort
Examples
curl
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 }
  }'
Python (httpx)
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:])
Node.js (fetch)
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));
    }
  }
}
Go
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 reverse content tunnel

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.

GET /ws/cli Authenticated

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.

Handshake sequence
  1. Dial the WebSocket with your bearer in the Authorization header.
  2. Send a handshake frame (payload shape below) as the first message.
  3. Read the relay's ack — a handshake frame back whose outer tunnel_id field is your new ct_... CLI tunnel id.
  4. Advertise that id on every subsequent POST /v1/messages via X-Dollama-CLI-Tunnel-ID. A request without it is rejected with 400 before any routing happens.
  5. Keep reading frames from the same connection for the life of the tunnel: binding_announce and close are control-plane (handle internally, never surface to "user" code); content_request is what you actually need to answer.
  6. The connection idle-closes after ~10 minutes of no traffic and zero in-flight requests. Reopen transparently on the next request — you don't need to hand-manage reconnects, just retry the dial with exponential backoff (a sane default: 250 ms → 30 s cap) on a dropped connection.
The outer envelope — TunnelFrame

Every 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.

FieldTypeNotes
protocol_versionintAlways 3
frame_typestringOne of handshake, binding_announce, content_request, content_response, rate_limit, close
tunnel_idstringOn 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
sequenceintPer-direction monotonic counter. Echo the inbound frame's sequence back on its response so the other side can correlate
encryptedboolReserved 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_idstringReserved companion to encrypted. Empty today
payloadobjectThe inner frame body — one of the five payload shapes below, depending on frame_type
Step 1 — the handshake

Your first frame, payload shaped as a TunnelHandshake:

You → relay
{
  "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.

Step 2 — a worker's request arrives

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:

relay → you
{
  "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.

Step 3 — answer the content_request

When 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:

relay → you (from the worker)
{
  "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:

you → relay (to the worker)
{
  "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.

The safeguard chain

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.

  1. Binding lookup. The frame's outer 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".
  2. Scope check. The inner payload's 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.
  3. Rate limit. Track requests/sec per bearer. On trip, send a 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.
  4. Allowlist check. 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.
  5. Fetch, with tier fallback. Resolve 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.
  6. Byte ceiling. Apply an output-size cap to the bytes you're about to serve (after the fetch, on the actual response size). Exceeding it triggers the same rate_limit signal (reason: "bytes_per_request") + reject path as step 3.
Revocation — the close frame

The 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.

Tiers and fallback order
TierMeaning
fullOriginal content, unmodified
extractedSchema-extracted slice
summaryCompressed summary
breadcrumbMinimal 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.

Illustrative client (Node.js)

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.

Node.js (ws)
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: "",
  });
}

Network status

GET /v1/status Public

Returns live network statistics including online nodes, capacity, request totals, and reliability metrics.

Response
200 OK
{
  "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
    }
  }
}

Token balance

GET /v1/ledger/balance Authenticated

Returns your current token balance and active request count. Balance determines your priority tier.

Response
200 OK
{
  "tokens_served": 100000,
  "tokens_self_served": 50000,
  "tokens_consumed": 75000,
  "balance": 75000,
  "active_requests": 2
}
FieldTypeDescription
tokens_servedintTokens earned by running nodes
tokens_self_servedintTokens earned from own-node usage (unmetered)
tokens_consumedintTokens spent making requests
balanceintNet balance (served + self_served - consumed)
active_requestsintCurrently in-flight requests

Cancel a request

POST /v1/cancel Authenticated

Cancel an in-flight inference request. The node is notified and the request transitions to a terminal state.

Request body
JSON
{
  "request_id": "req_..."
}
Response
200 OK
{
  "status": "ok"
}
StatusTypeDescription
200OKCancelled successfully or already in terminal state
400Bad RequestMissing request_id
403ForbiddenRequest belongs to a different user

Structured outputs

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.

Envelope field
FieldTypeDescription
response_format.typestringjson_schema, or json_object for schemaless JSON
response_format.namestringOptional label for the schema. Diagnostics only — nothing routes on it
response_format.schemaobjectA JSON Schema, passed to the serving node verbatim. Required when type is json_schema
response_format.strictbooleanOptional; 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.

Example
curl
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.

Using the local proxy instead

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.

Two constraints worth knowing

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.

SSE event format

The /v1/messages endpoint streams Anthropic-compatible SSE events. Events are pre-formatted by the node and passed through the relay.

Full stream example
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 reference
EventWhenKey fields
message_startFirst event in the streammessage.id, message.model, usage.input_tokens
content_block_startNew content block beginsindex, content_block.type (text or tool_use)
content_block_deltaEach token/chunkdelta.text (text) or delta.partial_json (tool use)
content_block_stopContent block completeindex
message_deltaMessage metadata updatedelta.stop_reason, usage.output_tokens
message_stopStream complete(none)
errorInference errorerror.type, error.message

Error responses

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.

Connection-level (before the stream starts)

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.

StatusClassReasonWhen
400permanent-ish (fix the request)Bad RequestMissing X-Dollama-CLI-Tunnel-ID; a fat envelope (tunnel advertised but content inlined anyway); malformed JSON; missing model field; unsupported/non-routable model
401permanentUnauthorizedMissing or invalid API key / session token
403permanentForbiddenAccount banned or request belongs to another user
408transientRequest TimeoutAdmission/queue timeout — no node accepted the request within the assignment deadline
413permanentPayload Too LargeRequest body exceeds the 10 MB envelope limit
429transientToo Many RequestsRate limit exceeded, or per-user concurrency cap hit (Retry-After set)
503transientService UnavailableGlobal 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.

Mid-stream (after the stream has started)

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:

Raw relay frame — event: error
{
  "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.

Rate limits

30
Requests / minute
Per user, fixed window
10
Concurrent requests
Per user, active at once
10 MB
Max payload
Supports base64 images

When a limit is hit, the response includes a Retry-After header with the number of seconds to wait.

Timeouts
TimeoutValueDescription
Stream timeout90sMaximum total time for a streaming response
Idle timeout15sMaximum time without receiving a token before error
Assignment timeout5sMaximum time to assign the request to a node