Browser-side chat
Sometimes the SDK consumer is the browser — an agentic chat UI
talking directly to /api/sdk/* with no backend-for-frontend in the
middle. Baking a workspace API key into the bundle would be a
credential leak, so Tavora ships a session-token exchange: the
browser trades the user’s JWT session for a short-lived workspace API
key, then uses that key for every SDK call exactly the way a Go
service would.
The /app/chat surface in the official web app uses this pattern —
source in web/src/lib/sdk.ts and web/src/pages/app/sdk/.
Browser POST /api/auth/session-token → Server Authorization: Bearer <jwt> { "workspace_id": "..." }
Server mints a workspace_api_keys row with expires_at = now + 8h
Browser ← { api_key: "tvr_...", key_id, expires_at } stores in sessionStorage (cleared on tab close)
Browser GET /api/sdk/space X-API-Key: tvr_... → Server (standard APIKeyAuth)Exchange endpoint
Section titled “Exchange endpoint”POST /api/auth/session-tokenAuthorization: Bearer <jwt>Content-Type: application/json
{ "workspace_id": "uuid-of-a-workspace-the-user-can-access" }Responses:
201 Createdwith{ api_key, key_id, key_prefix, expires_at }on success.api_keyis returned once — store it client-side.401if no JWT.404if the workspace doesn’t belong to the user’s current team (non-existence and no-access collapse to the same shape on purpose).400ifworkspace_idis missing or malformed.
Keys issued this way expire in 8 hours. The middleware rejects expired keys at use time; clients should re-mint slightly ahead of expiry (the reference implementation uses a 10-minute early window).
Frontend helper
Section titled “Frontend helper”A minimal sdkFetch implementation that mints + caches the key:
const STORAGE_KEY = 'tavora_sdk_session_key';const EARLY_WINDOW = 10 * 60 * 1000; // refresh 10 min before expiry
async function ensureApiKey(workspaceId: string): Promise<string> { const raw = sessionStorage.getItem(STORAGE_KEY); if (raw) { const stored = JSON.parse(raw); if ( stored.workspaceId === workspaceId && new Date(stored.expiresAt).getTime() - Date.now() > EARLY_WINDOW ) { return stored.apiKey; } } const res = await fetch('/api/auth/session-token', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getJwt()}`, }, body: JSON.stringify({ workspace_id: workspaceId }), }); const data = await res.json(); sessionStorage.setItem( STORAGE_KEY, JSON.stringify({ apiKey: data.api_key, expiresAt: data.expires_at, workspaceId, }), ); return data.api_key;}
export async function sdkFetch(workspaceId: string, path: string, init: RequestInit = {}) { const apiKey = await ensureApiKey(workspaceId); const res = await fetch(`/api/sdk${path}`, { ...init, headers: { ...init.headers, 'X-API-Key': apiKey }, }); if (res.status === 401) { sessionStorage.removeItem(STORAGE_KEY); // Retry once with a fresh mint — omitted here for brevity. } return res;}Streaming agent runs from the browser
Section titled “Streaming agent runs from the browser”POST /api/sdk/agents/{id}/run returns an SSE stream. Reach for a
streaming fetch rather than the built-in EventSource — the latter
only supports GET and can’t carry an X-API-Key header.
const res = await fetch(`/api/sdk/agents/${sessionId}/run`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', 'X-API-Key': apiKey, }, body: JSON.stringify({ message: userInput }),});const reader = res.body!.getReader();const decoder = new TextDecoder();let buf = '';let currentEvent = '';while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop() ?? ''; for (const line of lines) { if (line.startsWith('event: ')) currentEvent = line.slice(7).trim(); else if (line.startsWith('data: ')) { const payload = JSON.parse(line.slice(6)); onEvent(currentEvent, payload); // {type, content, tool, args, …} } }}Event names are step (default AgentEvent), done, error, and
input_request. Each payload’s .type field carries the detailed
AgentEvent kind (execute_js, response, tool_call, etc.).
Security considerations
Section titled “Security considerations”- TTL. Keys expire in 8h. Don’t raise the TTL — re-minting is cheap, and shorter-lived keys blunt the impact of a sessionStorage compromise.
- Storage. Prefer
sessionStorageoverlocalStorageso the key is cleared on tab close. - Origin. CORS whitelists a single origin per deployment
(
cfg.CORSOrigin). The exchange endpoint requires a JWT from the same origin; cross-origin XHR with stolen cookies still needs to pass preflight. - Revocation. There’s no explicit revoke today — keys die at expiry. A cleanup cron is on the roadmap.
- Cross-workspace minting. Users can only mint for workspaces their JWT’s team owns — cross-team requests return 404 without leaking whether the workspace exists.
Reference implementations
Section titled “Reference implementations”web/src/lib/sdk.ts— production client helper with in-flight dedupe and 401 retry.web/src/pages/app/sdk/ChatPage.tsx— full chat UI including SSE event rendering and trace toggle.examples/chat/— the CLI equivalent, for comparing the flow end- to-end against a Go SDK consumer.
When not to use this
Section titled “When not to use this”If your consumer is a server (Go backend, Python batch job, Zapier integration, …), just use a regular workspace API key from Workspace Settings → API keys. The session-token exchange exists because browsers can’t safely hold a long-lived key — server-side consumers don’t have that problem.