Apps acting for people (authorization code + PKCE)
Use the authorization code flow with PKCE when your app acts for a person: a command-line tool, a desktop app, a single-page app or a server-side integration that people sign in to. The app can then do what that person may do, within the scopes they approve.
Register the app (admins)
Section titled “Register the app (admins)”In Admin → API → Clients, select New API client and choose:
- Public app for apps that cannot keep a secret (command-line, desktop, single-page apps). PKCE protects the flow; there is no secret.
- Confidential app for server-side apps. It also gets a client secret (shown once, rotated like a machine client’s) and authenticates to the token endpoint with HTTP Basic.
Enter its Redirect URIs (one per line). They must match exactly: https://… addresses, or loopback addresses (http://127.0.0.1, http://localhost, http://[::1]) on any port for local tools. Public apps may also use a private-use scheme such as com.example.tool:/callback. Wildcards and fragments are not allowed.
An app registered by your organization can only be used with your organization.
What people need
Section titled “What people need”- Their role holds Connect apps to the API on my behalf. By default reviewers, approvers and admins have it; admins change it in Admin → AI tools (MCP) → Who can connect.
- They turned on Settings → Connected apps → Allow apps and AI tools to act for me. Admins can’t turn it on for someone else; turning it off disconnects all their apps at once.
The flow
Section titled “The flow”-
Create a PKCE pair and a state. Only
S256is accepted, andstateis required.const b64url = (bytes: ArrayBuffer | Uint8Array) =>btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)))const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))const state = b64url(crypto.getRandomValues(new Uint8Array(16))) -
Send the person to the authorize endpoint.
const authorize = new URL("https://api.tradecrafthub.ai/oauth/authorize")authorize.search = new URLSearchParams({response_type: "code",client_id: CLIENT_ID,redirect_uri: "http://127.0.0.1:8765/callback",scope: "assessments:read questions:write assessments:review",state,code_challenge: challenge,code_challenge_method: "S256",resource: "https://api.tradecrafthub.ai",}).toString() -
The organization. For an app your organization registered, the organization is fixed. Otherwise Tradecraft asks the person to type their organization’s address (it never lists organizations), or takes it from an
organization=<address>parameter. People in several organizations connect once per organization and get one token per organization; the consent page offers Use a different organization. -
Consent on the organization’s own address. The person signs in there if needed and sees your app, the organization’s name, where they will be sent back and the scopes in plain words. Only scopes their role can grant are offered. Finalize questionnaires and Read your organization’s audit log also need a sign-in in the last 12 hours. Consent is remembered: next time the screen is skipped unless your app asks for more.
-
The callback. Tradecraft redirects to your
redirect_uriwithcode,stateandiss=https://api.tradecrafthub.ai. Check thatstateis yours andissis that issuer. If the person cancels, you geterror=access_denied. -
Exchange the code within 60 seconds; a code works once.
Terminal window curl -s https://api.tradecrafthub.ai/oauth/token \-d grant_type=authorization_code -d code="$CODE" -d code_verifier="$VERIFIER" \-d redirect_uri=http://127.0.0.1:8765/callback -d client_id="$CLIENT_ID" \-d resource=https://api.tradecrafthub.aiA confidential app adds
-u "$CLIENT_ID:$CLIENT_SECRET". The response has anaccess_token(one hour), arefresh_tokenand the grantedscope.
Refresh tokens
Section titled “Refresh tokens”const res = await fetch("https://api.tradecrafthub.ai/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: stored.refreshToken, client_id: CLIENT_ID }),})const next = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }// Store next.refresh_token now: the one you sent no longer works.- Refresh tokens rotate: every refresh returns a new one. Using an old one again revokes the whole chain, and the person has to connect again.
- They expire after 30 days without use and 90 days after consent, whatever the use. Then the person consents again.
- A refresh can narrow the scopes (
scope=…), never widen them. For more scopes, send the person through consent again.
Revoke
Section titled “Revoke”curl -s https://api.tradecrafthub.ai/oauth/revoke -d token="$REFRESH_TOKEN" -d client_id="$CLIENT_ID"Revoking a refresh token revokes its chain; revoking an access token revokes that token. The answer is always 200. People see and revoke their connections in Settings → Connected apps; admins see everyone’s in Admin → API → Connected apps.
What the token can do
Section titled “What the token can do”A person’s token can never do more than the person may do now: every request checks the token’s scopes and the person’s current role. If their role is lowered, the next request is limited accordingly; if they are removed or deactivated, their tokens are revoked at once.
These operations are for people only; machine clients get 403:
POST /v1/assessments/{id}/review-complete— Send to Finalize (scopeassessments:review, reviewers and up)POST /v1/assessments/{id}/finalize— Finalize (scopeassessments:finalize, approvers and admins)