Skip to content

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.

Terminal window
npm install @tanzanite/sdk
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.

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.

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.

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 deleted
const { 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 link

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

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

generate 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 storage

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

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 / false

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

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.

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.

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.

const info = await crew.connection.get();
if (!info.scopes.includes("media.generate")) hideImageFeatures();
await crew.connection.revoke(); // immediate and final

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

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

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 measured
const { messages, hasMore } = await session.getMessages({ limit: 50 });
await session.respondToHumanInput(inputId, { approved: true });
const child = await session.getSubagentMessages(subagentId, { limit: 20 }); // from subagent_spawned
const artifact = await session.getArtifact(artifactId); // full source; the stream's copy may be clipped
const 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.

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();
// …later
off();
session.stopStreaming();

stream() connects on first iteration and disconnects when the loop exits, so a break or a thrown error cleans up after itself.

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.

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.

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.

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.

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;
}