Connect a host app
Connect is how a Tanzanite user grants your app the right to act on their behalf. It is OAuth 2.0 authorization code with PKCE (RFC 7636), with one addition that is specific to this platform: the exchange also carries your own per-user credential, so the platform can call back into your app as that user.
The whole flow runs against https://api.tanzanite.ai except the consent
screen itself, which is on the app origin, https://app.tanzanite.ai.
Before you start
Section titled “Before you start”You need a host-app definition that is
connectable by the account you are testing with: your own account can connect a
private definition; other accounts can connect it once it is published
(see how publishing works). Its id is
your client_id, and its redirect URI allow-list is checked on an exact string
match at both ends of the flow.
Two rules that will save you an afternoon:
- The
code_verifieris a secret. It belongs to your backend. If it reaches your frontend, the code the user carries back becomes redeemable by anyone who intercepts it, and PKCE has bought you nothing. redirect_urimust match the allow-list byte for byte, must carry no fragment, and must contain no embedded credentials. A trailing slash is a different string.
The flow
Section titled “The flow”1. Fetch the app’s public metadata (optional but useful)
Section titled “1. Fetch the app’s public metadata (optional but useful)”curl "https://api.tanzanite.ai/api/connect/client?client_id=acme-studio&redirect_uri=https%3A%2F%2Facme.example%2Foauth%2Ftanzanite"{ "clientId": "acme-studio", "hostAppId": "acme-studio", "name": "Acme Studio", "description": "Level design and asset generation inside Acme Studio.", "iconUrl": "https://acme.example/icon.png", "requestedScopes": ["agent.sessions", "projects", "media.generate"], "scope": "agent.sessions projects media.generate", "spendScopes": ["agent.sessions", "media.generate"], "version": 3, "verified": true, "defaultSpendCapUSD": 5}This is what the consent screen renders, and it is also a cheap pre-flight: it
validates the redirect_uri against the allow-list before you send a user
anywhere. Checking here means a misconfigured redirect surfaces in your own logs
rather than as an error page in front of a user mid-connect.
2. Mint a PKCE pair and send the user to the consent screen
Section titled “2. Mint a PKCE pair and send the user to the consent screen”Your backend generates the pair. Only S256 is accepted — plain makes the
challenge equal to the verifier, so anyone observing the authorize request could
redeem the code, and it is refused rather than quietly accepted.
import { createPkcePair, createConnectState, buildConnectAuthorizeUrl,} from "@tanzanite/sdk/connect";
const { codeVerifier, codeChallenge } = await createPkcePair();const state = createConnectState();
// Store both against the user's pending-connect record, server-side.await savePendingConnect(userId, { codeVerifier, state });
const url = buildConnectAuthorizeUrl({ appUrl: "https://app.tanzanite.ai", clientId: "acme-studio", redirectUri: "https://acme.example/oauth/tanzanite", codeChallenge, state,});// → redirect the user's browser to `url`The URL it builds is:
https://app.tanzanite.ai/connect ?client_id=acme-studio &redirect_uri=https%3A%2F%2Facme.example%2Foauth%2Ftanzanite &code_challenge=<base64url SHA-256 of the verifier> &code_challenge_method=S256 &state=<opaque value you generated> &scope=<optional space-separated scopes> &reuse_consent=1 (optional, see "Reconnecting without asking again")scope is optional and usually best omitted: the definition already names its
requestedScopes, and repeating the list in your authorize URL is one more place
for it to go stale. Pass it only when you deliberately want to ask for less
than the definition requests.
state is not a secret and is not a substitute for PKCE. It exists so your
callback can confirm the response belongs to a flow you started.
3. The user consents
Section titled “3. The user consents”The consent screen is a first-party surface — you do not build it and you do not call it. It shows the user:
- Your app’s name, icon and description, taken from the definition rather than from anything a caller sent.
- The scopes being requested, with the credit-spending ones marked as such.
- A provenance marker: first-party or reviewed, versus an unverified third party. An author controls their own display name, so an unverified app is labelled as one no matter how plausible its name is.
- The daily spend cap, pre-filled — 5 USD for a verified app, 1 USD for an unverified one. The user can change it or clear it.
- The concurrent-session ceiling, defaulting to 3 and raisable to at most 20.
For completeness — this is the consent screen’s own call, made after the user clicks approve, and a host app never makes it:
POST https://api.tanzanite.ai/api/connect/authorizeContent-Type: application/json
{ "client_id": "acme-studio", "redirect_uri": "https://acme.example/oauth/tanzanite", "state": "…", "code_challenge": "…", "code_challenge_method": "S256", "scope": "agent.sessions projects media.generate", "spend_cap_usd": 5, "max_concurrent_sessions": 3}It answers { "redirectTo": "…", "expiresAt": "…" }, and the browser follows
redirectTo. For a refresh Connect the screen sends "reuse_consent": true and
no limits; the server answers 409 consent_required when it cannot approve
from the previous consent, and the screen then asks the user as usual.
4. The user lands back on your redirect
Section titled “4. The user lands back on your redirect”https://acme.example/oauth/tanzanite?code=<authorization code>&state=<your state>or, if something went wrong:
https://acme.example/oauth/tanzanite?error=access_denied&state=<your state>Compare state against what you stored. If it does not match, stop — do not
exchange the code.
5. Exchange the code, from your backend
Section titled “5. Exchange the code, from your backend”import { buildConnectTokenRequest, parseConnectGrant } from "@tanzanite/sdk/connect";
const body = buildConnectTokenRequest({ clientId: "acme-studio", redirectUri: "https://acme.example/oauth/tanzanite", code, codeVerifier, // the secret half, from your pending record credential: await mintAcmeTokenFor(userId), description: "Acme Studio", // optional label in the user's connections list});
const res = await fetch("https://api.tanzanite.ai/api/connect/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),});
const grant = parseConnectGrant(await res.json());await storeTenantKeyServerSide(userId, grant.apiKey);A successful exchange is 201:
{ "connectionId": "conn_9f3…", "hostAppId": "acme-studio", "subject": "Qm9Ft4c…", "apiKey": "tanzanite_live_xxxxxxxxxxxx", "name": "Acme Studio", "baseUrl": "https://api.acme.example/v2", "status": "active", "toolCount": 7, "scope": "agent.sessions projects media.generate", "scopes": ["agent.sessions", "projects", "media.generate"], "hostAppVersion": 3}subject is your app’s stable id for the user who consented. It is the same
every time that user connects to your app — a reconnect mints a new
connectionId, never a new subject — and a different value for every other
app, so it cannot be used to follow a user across apps. It is not their
Tanzanite user id. Key your own account on it if you sign users in with
Tanzanite: the exchange runs server to server, so a subject in this response
is proof of who consented.
credential — the field that is easy to get wrong
Section titled “credential — the field that is easy to get wrong”credential is your app’s own per-user token. It is what Tanzanite presents
back to your API on every tool call made for that user, using the auth shape your
definition declares. It is not a Tanzanite key, and it is not the user’s
password.
Mint a scoped, revocable token for it. Two properties matter:
- You must be able to revoke it independently. If a user disconnects on your side, or you detect abuse, killing that one token must be enough — you should never need Tanzanite’s cooperation to cut off access to your own API.
- It should carry only the reach your tools need. It authenticates every tool call the agent makes on that user’s behalf, so a token that could do more than your published tools is reach you did not intend to grant.
The credential is stored encrypted and is returned to nobody, including the response above.
What the exchange no longer carries
Section titled “What the exchange no longer carries”Earlier versions of this flow accepted the app’s own definition — name,
baseUrl, toolsUrl, tool catalog, prompt context — with every user’s token
exchange. That meant the app’s identity was whatever the last caller said it was,
and the tool catalog was authored once per connection instead of once per app.
The definition is now authored once and reviewed before it is connectable, and
the exchange carries only credential. If you are porting from that older shape,
delete those fields from your request body; they are no longer read.
apiKey is shown once
Section titled “apiKey is shown once”The tenant key in the exchange response is the only time you will ever see it. There is no endpoint that returns it again. Store it server-side at the moment you receive it; if you lose it, the user has to reconnect.
Re-connecting
Section titled “Re-connecting”A user can connect your app again at any time — to widen scopes, to change their spend cap, or after they disconnected. Reconnecting mints a new connection and revokes the previous one for that same user and app, so:
- The old tenant key stops authenticating. Overwrite your stored key with the new one rather than keeping both.
- Nothing is orphaned. Projects, session history and spend are keyed on the host app, not the connection, so everything the user already had is still there under the new key.
Reconnecting without asking again
Section titled “Reconnecting without asking again”When your stored key stops working (another reconnect superseded it, or you lost it), the user would normally see the consent screen again and re-approve what they already approved. Opt in to skip that:
const url = buildConnectAuthorizeUrl({ appUrl: "https://app.tanzanite.ai", clientId: "acme-studio", redirectUri: "https://acme.example/oauth/tanzanite", codeChallenge, state, reuseConsent: true, // adds reuse_consent=1});If Tanzanite can approve from the consent the user already gave, the browser
passes through a brief “Reconnecting…” page and lands on your redirect with a
code, exactly as after a click. Your callback and your exchange are unchanged.
The new connection carries the same limits the user set before (daily spend
cap, concurrent sessions) and no scope beyond what the previous one held.
Tanzanite decides this on its own servers, from its own records, and only when all of these hold. Otherwise the normal consent screen shows, with no error, so setting the flag on any reconnect is safe:
- The signed-in user has a previous connection to your app that came through the consent screen, and it is still active (or was only replaced by a newer connection of the same user).
- They did not disconnect it. A disconnect from the Tanzanite app, from your app
via
DELETE /v1/connection, a password reset, a sign-out of all sessions, an account suspension and every other revocation all mean the next Connect asks again. - Your definition is at the same
versionthey consented to and its verified state has not changed. - The scopes being requested are no wider than the previous connection’s.
Why it is not a shortcut for a malicious link: the code is bound to the PKCE
challenge in the link and redeemable only with the verifier your server holds;
it is only ever sent to a redirect_uri on your definition’s allow-list; the
user must be signed in to Tanzanite; and nothing is widened, since the limits
and scopes can only equal or undercut what the user granted before.
You can also revoke from your side with
DELETE /v1/connection. Do that
when a user disconnects your app in your interface. You do not need to call it
before a reconnect — the platform already revokes the previous connection.
Re-consent when a definition widens
Section titled “Re-consent when a definition widens”Any change to a definition that widens reach — a new harness tool group, a
new scope, a higher dangerLevel ceiling — bumps its version. A connection
that consented at an older version keeps everything it was already granted and is
refused only the new capability:
{ "error": "reconsent_required", "hostAppVersion": 3, "requiredVersion": 4}with status 403. The remedy is to send that user back through Connect. Handle
this explicitly: it is the one refusal whose fix is a user journey rather than a
retry or a top-up.
Changes that do not widen reach — a better description, a new icon, a tool removed, a narrower parameter — do not bump the version and need no re-consent.
Error responses during Connect
Section titled “Error responses during Connect”| Condition | Response |
|---|---|
redirect_uri not on the allow-list, has a fragment, or has embedded credentials |
400 |
code_challenge_method other than S256 |
400 |
Code already redeemed, expired, or issued for a different client_id |
400 |
code_verifier does not match the challenge |
400 |
credential missing or empty |
400 |
| Definition is not published, or not connectable by this account | 403 |
| Refresh Connect: consent was withdrawn between approval and exchange | 409 consent_required (the user sees the consent screen on the next attempt) |
Codes are single-use and short-lived. Exchange one immediately on receipt, and treat a failed exchange as “start the flow again” rather than something to retry.