Skip to content

Projects & sessions

A project is the workspace an agent session runs in. Every session your app creates lives in one, and there is no implicit or default project: a session with nowhere to live has no settings, no tool policy, no spend attribution and no row the user can open to see what your app has been doing on their behalf.

The rule is:

One project per (connected user × host app × external entity).

An “external entity” is whatever your app’s unit of work is — a level, a document, a repository, a campaign. You name it with an externalId that is opaque to Tanzanite.

Two collaborators working on the same document each connect their own Tanzanite account. The same externalId therefore maps to two different projects, in two different accounts — and both are correct. Creation is idempotent per user, so the second collaborator’s project creation is a genuine creation, not a duplicate of the first’s.

The practical consequence: store your mapping per user, not per entity.

WRONG entity_id -> tanzanite_project_id
RIGHT (your_user_id, entity_id) -> tanzanite_project_id

An app that keys its mapping on the entity alone will hand the second collaborator the first collaborator’s project id, and every call with it will fail — that project is in an account this connection cannot reach.

Terminal window
curl -X POST https://api.tanzanite.ai/v1/projects \
-H "Authorization: Bearer tanzanite_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"externalId": "level_42",
"label": "Level 42 — Crypt",
"url": "https://studio.acme.example/levels/42"
}'
{
"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
}

created tells you which happened — 201 with created: true for a new project, 200 with created: false when the reservation already existed. Both are success. Calling this on every open of an entity is the intended usage, not something to guard against; it is the cheapest way to keep your mapping honest without tracking creation state yourself.

externalRef is how the project points back at your app.

Field Meaning Limit
hostAppId Your app. Set by the platform, not by you. —
externalId Your id for the entity. Opaque to Tanzanite. 200 characters
label What a human sees in the project list. 200 characters
url Deep link back into your app. Optional. 2048 characters

Set url. It is what makes the project badge in the Tanzanite interface clickable, and it is most of the answer to “I am looking at this in Tanzanite and I cannot see the thing it is about”. A user who can get from the agent’s work back to your app in one click is a user who trusts the integration.

POST /v1/projects never changes an existing project. On a repeat it returns the first project’s fields unchanged — that is what makes it safe to call on every open — so a new label in the body of a repeat create is not an update; it is ignored. When a user renames the thing on your side, or your deep links move, push the change explicitly:

Terminal window
curl -X PATCH https://api.tanzanite.ai/v1/projects/proj_7bd... \
-H "Authorization: Bearer tanzanite_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "label": "Level 42 — Catacombs", "url": "https://studio.acme.example/levels/42" }'
{ "project": { "id": "proj_7bd…", "name": "Level 42 — Catacombs", "externalRef": { "…": "…" } } }

Send either field or both; url: null clears the link, and the project’s name follows the label. externalId is not writable — it is the idempotency key, and a different id is a different entity. A 404 here means the same thing it means on GET: the project is gone, drop your mapping.

Users can delete a project in Tanzanite at any time. There is no callback, and this is deliberate: the reconciliation mechanism is a 404.

GET /v1/projects/:id → 404

A 404 here means the user deleted the project. Drop your stored mapping for that entity and create a fresh project on next use. One idempotent POST /v1/projects and you are whole again — which is exactly what your code already does on every open.

The inverse case needs no handling at all: if your app simply stops referencing a project, nothing special happens. It stays in the user’s account as ordinary history, visible and deletable by them like any other project.

An integration session is confined to its own project. This is not advice or a convention — it is enforced at tool dispatch, and it is what makes it safe for a user to connect a third-party app to an account that also holds their unrelated work.

A session can:

  • Call your host app’s own tools, which appear to the agent as integration_<name> with their descriptions prefixed [<App name>].
  • Call the harness tool groups your definition was granted.
  • Call the baseline: the completion protocol, orchestration and artifact tools, and a small utility set. These carry no reach into the user’s other resources.

A session cannot:

  • Read or write the user’s other projects — including ones created by a different host app, and including the user’s own.
  • Reach a tool outside the grant, by any route. Naming it directly does not work, and neither does discovering it: a tool outside the grant reports “not available in this session” rather than appearing and failing.
  • Escalate through a sub-agent. A sub-agent can never hold more than its parent — the capability grant is stamped at spawn and narrowed, never widened.

And by construction, a host-app project has no repository and no device folder. There is no checkout to reach, so file, git and shell tools are withdrawn rather than merely blocked. That is a stronger property than a blocklist: there is nothing for them to operate on.

Your definition names the groups its sessions may use. They are granted at review time, expanded into concrete tool names once at session creation, and enforced for the life of the session.

Group What it covers
media.generation Generating images, video and audio.
media.transform Editing and transforming existing images and video.
documents Structured data and document handling.
web Web fetching and search, and browser automation.
files File-system and git tools.
code Writing and running code.
subagents Spawning quarantined sub-agents to parallelise work.
knowledge Agent memory, skills, and session knowledge.
management Enumerating and managing projects, plans and settings.

A freshly authored definition gets media.generation and nothing else. Ask for groups you actually use — each one widens what a session can do inside the user’s account, and each addition bumps your definition version and requires every existing user to re-consent.

Two notes:

  • management is never granted by default. It is the group whose tools authorize by user rather than by plan, so granting it is precisely what would let an app enumerate a user’s unrelated work. Treat a request for it as something you will have to justify.
  • files does nothing useful in a host-app project. There is no workspace for it to act on. Granting it costs you a version bump and buys you nothing.
Terminal window
curl -X POST https://api.tanzanite.ai/v1/sessions \
-H "Authorization: Bearer tanzanite_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"projectId": "proj_7bd…",
"goal": "Add three ambush encounters to the crypt level, balanced for level 8 players.",
"context": { "sceneId": "scene_main", "levelId": "level_42" },
"guardrails": { "maxSubagents": 2, "requireToolApproval": false }
}'
{ "sessionId": "sess_4c1…", "roundId": "round_1", "status": "queued" }

Scope: agent.sessions. The session begins processing immediately.

Everything in context is merged into the arguments of every tool call the session makes, and the pin wins over anything the model supplies for the same key. That makes it the right place for the identifiers your tools must not be allowed to guess: the scene the user actually has open, the tenant id, the document revision. A model that cannot influence a value cannot get it wrong.

Put ids there. Do not put instructions there — instructions belong in goal.

queued, planning, orchestrating, waiting_subagents, waiting_human, waiting_human_review, paused, complete, failed, cancelled.

Treat this list as open: /v1 may add statuses additively, so switch on the ones you handle and have a default branch.

Call Purpose
GET /v1/sessions/:id Status, cost (billed total, with an own / subagents / media breakdown), timestamps, completion report or error.
GET /v1/sessions/:id/messages Paginated transcript.
POST /v1/sessions/:id/signal pause, resume, cancel, redirect.
POST /v1/sessions/:id/human-input/:inputId Answer a question the agent asked.

redirect sends a new instruction into a running session — the way to say “not that, do this instead” without cancelling and paying for a fresh start. resume optionally raises the cost budget of a session that paused against it.

When an agent needs a decision it cannot make, it emits a human_input_needed event carrying an inputId, a question, a category (clarification, selection, confirmation, approval, tool_approval), optional choices, and whether free text is allowed. The session waits in waiting_human until you answer:

Terminal window
curl -X POST https://api.tanzanite.ai/v1/sessions/sess_4c1.../human-input/input_9 \
-H "Authorization: Bearer tanzanite_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "approved": true }'

Send choiceId for a selection, freeText for a clarification, approved for a confirmation or approval. A session that is never answered stays waiting — it does not time out into a decision on your behalf, which is the point.

Terminal window
curl "https://api.tanzanite.ai/v1/projects/proj_7bd.../sessions?limit=20" \
-H "Authorization: Bearer tanzanite_live_xxxxxxxxxxxx"

Newest first, paged with after (the sessionId of the last row you saw). Each row carries status, the original goal as message, timestamps, and cost in USD (billed, the whole figure: the same number as the session’s cost.totalCost) — enough to render an activity list in your own app without opening every session.

This history is keyed on the host app, so it survives a user disconnecting and reconnecting. See the two records.

Status Body Meaning
400 { "error": "project_required" } No projectId. Create one first.
403 { "error": "insufficient_scope", "scope": "agent.sessions" } The connection never held the scope.
403 { "error": "reconsent_required", … } Consented at an older definition version. Send the user through Connect.
402 { "error": "insufficient_credits" } The user’s balance is empty.
402 { "error": "host_app_spend_cap" } This connection’s daily cap is spent.
429 { "error": "too_many_sessions", "limit": 3 } In-flight session ceiling full. Retryable when one finishes.