Skip to content

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)
POST /api/auth/session-token
Authorization: Bearer <jwt>
Content-Type: application/json
{ "workspace_id": "uuid-of-a-workspace-the-user-can-access" }

Responses:

  • 201 Created with { api_key, key_id, key_prefix, expires_at } on success. api_key is returned once — store it client-side.
  • 401 if no JWT.
  • 404 if the workspace doesn’t belong to the user’s current team (non-existence and no-access collapse to the same shape on purpose).
  • 400 if workspace_id is 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).

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;
}

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.).

  • 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 sessionStorage over localStorage so 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.
  • 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.

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.