SDK
@tanzanite/sdk is the typed TypeScript client for everything on
the API reference. Since there is deliberately no
OpenAPI document, this package is the machine-readable half of the contract:
the types are generated from the same definitions the server validates against.
It uses native fetch and WebCrypto, so it runs unchanged on Node 18+ and in
browsers with no polyfills.
npm install @tanzanite/sdkThree entry points
Section titled “Three entry points”| Import | Contains | Use it in |
|---|---|---|
@tanzanite/sdk |
The client, sessions, resources, errors, types | Your backend |
@tanzanite/sdk/connect |
The Connect contract: PKCE, URL building, request/response types | Your backend’s OAuth handler |
@tanzanite/sdk/react |
Prebuilt chat UI and hooks | Your frontend |
@tanzanite/sdk/connect is a separate entry point on purpose: it pulls in no
React and no WebSocket client, just the contract. It is the import a host
backend’s OAuth handler should reach for. See Connect.
Two client modes
Section titled “Two client modes”API-key mode (backends)
Section titled “API-key mode (backends)”import { Tanzanite } from "@tanzanite/sdk";
const crew = new Tanzanite({ apiKey: "tanzanite_live_xxxxxxxxxxxx", baseUrl: "https://api.tanzanite.ai", // default timeout: 30_000, // default});The tenant key belongs on a server. Never construct this client in a browser, a mobile binary, or anything else a user can read.
Ticket-only mode (browsers)
Section titled “Ticket-only mode (browsers)”const crew = new Tanzanite({ baseUrl: "https://api.tanzanite.ai", sessionId, // your backend created the session streamTicket, // from POST /v1/sessions/:id/embed-ticket});
const session = await crew.attachSession();A ticket-only client holds no tenant key. It can attach to the one session it was constructed with, stream it, signal it and answer its human-input requests — nothing else.
The resource groups are getters that throw at the call site in this mode:
crew.projects; // throws TanzaniteError, code "TICKET_ONLY"That is a deliberate choice over letting the request go out and reporting the
server’s 401. Every resource group is connection-scoped, so a widget calling one
is always a programming error, and the error names the client you built rather
than the status code you got back.
Splitting the work this way is the point of tickets: your backend creates the project, creates the session and mints the ticket; the browser gets a credential that can only watch and talk to that one session.
Resource groups
Section titled “Resource groups”crew.projects
Section titled “crew.projects”const { project, created } = await crew.projects.create({ externalId: "level_42", label: "Level 42 — Crypt", url: "https://studio.acme.example/levels/42",});
const { projects } = await crew.projects.list({ limit: 50 });const one = await crew.projects.get(project.id); // throws NotFoundError if deletedconst { sessions, hasMore } = await crew.projects.listSessions(project.id, { limit: 20 });
// A repeat `create` returns the first project unchanged — a rename is a PATCH.await crew.projects.update(project.id, { label: "Level 42 — Catacombs" });await crew.projects.update(project.id, { url: null }); // clear the deep linkcreate is idempotent per connected user and externalId, so calling it every
time the user opens an entity is the intended shape. It never updates: when the
user renames the entity on your side, update is how the new label or deep link
reaches Tanzanite.
get throwing NotFoundError is the lifecycle signal: the user deleted the
project. Drop your mapping and let the next create make a fresh one.
crew.media
Section titled “crew.media”const image = await crew.media.generate( { mediaType: "image", prompt: "…", size: "1024x1024", projectId }, { idempotencyKey: "acme-job-7f3c1e" },);
const verdict = await crew.media.describe( { images: [{ url: image.url }], question: "Does the archway read as passable?", measuredContext: `${image.width}x${image.height} PNG generated from that prompt.`, projectId, },);
// Text to speech (≤ 4000 characters) — same endpoint, same ceiling.const vo = await crew.media.generateSpeech( { mediaType: "speech", input: "In a world where every block falls…", voice: "am_michael", projectId }, { idempotencyKey: "acme-vo-91c2" },);// vo.mimeType ("audio/mpeg" | "audio/wav"), vo.durationMs, vo.costUSDgenerate and generateSpeech have a 90-second server-side ceiling and the client default timeout is
30 seconds — construct a client with a higher timeout for this call, or it
will abort a healthy generation. See Media.
3D models are a job, not a held request, so the 30-second default is fine for them:
const model = await crew.media.generateModel( { mediaType: "model", prompt: "a treasure chest", targetHeight: 0.8, projectId }, { idempotencyKey: "acme-model-91ac", onProgress: (job) => console.log(job.progress.stage) },);const glb = await fetch(model.lods[0].url); // signed URL: copy the bytes into your storagegenerateModel starts the job and polls it until it finishes (it throws a
TanzaniteError carrying the job’s error code if it fails; nothing is charged).
startModel and job are its two halves. See
Media.
crew.completions
Section titled “crew.completions”const result = await crew.completions.create( { messages: [{ role: "user", content: "Summarise this level in one line." }], jsonSchema: { type: "object", properties: { line: { type: "string" } }, required: ["line"] }, projectId, }, { idempotencyKey: "acme-summary-42" },);// result.structuredResult → { line: "…" }; result.schemaValid → true / falseWith jsonSchema, the parsed output comes back as structuredResult and
schemaValid says whether it satisfies the schema — the model is asked, not made,
to comply, so check it before you act on the output. Output that did not parse as
JSON comes back raw as structuredRaw (with schemaValid: false).
crew.decisions
Section titled “crew.decisions”const { results, costUSD } = await crew.decisions.create({ 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" } }, }, }], tag: "bot-live",});// results[0].answers.move → { type: "choice", choice: "flee", probabilities, confidence }Up to 16 requests per call; a failed request is reported in its own result and
the others still answer. See POST /v1/decisions.
crew.estimates
Section titled “crew.estimates”const { perCallUSD, typicalLatencyMs } = await crew.estimates.get({ kind: "decision", stateChars: 4000 });const voiceover = await crew.estimates.get({ kind: "speech", chars: script.length, model: "hexgrad/kokoro-82m" });What one call would cost the user, billed. Needs no scope and spends nothing.
crew.wallet
Section titled “crew.wallet”const { spendableUSD, availableUSD, connectionDailyRemainingUSD, autoReload } = await crew.wallet.get();if (spendableUSD < worstCaseUSD) showAddCredits();What this connection can spend right now: the wallet (availableUSD), the daily
cap the user set on this connection, the account’s own daily and monthly caps, and
card auto-reload. spendableUSD is the least of those that apply (with auto-reload
on, the wallet also counts its reload amount). Needs any spend scope; free. Check it
before starting something billed — a call is still refused if the money is gone by
then.
crew.connection
Section titled “crew.connection”const info = await crew.connection.get();if (!info.scopes.includes("media.generate")) hideImageFeatures();
await crew.connection.revoke(); // immediate and finalCall get at start-up. Confirming a stored key still authenticates and still
holds the scopes a feature needs is much better than discovering both from a
403 in front of a user.
Sessions
Section titled “Sessions”const session = await crew.createSession({ projectId: project.id, goal: "Add three ambush encounters to the crypt level.", context: { sceneId: "scene_main" }, guardrails: { maxSubagents: 2 },});projectId is required, and the SDK checks it before the request goes out — the
server’s project_required answer arrives after a round trip and names a field
the caller can no longer see.
Reattach later with crew.getSession(id), or read status without opening a
stream using crew.getSessionInfo(id).
Control
Section titled “Control”await session.pause("user switched scenes");await session.resume({ newBudget: 2.5 });await session.redirect("Two encounters, not three.");await session.cancel();const { mode } = await session.compact(); // "latched" (running: after this step) or "started" (idle)
await session.signal({ type: "pause" }); // the general form
const info = await session.getInfo(); // info.context: the context meter, null until measuredconst { messages, hasMore } = await session.getMessages({ limit: 50 });await session.respondToHumanInput(inputId, { approved: true });
const child = await session.getSubagentMessages(subagentId, { limit: 20 }); // from subagent_spawnedconst artifact = await session.getArtifact(artifactId); // full source; the stream's copy may be clippedconst file = await session.downloadArtifact(artifactId); // { filename, url } or { filename, blob }compact() summarises the agent’s history so its next call starts with a
smaller context — the same thing the agent does on its own at
compactAtTokens. A refusal throws a TanzaniteError with status 409 and a
code: compaction_running, nothing_to_compact (no call measured yet),
compaction_disabled or not_compactable (e.g. a paused session). The stock
<TanzaniteAgent> shows the meter and a Compact button in its status bar.
Streaming
Section titled “Streaming”Two equivalent styles. The async iterator is the simplest:
for await (const event of session.stream()) { if (event.type === "message") console.log(event.role, event.content); if (event.type === "complete") console.log("done:", event.report);}Or listeners, when you need more than one consumer:
const off = session.on("tool_call", (e) => console.log(e.toolName, e.arguments));session.on("human_input_needed", async (e) => { await session.respondToHumanInput(e.inputId, { approved: true });});session.onAny((e) => log(e));session.startStreaming();// …lateroff();session.stopStreaming();stream() connects on first iteration and disconnects when the loop exits, so a
break or a thrown error cleans up after itself.
Events
Section titled “Events”Every frame /v1/stream sends:
| Event | Carries |
|---|---|
init |
resync — true when this replaces a previous stream generation. |
status_change |
previousStatus, newStatus, timestamp. |
message |
role, content, timestamp, and reasoning when the model thought before an assistant message. |
live |
roundId, content, reasoning, toolCalls — what the agent is typing and calling right now. Whole buffers, each frame replacing the last; empty means nothing is streaming. |
model |
model — the model the session runs on, when resolved and whenever it changes. |
tool_call |
toolName, arguments, callId. |
tool_result |
toolName, callId, success, output, error. |
human_input_needed |
inputId, question, category, choices, allowFreeText; for tool approvals toolName, toolArguments, riskLevel; optionally multiSelect, questions, changePreview. |
human_input_resolved |
inputId — the input was answered or withdrawn (here, in the Tanzanite app, or by expiry). Stop showing it. |
todos |
todos — the agent’s whole to-do list, whenever it changes. |
cost_update |
totalCost (billed, the whole session: own + subagents + attributed /v1 spend), totalTokens, promptTokens, completionTokens. |
context |
usedTokens, contextWindow, compactAtTokens (null when compaction is off), estimated (right after a compaction — show a ~), compacting. How full the model’s context window is; sent on connect and whenever it changes. |
subagent_spawned |
subagentId, scope. |
subagent_update |
subagentId, status, and activity — its latest reply or tool. |
subagent_complete |
subagentId, status, report. |
artifact |
id, title, format, version, content, truncated, and file for a file. A new version replaces the old; read a clipped one in full with session.getArtifact. |
complete |
report (the completion report), cost. Terminal. |
cancelled |
timestamp. Terminal — the session was cancelled; not an error. |
error |
message, code. Terminal. code: "ENQUEUE_ERROR" means the session could not be started. |
The client adds one of its own:
| Event | Carries |
|---|---|
warning |
message, code. A recoverable transport condition. |
The SDK’s SessionEvent type also declares token, which /v1/stream does not
send today — keep a default branch rather than waiting on it.
Two things to handle correctly:
warning is not a failure. A network blip surfaces as a warning while the
client reconnects underneath. Only a terminal error ends the stream — including
WS_GIVE_UP, emitted once the reconnect budget is genuinely exhausted. Do not
tear down your UI on a warning.
init with resync: true means drop what you buffered. It marks a reconnect
that replaces a previous stream generation; without handling it, the first
snapshot after a gap duplicates every message you already rendered.
Errors
Section titled “Errors”Everything the client throws extends TanzaniteError, which carries status,
an optional code, and the response body in details. AuthenticationError
(401), NotFoundError (404), RateLimitError (429, with retryAfter) and
SessionError are the named subclasses.
Two accessors turn a refusal into something you can branch on. They are separate because their remedies are different in kind: one is answered with money, the other with a capability change. Code that renders “add credits” for a capability refusal is showing the user the wrong button.
err.billing — refusals answered with money
Section titled “err.billing — refusals answered with money”try { await crew.media.generate({ mediaType: "image", prompt });} catch (err) { const b = err instanceof TanzaniteError ? err.billing : null; if (b?.reason === "insufficient_credits") return showAddCredits(b); if (b?.reason === "host_app_spend_cap") return showSpendCapReached(b); if (b?.retryable) return scheduleRetry(b.retryAfterSeconds); throw err;}reason |
Meaning |
|---|---|
insufficient_credits |
The user has no funds. Determinate. |
host_app_spend_cap |
The daily cap on this connection is spent. The wallet is fine. |
billing_unavailable |
The balance could not be read at all. Retryable. |
rate_limited |
Too many requests. Retryable after a wait. |
availableUSD may be null, meaning the balance is unknown. Do not render
null as $0.00 — unknown is not empty.
err.refusal — refusals answered with a capability change
Section titled “err.refusal — refusals answered with a capability change”const r = err instanceof TanzaniteError ? err.refusal : null;if (r?.reason === "media_timeout") return retryWithSameIdempotencyKey();if (r?.reason === "reconsent_required") return sendUserThroughConnect();if (r?.reason === "too_many_sessions") return queueUntilASlotFrees(r.limit);reason |
Extra fields | Remedy |
|---|---|---|
insufficient_scope |
scope |
The user must reconnect granting that scope. |
reconsent_required |
hostAppVersion, requiredVersion |
Send the user through Connect. |
too_many_sessions |
limit |
Retryable once a session finishes. |
media_timeout |
ceilingMs |
Nothing charged. Retry with the same Idempotency-Key. |
idempotency_key_reused |
— | A collision, not a replay. Pick a fresh key. |
idempotency_in_progress |
— | The first request under this key is still running. Nothing charged. Retry with the same key. |
Both accessors return null for anything they do not recognise, rather than
mislabelling it. A 403 that is not one of the two above means something else and
carries different affordances — let it propagate.
Connect helpers
Section titled “Connect helpers”import { createPkcePair, createConnectState, buildConnectAuthorizeUrl, parseConnectCallback, buildConnectTokenRequest, parseConnectGrant, CONNECT_SCOPES, CONNECT_SPEND_SCOPES, CONNECT_DEFAULT_SCOPES, formatConnectScopes, parseConnectScopes,} from "@tanzanite/sdk/connect";These are pure — no fetch, no error taxonomy. The one HTTP call in the flow is
yours to make, because your backend already has a client with its own retry and
error mapping and adopting the SDK’s would be a worse trade.
parseConnectGrant is worth using rather than casting the response: a cast lets a
proxy error page or a truncated body become a “connection” whose stored key is
undefined, and that failure surfaces much later as an unexplained 401.
Full walkthrough on the Connect page.
React components
Section titled “React components”import { TanzaniteProvider, TanzaniteAgent } from "@tanzanite/sdk/react";
<TanzaniteProvider client={crew}> <TanzaniteAgent sessionId={sessionId} streamTicket={ticket} /></TanzaniteProvider>TanzaniteAgent is a complete chat surface — transcript, input, tool-call cards,
a human-input dialog, a status bar (status, cost, model,
the context meter and a Compact button) and a sub-agent panel.
The sub-components are exported individually for custom layouts, alongside the
useSession, useSessionEvents, useTanzanite, useTheme and useVoiceInput
hooks and light and dark themes.
React is an optional peer dependency; the main entry point does not pull it in.
A complete backend flow
Section titled “A complete backend flow”import { Tanzanite, TanzaniteError } from "@tanzanite/sdk";
const crew = new Tanzanite({ apiKey: await keyFor(userId), timeout: 120_000 });
const { project } = await crew.projects.create({ externalId: level.id, label: level.name, url: `https://studio.acme.example/levels/${level.id}`,});
try { const session = await crew.createSession({ projectId: project.id, goal: "Add three ambush encounters, balanced for level 8 players.", context: { sceneId: level.sceneId }, });
for await (const event of session.stream()) { if (event.type === "human_input_needed") await askTheUser(event); if (event.type === "complete") await recordReport(level.id, event.report); }} catch (err) { if (err instanceof TanzaniteError) { if (err.refusal?.reason === "reconsent_required") return sendUserThroughConnect(); if (err.billing?.reason === "insufficient_credits") return showAddCredits(); } throw err;}