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.
Why the user is in the key
Section titled “Why the user is in the key”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_idRIGHT (your_user_id, entity_id) -> tanzanite_project_idAn 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.
Creating a project
Section titled “Creating a project”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.
The external reference
Section titled “The external reference”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.
Renaming an entity
Section titled “Renaming an entity”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:
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.
Project lifecycle — there is no webhook
Section titled “Project lifecycle — there is no webhook”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 → 404A 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.
What a session can and cannot reach
Section titled “What a session can and cannot reach”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.
Harness tool groups
Section titled “Harness tool groups”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:
managementis 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.filesdoes 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.
Creating a session
Section titled “Creating a session”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.
context is a pin, not a hint
Section titled “context is a pin, not a hint”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.
Session statuses
Section titled “Session statuses”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.
Driving a session
Section titled “Driving a session”| 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.
Human input
Section titled “Human input”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:
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.
Session history
Section titled “Session history”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.
Errors when creating a session
Section titled “Errors when creating a session”| 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. |