Skip to content

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.

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_verifier is 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_uri must match the allow-list byte for byte, must carry no fragment, and must contain no embedded credentials. A trailing slash is a different string.

1. Fetch the app’s public metadata (optional but useful)

Section titled “1. Fetch the app’s public metadata (optional but useful)”
Terminal window
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.

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.

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/authorize
Content-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.

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.

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.

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.

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.

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.

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 version they 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.

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.

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.