API reference
Every endpoint below is on https://api.tanzanite.ai. All of /v1 is
versioned: changes are additive only, and anything breaking arrives as /v2.
See the two API surfaces.
/v1 serves two kinds of caller with two kinds of tanzanite_… key:
- A host app — a product that signs its users in with Tanzanite (Connect) and runs Tanzanite sessions for them. Its key is the user’s connection to that app, issued by the Connect exchange, and it reaches the host-app routes: connection, projects, sessions, tickets and the spend primitives, gated by scopes.
- An agent key — the account holder’s own key for their own software
(ADR-0117). It reaches the object plane and the hosted gadget backends, gated
by the
resource.verbpermissions below.
Authentication
Section titled “Authentication”| Credential | Header | Reaches |
|---|---|---|
| Session | Authorization: Bearer <Firebase ID token> |
/api/* (the app) and /v1 |
| Agent key | Authorization: Bearer tanzanite_agent_… or X-Tanzanite-Key: tanzanite_agent_… |
/v1 agent-key routes only |
A key calling /api/* gets 403 {"error":"not_available_to_api_keys"} — key
minting, billing, 2FA, account settings and secrets are not part of the public
contract, whatever the key was granted.
Keys are their own records in agentKeys/{id}, managed at /api/agent-keys
(list, mint with step-up, revoke). They are not host-app connections and never
appear in one.
Permissions
Section titled “Permissions”A key’s grants are a list of resource.verb chosen when it is minted:
{ "permissions": ["gadgets.read", "stores.read", "stores.write", "vaults.read"] }| Resource | Verbs | Covers |
|---|---|---|
gadgets |
read, write, delete, deploy | Gadget documents, gadget types, per-gadget rate limits |
stores |
read, write, delete | Gadget stores and sync floors |
vaults |
read, write, delete | The account vault’s cloud copy |
artifacts |
read, write, delete | Artifact files and metadata |
settings |
read, write | Account settings (server-managed keys are refused) |
workstations |
read, deploy | Workstation state (read) and deploy/recall (deploy) — never a running workstation |
permissions |
read | The account’s permission policy (read-only) |
spend |
read, write | Hosted gadget backends (/v1/llm, /v1/image, /v1/video) |
A missing grant is denied with 403 {"error":"insufficient_permission","required":"store.write"}.
resource.* and * are accepted. A key cannot widen its own grants, write the
permission policy, mint keys, or answer a prompt.
Headers
Section titled “Headers”| Header | Direction | Notes |
|---|---|---|
Authorization: Bearer tanzanite_… |
Request | A host app’s connection key (tanzanite_ + hex) or an agent key (tanzanite_agent_…). The two never reach each other’s routes. Backend only. |
Authorization: Bearer <ID token> |
Request | A signed-in user’s session instead of a key. |
X-Tanzanite-Key: tanzanite_… |
Request | Equivalent alternative to the key header. |
X-Tanzanite-Embed-Ticket |
Request | A session-scoped ticket, for browsers. Session routes only. |
Idempotency-Key |
Request | Optional on the spend endpoints. Up to 200 characters, valid 24 hours. |
Content-Type: application/json |
Request | Required on every body-carrying call. |
Route index — object plane
Section titled “Route index — object plane”| Method | Path | Permission |
|---|---|---|
GET |
/v1/gadgets |
gadgets.read |
POST PATCH DELETE |
/v1/gadgets[/:id] |
gadgets.write / gadgets.delete |
GET PUT |
/v1/gadgets/:id/limits |
gadgets.read / gadgets.write |
GET |
/v1/stores/… |
stores.read |
PUT DELETE |
/v1/stores/… |
stores.write / stores.delete |
GET |
/v1/vaults/… |
vaults.read |
PUT DELETE |
/v1/vaults/… |
vaults.write / vaults.delete |
GET |
/v1/artifacts/… |
artifacts.read |
POST DELETE |
/v1/artifacts/… |
artifacts.write / artifacts.delete |
GET PATCH |
/v1/settings |
settings.read / settings.write |
GET |
/v1/permissions/policy |
permissions.read |
GET |
/v1/workstations/:id/state |
workstations.read |
POST |
/v1/workstations/:id/deploy |
workstations.deploy |
An agent key reads and writes objects; it does not run a workstation — no presence, no live gadgets, no lease.
Size limits
Section titled “Size limits”| Object | Limit |
|---|---|
| Gadget document | 1 MB |
| Store document | 4 MB |
| Vault file | streamed; per-file cap enforced by the vault lane |
| Artifact | capped by the artifact lane |
Route index — host apps and spend primitives
Section titled “Route index — host apps and spend primitives”| Method | Path | Scope |
|---|---|---|
GET |
/v1/connection |
none |
DELETE |
/v1/connection |
none |
POST |
/v1/stream-ticket |
none |
POST |
/v1/sessions/:id/embed-ticket |
none |
POST |
/v1/sessions |
agent.sessions |
GET |
/v1/sessions/:id |
agent.sessions |
POST |
/v1/sessions/:id/signal |
agent.sessions |
POST |
/v1/sessions/:id/human-input/:inputId |
agent.sessions |
GET |
/v1/sessions/:id/messages |
agent.sessions |
GET |
/v1/sessions/:id/subagents/:childId/messages |
agent.sessions |
POST |
/v1/projects |
projects |
GET |
/v1/projects |
projects |
GET |
/v1/projects/:id |
projects |
PATCH |
/v1/projects/:id |
projects |
GET |
/v1/projects/:id/sessions |
projects |
POST |
/v1/media/generate |
media.generate |
GET |
/v1/media/jobs/:jobId |
media.generate |
POST |
/v1/media/vision |
media.vision |
POST |
/v1/media/transcribe |
media.vision |
POST |
/v1/completions |
completions |
POST |
/v1/decisions |
decisions |
GET |
/v1/estimates |
none |
GET |
/v1/usage |
any spend scope (read) |
GET |
/v1/models |
any spend scope (read) |
GET |
/v1/wallet |
any spend scope |
Connection
Section titled “Connection”GET /v1/connection
Section titled “GET /v1/connection”Auth tenant key · Scope none
Who this key is. Call it at start-up to confirm a stored key still authenticates
and still holds the scopes your feature needs, rather than discovering both from
a 403 in front of a user.
{ "id": "conn_9f3…", "hostAppId": "acme-studio", "subject": "Qm9Ft4c…", "name": "Acme Studio", "status": "active", "scopes": ["agent.sessions", "projects", "media.generate"], "hostAppVersion": 3, "toolCount": 7, "baseUrl": "https://api.acme.example/v2"}status is active, suspended or revoked; only active authenticates.
hostAppVersion is the definition version the user consented at — compare it
against what a capability needs before you use one that is newer. subject is
your app’s stable id for the key’s user, the same one the Connect exchange
returned (Connect); a personal access token has none
(null).
DELETE /v1/connection
Section titled “DELETE /v1/connection”Auth tenant key · Scope none
Revoke this connection.
{ "id": "conn_9f3…", "status": "revoked" }Immediate and final. The key stops authenticating, running sessions fail closed at their next round, and there is no un-revoke — the user reconnects through Connect, which mints a new connection.
Call this when a user disconnects your app on your side. You do not need to call it before a reconnect: the platform already revokes a user’s previous connection to the same app.
Tickets
Section titled “Tickets”Tickets exist so a browser can stream a session without ever holding the tenant key. Both are short-lived and session-scoped: a ticket reaches session routes only, and never a connection-scoped one. A widget holding a ticket therefore cannot create sessions, cannot list projects, and cannot spend money on its own — your backend does all of that, then hands the browser a ticket for the one session it should see.
POST /v1/stream-ticket
Section titled “POST /v1/stream-ticket”Auth tenant key · Scope none
Mint a ticket for the WebSocket stream.
POST /v1/sessions/:id/embed-ticket
Section titled “POST /v1/sessions/:id/embed-ticket”Auth tenant key · Scope none
Mint a multi-use embed ticket bound to one session. This is what an embedded
widget uses: pass it, with the sessionId and the API base URL, to a ticket-only
SDK client.
Send it as X-Tanzanite-Embed-Ticket on session routes. Tickets expire; mint a
fresh one when the widget reconnects rather than caching one indefinitely.
Streaming
Section titled “Streaming”Session events arrive over a WebSocket at wss://api.tanzanite.ai/v1/stream,
authenticated with a stream ticket or an embed ticket. The
SDK handles the connection, the reconnect backoff
and the resync marker for you; the event shapes are listed there.
Sessions
Section titled “Sessions”POST /v1/sessions
Section titled “POST /v1/sessions”Auth tenant key · Scope agent.sessions · Spends
{ "projectId": "proj_7bd…", "goal": "Add three ambush encounters to the crypt level.", "context": { "sceneId": "scene_main" }, "guardrails": { "maxSubagents": 2, "requireToolApproval": false }}| Field | Required | Notes |
|---|---|---|
projectId |
Yes | The project to run in. There is no implicit project. |
goal |
Yes | What the agent should accomplish. |
context |
No | Pinned onto every tool call; the pin wins over model-supplied values. |
guardrails |
No | maxSubagents, requireToolApproval. |
201:
{ "sessionId": "sess_4c1…", "roundId": "round_1", "status": "queued" }Errors
| Status | Body |
|---|---|
400 |
{ "error": "project_required" } |
403 |
{ "error": "insufficient_scope", "scope": "agent.sessions" } |
403 |
{ "error": "reconsent_required", "hostAppVersion": 3, "requiredVersion": 4 } |
402 |
{ "error": "insufficient_credits" } |
402 |
{ "error": "host_app_spend_cap" } |
429 |
{ "error": "too_many_sessions", "limit": 3 } |
GET /v1/sessions/:id
Section titled “GET /v1/sessions/:id”Auth tenant key or embed ticket · Scope agent.sessions
{ "id": "sess_4c1…", "status": "orchestrating", "projectId": "proj_7bd…", "hostAppId": "acme-studio", "goal": "Add three ambush encounters to the crypt level.", "cost": { "totalCost": 0.184, "totalTokens": 41233, "promptTokens": 37110, "completionTokens": 4123, "ownCost": 0.121, "subagentsCost": 0.043, "mediaCost": 0.02 }, "createdAt": "2026-09-10T09:12:04.221Z", "updatedAt": "2026-09-10T09:14:51.008Z", "completedAt": "2026-09-10T09:14:51.008Z", "completionReport": "…", "context": { "usedTokens": 42310, "contextWindow": 200000, "compactAtTokens": 153600, "estimated": false, "compacting": false }}completedAt, completionReport and error are present only when they apply.
context is how full the model’s context window is: null until the first LLM
call has been measured; compactAtTokens is where the agent compacts on its own
(null when compaction is off); estimated is true right after a compaction,
before the next call measures it. The stream’s context frame carries the same
reading as it changes.
cost.totalCost is USD, billed — the whole of what the session cost the
user: its own work, every subagent’s, and the /v1 spend you attributed to its
tool calls (Tanzanite sends your tool endpoint an X-Tanzanite-Tool-Call token;
forward it unchanged on the media, vision, decisions and completions requests you
make while serving that call). Each dollar is counted once, so a host
app that reads only cost.totalCost has the right total. ownCost,
subagentsCost and mediaCost break it down — they sum to totalCost — and
each is absent when it is 0 or unknown (mediaCost is absent for a session
whose host-app spend predates the breakdown; totalCost still includes it).
/v1 spend made without the header belongs to no session and is in none of
them. The token counts are the session’s own agent’s. A running session’s cost
can trail a few seconds behind its spend; a completed one is final. The sessions
list’s cost and the stream’s cost_update / complete totalCost are this
same whole figure. status is one of the
SDK’s SESSION_STATUSES; complete, failed, cancelled and enqueue_error
(the session could not be started) are terminal — treat the list as open and
keep a default branch.
404 if the session does not exist or belongs to another connection’s user.
POST /v1/sessions/:id/signal
Section titled “POST /v1/sessions/:id/signal”Auth tenant key or embed ticket · Scope agent.sessions
{ "type": "redirect", "message": "Two encounters, not three — the level is short." }type |
Extra fields | Effect |
|---|---|---|
pause |
reason |
Halt after the current step. |
resume |
newBudget |
Continue; optionally raise the cost budget. |
cancel |
reason |
Stop for good. |
redirect |
message |
Push a new instruction into the running session. |
compact |
— | Compact the agent’s context now. Running: it compacts after its current step. Idle (complete, failed, cancelled, waiting_human): a compaction starts at once. |
redirect is how you say “not that, do this instead” without cancelling and
paying for a fresh start.
Every signal answers 202 { sessionId, signal, message }. compact adds mode
— "latched" (after the current step) or "started" (with the compaction’s
roundId). A refused compact answers 409 { error, code }:
code |
Meaning |
|---|---|
compaction_running |
A compaction is already in flight. |
nothing_to_compact |
No LLM call has been measured yet. |
compaction_disabled |
Compaction is off for this session. |
not_compactable |
The session’s status allows neither (e.g. paused, waiting_wake). |
POST /v1/sessions/:id/human-input/:inputId
Section titled “POST /v1/sessions/:id/human-input/:inputId”Auth tenant key or embed ticket · Scope agent.sessions
Answer a question the agent asked. inputId comes from the
human_input_needed event.
{ "approved": true }Send choiceId for a selection, freeText for a clarification, approved for a
confirmation, an approval or a tool approval. A session that is never answered
stays waiting rather than timing out into a decision on your behalf.
GET /v1/sessions/:id/messages
Section titled “GET /v1/sessions/:id/messages”Auth tenant key or embed ticket · Scope agent.sessions
Query: limit (rounds per page), after (a previous page’s lastRoundId).
{ "sessionId": "sess_4c1…", "messages": [ { "roundId": "round_3", "role": "assistant", "content": "…", "timestamp": "2026-09-10T09:13:02.114Z" } ], "hasMore": true, "lastRoundId": "round_3"}role is user, assistant, system or tool; toolName and toolCallId
are present on tool messages.
GET /v1/sessions/:id/subagents/:childId/messages
Section titled “GET /v1/sessions/:id/subagents/:childId/messages”Auth tenant key or embed ticket · Scope agent.sessions
A subagent’s transcript, paged exactly like
GET /v1/sessions/:id/messages (same query, same
response, with sessionId set to the subagent). childId is the subagentId
from the subagent_spawned event.
Authorized through the parent: the caller needs the session (its embed ticket is
enough), and childId must be one of that session’s subagents. Anything else is
404 with { "error": "Subagent not found" } — a ticket still cannot reach any
other session.
Projects
Section titled “Projects”POST /v1/projects
Section titled “POST /v1/projects”Auth tenant key · Scope projects
{ "externalId": "level_42", "label": "Level 42 — Crypt", "url": "https://studio.acme.example/levels/42"}| Field | Required | Limit |
|---|---|---|
externalId |
Yes | 200 characters |
label |
Yes | 200 characters |
url |
No | 2048 characters |
Idempotent per connected user × host app × externalId. 201 with
created: true for a new project, 200 with created: false when it already
existed. Both are success.
{ "project": { "id": "proj_7bd…", "name": "Level 42 — Crypt", "externalRef": { "hostAppId": "acme-studio", "externalId": "level_42", "label": "Level 42 — Crypt", "url": "https://studio.acme.example/levels/42" }, "hostAppId": "acme-studio", "createdAt": "2026-09-10T09:12:04.221Z", "updatedAt": "2026-09-10T09:12:04.221Z" }, "created": true}The user is part of the idempotency key on purpose — see why the user is in the key.
GET /v1/projects
Section titled “GET /v1/projects”Auth tenant key · Scope projects
Query: limit.
{ "projects": [ /* ProjectInfo */ ], "limit": 50 }Only this host app’s projects, for this user. The query is scoped server-side and there is no parameter that widens it.
GET /v1/projects/:id
Section titled “GET /v1/projects/:id”Auth tenant key · Scope projects
{ "project": { /* ProjectInfo */ } }A 404 here is the lifecycle signal: the user deleted the project. There is
no webhook — drop your mapping and create a fresh project on next use. See
project lifecycle.
PATCH /v1/projects/:id
Section titled “PATCH /v1/projects/:id”Auth tenant key · Scope projects
{ "label": "Level 42 — Catacombs", "url": "https://studio.acme.example/levels/42"}| Field | Required | Limit |
|---|---|---|
label |
At least one of the two | 200 characters |
url |
At least one of the two | 2048 characters; absolute http(s). null clears it. |
Rename an entity. POST /v1/projects never changes an existing project — on a
repeat it returns the first project’s fields unchanged, which is what makes
it safe to call on every open — so when a user renames the thing on your side,
or your deep links move, this is how the new label or url reaches Tanzanite.
name follows label. externalId is not writable: it is the idempotency
key, and a different id is a different entity. See
renaming an entity.
200:
{ "project": { /* ProjectInfo, with the new label and url */ } }Errors
| Status | Body |
|---|---|
400 |
{ "error": "invalid_request", "message": "…" } — empty patch, a field over its limit, or a url that is not an absolute http(s) URL. |
404 |
{ "error": "Project not found" } — the same answer as GET, for a deleted project and for one this connection cannot reach. |
GET /v1/projects/:id/sessions
Section titled “GET /v1/projects/:id/sessions”Auth tenant key · Scope projects
Query: limit, after (the sessionId of the last row of the previous page).
{ "sessions": [ { "sessionId": "sess_4c1…", "status": "complete", "message": "Add three ambush encounters to the crypt level.", "createdAt": "2026-09-10T09:12:04.221Z", "updatedAt": "2026-09-10T09:14:51.008Z", "completedAt": "2026-09-10T09:14:51.008Z", "cost": 0.184, "hostAppId": "acme-studio" } ], "hasMore": false, "limit": 50}Newest first, 50 per page by default and 200 at most. message is the goal the
session was created with; all three timestamps are ISO 8601, and completedAt
is absent while a session is still running.
POST /v1/media/generate
Section titled “POST /v1/media/generate”Auth tenant key · Scope media.generate · Spends · Optional
Idempotency-Key
{ "mediaType": "image", "prompt": "A moss-covered stone archway at dusk", "model": null, "size": "1024x1024", "aspectRatio": null, "resolution": null, "quality": null, "seed": null, "sourceImage": null, "projectId": "proj_7bd…"}sourceImage is either a URL the platform can fetch, or inline base64 plus
mimeType.
With "mediaType": "speech" the body is { "input" (≤ 4000 chars), "model"?, "voice"?, "format"?: "mp3" | "wav", "speed"?, "instructions"?, "projectId"? } and
the result carries mimeType (audio/mpeg or audio/wav — the model decides),
durationMs, model, voice, chars, costUSD and costSource
(provider-actual — OpenRouter’s record of the call — or catalog), plus
generationId / provider when known, beside url / storagePath — see
Generate speech.
{ "mediaType": "image", "url": "https://storage.example/signed/…", "expiresAt": "2026-09-10T10:12:04.000Z", "storagePath": "media/…/7f3c1e.png", "mimeType": "image/png", "width": 1024, "height": 1024, "model": "…", "costUSD": 0.042, "idempotent": false}Synchronous, with a 90-second server-side ceiling. Set your HTTP client
timeout above it — the default 30 seconds will abort a healthy call. url
expires after 60 minutes; storagePath is the durable handle. Full contract on
the Media page.
Errors
| Status | Body |
|---|---|
400 |
{ "error": "unsupported_media_type" } — video is not served in v1. |
402 |
{ "error": "insufficient_credits" } |
402 |
{ "error": "host_app_spend_cap" } |
403 |
{ "error": "insufficient_scope", "scope": "media.generate" } |
409 |
{ "error": "idempotency_key_reused" } |
409 |
{ "error": "idempotency_in_progress" } — the first request under this key is still running; nothing charged. |
504 |
{ "error": "media_timeout", "ceilingMs": 90000 } — nothing charged. |
mediaType: "model" is the asynchronous exception: it answers 202 with a job
(or 200 with the finished job on an idempotent replay), and the same scope covers
polling it. Request, result and error codes are on the
Media page; 3D-specific errors are
400 invalid_request, 429 too_many_jobs and 503 model_generation_unavailable.
mediaType: "audio" (music and sound effects: kind, prompt, durationSec, loop, bpm,
key, layers, channels, format: "wav") is a job of the same shape, on the same poll; see
Media. Its own errors are 400 invalid_request,
429 too_many_jobs and 503 media_unavailable; a refused prompt is a failed job (prompt_blocked).
mediaType: "motion" (an animation clip on the humanoid skeleton: prompt, duration, loop,
inPlace, quality: draft | standard, name) is a job of the same shape too; its result is one GLB
(a bare skeleton + one animation) and a summary — see Media.
Its own errors are 400 invalid_request, 429 too_many_jobs and 503 motion_generation_unavailable;
a refused prompt is a failed job (content_refused), uncharged.
GET /v1/media/jobs/:jobId
Section titled “GET /v1/media/jobs/:jobId”Auth tenant key · Scope media.generate · Free (reads a job; the spend
happened at POST)
Returns the job the POST accepted — queued, running, succeeded (with
result) or failed (with error) — while it is under 24 hours old. A job that
belongs to another connection is 404 job_not_found, the same as one that does not
exist. Poll no faster than pollAfterMs; the response carries Retry-After while
the job is pending and Cache-Control: no-store.
POST /v1/media/vision
Section titled “POST /v1/media/vision”Auth tenant key · Scope media.vision · Spends
{ "images": [{ "url": "https://storage.example/signed/…" }], "question": "Does the archway read as passable?", "measuredContext": "1024x1024 PNG generated from: a moss-covered stone archway at dusk.", "model": null, "projectId": "proj_7bd…"}images takes 1 to 4 entries. More than four is refused rather than truncated.
{ "text": "…", "model": "…", "costUSD": 0.003, "truncated": false, "imageCount": 1}POST /v1/media/transcribe
Section titled “POST /v1/media/transcribe”Auth tenant key · Scope media.vision · Spends · Optional
Idempotency-Key
Speech-to-text for a short clip (push-to-talk). The answer is the bare words
spoken — no labels, no bracketed events — and "" when nothing was said.
{ "audio": "GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQRChYEC…", "mimeType": "audio/webm;codecs=opus", "language": "en", "prompt": "Brannoc, Eldermoor", "model": null, "projectId": "proj_7bd…"}audio is bare base64 (no data: prefix), at most 2 MB decoded. mimeType is
one of audio/webm, audio/ogg, audio/mp4, audio/wav, audio/mpeg (a
;codecs= parameter and common aliases accepted). language is an optional
BCP-47 hint; prompt (≤ 500 characters) lists names and words to expect and is
read as a spelling hint, never as instructions. A clip that is too big, empty,
not base64 or of an unsupported type is a 400 invalid_request before any
spend.
{ "text": "Brannoc, open the gate.", "language": "en", "durationMs": 2380, "durationEstimated": false, "model": "openrouter/google/gemini-3.5-flash-lite", "costUSD": 0.0011, "idempotent": false}language echoes the hint (null without one). durationEstimated is true
when the container did not state its length and it was estimated from the size.
A replay under the same Idempotency-Key answers idempotent: true and
costUSD: 0. 502 transcription_failed bills what the failed call spent; past
the 90 s ceiling, 504 media_timeout charges nothing. Details:
Media → Transcribe speech.
Completions
Section titled “Completions”POST /v1/completions
Section titled “POST /v1/completions”Auth tenant key · Scope completions · Spends · Optional
Idempotency-Key
One model call, no harness. For the parts of a host app that want a model but not an agent: a rewrite, a classification, a structured extraction. If the work needs tools, a plan, or more than one turn, create a session instead.
{ "messages": [ { "role": "system", "content": "You rewrite level descriptions." }, { "role": "user", "content": "Crypt, dark, three rooms." } ], "model": null, "maxTokens": 512, "temperature": 0.4, "reasoning": "off", "jsonSchema": null, "projectId": "proj_7bd…", "tag": "level-copy", "label": "Level 42 · description rewrite"}reasoning is "off" by default: the model answers without hidden thinking, which
is what a short, awaited reply wants — a reasoning model otherwise spends seconds,
and part of your maxTokens, before the first visible word. Set a level (minimal,
low, medium, high, xhigh, max; snapped to the nearest one the model
offers) to opt in for a call that needs it, and leave room in maxTokens for the
thinking. Anything else is 400 invalid_request. Completions are routed to the
model’s fastest available host, not its cheapest.
role is system, user or assistant. jsonSchema is sent to the provider as
a forced structured output — the difference between parsing the answer and hoping
to — but a model can still answer off-schema, so the response says whether it did:
structuredResult (the parsed output) with schemaValid, or structuredRaw (the
raw text, when it did not parse as JSON) with schemaValid: false. schemaValid
is absent when your schema itself does not compile.
tag and label are optional and name the spend on the user’s Usage tab: tag
is 1–64 letters, digits, _ - : . (level-copy, or one per thing you serve, like
game:abc123); label is one line of at most 120 characters. An invalid one is
400 invalid_request.
{ "content": "…", "model": "…", "finishReason": "stop", "usage": { "promptTokens": 122, "completionTokens": 88 }, "costUSD": 0.0009, "idempotent": false, "structuredResult": { "line": "…" }, "schemaValid": true}Decisions
Section titled “Decisions”POST /v1/decisions
Section titled “POST /v1/decisions”Auth tenant key · Scope decisions · Spends · Optional
Idempotency-Key
Fast, typed answers about your app’s state — a game bot choosing its next move
between ticks, a classifier in a hot path. Each named question is a noul
(yes/no), a choice (one of N) or a score (an ordered rubric), and every
answer carries a probability.
{ "requests": [ { "id": "bot:1", "state": { "me": { "x": 3, "hp": 2 }, "enemies": [{ "x": 7, "hp": 5 }] }, "questions": { "move": { "type": "choice", "instructions": "Best move now?", "criteria": { "chase": "close in", "flee": "get away" } } } } ], "model": null, "projectId": "proj_7bd…", "tag": "bot-live", "label": "Crypt · Hunter · match 3"}| Field | Required | Notes |
|---|---|---|
requests |
Yes | 1 to 16 entries — batch the seats of one moment into one call. |
requests[].id |
No | Echoed on the result. Unique, 1–64 of letters, digits and _:.-; defaults to the index. |
requests[].state |
Yes | A string, object or array; at most 60 000 characters serialized. |
requests[].questions |
Yes | An object of named questions. |
model |
No | An enabled decision model. Omit for the platform default. |
projectId, tag, label |
No | The project to bill under, and the Usage-tab attribution — as on completions. |
{ "model": "…", "results": [ { "id": "bot:1", "ok": true, "answers": { "move": { "type": "choice", "choice": "flee", "probabilities": { "chase": 0.18, "flee": 0.82 }, "confidence": 0.82 } }, "latencyMs": 212, "truncated": false, "backend": "…" } ], "costUSD": 0.0004, "idempotent": false}One result per request, in order. A request that fails is reported in its own
result as { "id", "ok": false, "error", "code"? }; the others still answer, and
the batch is billed once.
Estimates
Section titled “Estimates”GET /v1/estimates
Section titled “GET /v1/estimates”Auth tenant key · Scope none
What one call would cost the connected user, billed — for a price you show before a user says yes. Spends nothing.
Query: kind=decision with stateChars (default 4 000), kind=completion
with promptTokens (default 1 000) and completionTokens (default 300),
kind=speech with chars (default 1 000; a mediaType: "speech" call), or
kind=vision with promptTokens (default 2 000, the images included) and
completionTokens (default 700) — priced on the model POST /v1/media/vision
would use for the same model (the platform’s vision model when none is named),
or kind=transcribe with seconds (default 15; a POST /v1/media/transcribe
clip, priced from its audio tokens per second on the model that call would use
and answered as { "kind": "transcribe", "model", "perCallUSD", "basis": { "seconds" } }),
or kind=audio with layers (default 1; a mediaType: "audio" job, every layer at the music
model’s catalog price plus the prompt screen — what the job holds — answered as
{ "kind": "audio", "model", "perCallUSD", "basis": { "generations", "perGenerationUSD" } }),
or kind=motion with quality (draft | standard, default standard; a mediaType: "motion" job:
one keyframe-writer call of ~6k prompt + ~4k completion tokens, answered as
{ "kind": "motion", "model", "perCallUSD", "maxUSD", "basis": { "quality" } } — maxUSD is the hold; the quality picks the model);
each with an optional model.
{ "kind": "decision", "model": "…", "perCallUSD": 0.0003, "basis": { "stateChars": 4000 }, "typicalLatencyMs": 180}typicalLatencyMs is present for a decision model with a measured latency. A
speech estimate adds "pricing" (per_character, per_second, per_token, …)
and basis: { chars, estimatedSeconds }; per-second and per-token models are
estimated at a typical narration pace (≈ 15 characters a second), and the actual
charge uses the measured audio. Any other kind, or a model that is not
enabled, is 400 invalid_request.
GET /v1/wallet
Section titled “GET /v1/wallet”Auth tenant key · Scope any spend scope
What this connection can spend of the user’s credits right now — for a credits check before you start something billed. Spends nothing.
{ "availableUSD": 12.5, "connectionDailyRemainingUSD": 3, "accountDailyRemainingUSD": null, "accountMonthlyRemainingUSD": 79.5, "autoReload": { "enabled": true, "thresholdUSD": 1, "amountUSD": 10 }, "spendableUSD": 3}availableUSD is credits − reserved − spent (it can be negative).
connectionDailyRemainingUSD is the daily cap the user set on this connection,
less today’s spend and in-flight holds; the account fields are the user’s own
daily and monthly caps, likewise. null means no such cap. spendableUSD is the
least of those that apply; with autoReload on, the wallet also counts its reload
amount (a wallet under its threshold tops itself up on the next spend). A call is
still refused if the money is gone by the time you make it.
Latency
Section titled “Latency”Warm up before a burst. GET /v1/wallet and GET /v1/estimates also load,
on the instance that answers, what the first paid call of a burst would
otherwise load on its own latency (the payer’s billing checks, the model
catalogs, prices). Call one of them when a user is about to start something
that makes calls in quick succession — a game with bots that think — rather
than letting the first call pay for it. It spends nothing.
Where the time went. Every /v1 answer carries a Server-Timing header:
queue (event-loop wait), rl_ip / auth / rl_conn (the limiters and the
key lookup), the route’s own stages — on POST /v1/decisions and
POST /v1/completions: consent, project, idem_claim, routing, admit,
hold, model, settle, idem_finish — and handler (the total). All in
milliseconds. model is the provider call; the rest is Tanzanite. Log it next
to your own timing when a call feels slow.
Error taxonomy
Section titled “Error taxonomy”Every error body carries a machine-readable error field and a human-readable
message. Branch on error, never on the message text.
error |
Status | Retryable | Remedy |
|---|---|---|---|
insufficient_scope |
403 | No | The connection never held the scope. The user must reconnect granting it. |
reconsent_required |
403 | No | Send the user through Connect again. |
project_required |
400 | No | Create a project and pass projectId. |
invalid_request |
400 | No | The body failed validation; message names the field. Fix the request. |
too_many_sessions |
429 | Yes | Wait for an in-flight session to finish. |
insufficient_credits |
402 | No | The user must add credits. |
host_app_spend_cap |
402 | No (today) | This connection’s daily cap is spent. Not “add credits”. |
billing_unavailable |
503 | Yes | The balance could not be read. Retry. |
media_timeout |
504 | Yes | Outran the 90-second ceiling. Nothing charged. Retry with the same Idempotency-Key. |
idempotency_key_reused |
409 | No | Same key, different body. Pick a fresh key. |
idempotency_in_progress |
409 | Yes | The first request under this key is still running. Nothing charged. Retry with the same key for its result. |
unsupported_media_type |
400 | No | Only image and speech (and model and audio as jobs) in v1. |
Two distinctions worth wiring into your error handling:
host_app_spend_capis notinsufficient_credits. The wallet has money; this connection is not allowed to spend more of it today. Rendering “add credits” sends the user to the wrong screen.too_many_sessionsis not a rate limit. It is a concurrency ceiling, and it clears when one of your sessions finishes rather than when a time window rolls over.
A 429 that is not too_many_sessions is a request-rate limit — 1 200 per
minute per IP before authentication, 300 per minute per connection after. Respect
Retry-After when it is present.
Not in /v1
Section titled “Not in /v1”- No session list endpoint. Sessions are listed per project, via
GET /v1/projects/:id/sessions. - No webhooks. A deleted object is discovered through a
404; session progress is discovered by streaming or polling. - No video yet. It will arrive on
POST /v1/media/generateas a202plus aGET /v1/media/jobs/:id, keyed onmediaType, the way 3D models and audio did. See whymediaTypeis a field.