Skip to content

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.

Header Direction Notes
Authorization: Bearer tanzanite_… Request The tenant key. Backend only.
X-Tanzanite-Key: tanzanite_… Request Equivalent alternative to the above.
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.
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
GET /v1/sessions/:id/artifacts/:artifactId agent.sessions
GET /v1/sessions/:id/artifacts/:artifactId/download 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/completions completions
POST /v1/decisions decisions
GET /v1/estimates none
GET /v1/wallet any spend scope

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

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

Auth tenant key · Scope none

Mint a ticket for the WebSocket stream.

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.

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.

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 }

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.

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.

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.

GET /v1/sessions/:id/artifacts/:artifactId

Section titled “GET /v1/sessions/:id/artifacts/:artifactId”

Auth tenant key or embed ticket · Scope agent.sessions

One artifact in full. The stream’s artifact event clips long sources (it sets truncated); this is where the whole of one is read. artifactId is the event’s id.

{
"id": "art_2d9…",
"title": "Crypt encounter table",
"format": "markdown",
"version": 2,
"content": "…"
}

format is html, markdown or file. For a file, content is "" and file carries filename, mimeType and sizeBytes. 404 with { "error": "Artifact not found" } when the session has no such artifact.

GET /v1/sessions/:id/artifacts/:artifactId/download

Section titled “GET /v1/sessions/:id/artifacts/:artifactId/download”

Auth tenant key or embed ticket · Scope agent.sessions

The artifact as a file. For a file artifact: a short-lived signed link as JSON ({ "url", "filename", "mimeType", "sizeBytes" }) when one can be signed, otherwise the bytes themselves, with a single Range honoured (206/416). For an HTML or Markdown artifact: its source as an attachment (text/html or text/markdown). The SDK’s session.downloadArtifact handles both answers.

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.

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.

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.

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.

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.

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 and audio (music) are 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.

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.

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
}

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
}

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.

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

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.

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.

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 as a job) in v1.

Two distinctions worth wiring into your error handling:

  • host_app_spend_cap is not insufficient_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_sessions is 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.

  • No session list endpoint. Sessions are listed per project, via GET /v1/projects/:id/sessions.
  • No webhooks. Project deletion is discovered through a 404; session progress is discovered by streaming or polling.
  • No async media jobs, yet. Video and long audio will arrive on POST /v1/media/generate as a 202 plus a GET /v1/media/jobs/:id, keyed on mediaType. See why mediaType is a field.