React + Vite Quickstart
Ship a working Behest chat UI from a React + Vite app in 5 minutes. The BEHEST_KEY stays on a tiny backend — the browser only ever sees a short-lived JWT scoped to the signed-in user.
Prerequisites: Node 18+, a Behest project (any tier), and its key (
behest_sk_live_...) from https://behest.ai.
1. Configure CORS (don't skip — silent killer)
In dashboard → Project → Settings → Allowed Origins, add every origin that will call Behest from a browser:
http://localhost:5173
http://localhost:3000
https://your-app.vercel.app
https://your-app.com
Click Save. Behest stores these in Redis; Kong returns Access-Control-Allow-Origin: <your-origin> only for matching origins. Miss this step and every browser call fails with a cryptic CORS error — the request never even reaches your mint endpoint.
Protocol + host + port must match exactly.
http://localhost:5173≠http://localhost:3000≠https://localhost:5173. No trailing slash.
2. Scaffold
npm create vite@latest behest-chat -- --template react-ts
cd behest-chat && npm i
# Frontend: uses the OpenAI SDK directly with a server-minted JWT.
npm i openaiFrontend env (safe to expose):
# .env.local
VITE_BEHEST_BASE_URL=https://amber-fox-042.behest.app3. Tiny token server
Same pattern as any "exchange my session for a third-party token" endpoint. The Behest SDK is only imported here, on the backend.
Security:
user_idMUST be derived from a server-verified session (cookie, auth-provider token, etc.). Never read it from a client-supplied header, query, or body — a caller who does that can mint a JWT for any user in your tenant. See Supabase, Clerk, NextAuth.
// server/token.ts (Node/Express)
import express from "express";
import { Behest } from "@behest/client-ts";
import { requireSignedInUser } from "./auth"; // your app's session check
const app = express();
app.use(express.json());
// Reads BEHEST_KEY + BEHEST_BASE_URL from env.
const behest = new Behest();
app.post("/api/behest/token", async (req, res) => {
const user = await requireSignedInUser(req);
if (!user) return res.status(401).end();
const { token, ttl, sessionId, expiresAt } = await behest.auth.mint({
user_id: user.id,
tier: user.plan ?? 1,
ttl: 900, // 15 minutes
});
res.json({ token, ttl, sessionId, expiresAt });
});
app.listen(8787);Run it with BEHEST_KEY=behest_sk_live_... BEHEST_BASE_URL=https://amber-fox-042.behest.app tsx server/token.ts.
See auth-modes if you want local signing instead (swap the key prefix to behest_pk_ and set BEHEST_KID, BEHEST_TENANT_ID, BEHEST_PROJECT_ID — no code change).
4. Token helper with expiry-aware refetch
// src/behestToken.ts
type TokenBundle = {
token: string;
ttl: number;
sessionId: string;
expiresAt: number;
};
let cached: TokenBundle | null = null;
export async function getBehestToken(): Promise<TokenBundle> {
const now = Math.floor(Date.now() / 1000);
if (cached && cached.expiresAt - now > 60) return cached;
const r = await fetch("/api/behest/token", {
method: "POST",
credentials: "include",
});
if (!r.ok) throw new Error(`token fetch failed: ${r.status}`);
cached = (await r.json()) as TokenBundle;
return cached;
}5. Chat component (browser talks to Behest directly)
// src/Chat.tsx
import { useRef, useState } from "react";
import OpenAI from "openai";
import { getBehestToken } from "./behestToken";
const BASE_URL = `${import.meta.env.VITE_BEHEST_BASE_URL}/v1`;
export function Chat({ threadId }: { threadId?: string }) {
const [messages, setMessages] = useState<
{ role: "user" | "assistant"; content: string }[]
>([]);
const [text, setText] = useState("");
const abortRef = useRef<AbortController | null>(null);
async function send() {
if (!text.trim()) return;
const userMsg = { role: "user" as const, content: text };
const next = [...messages, userMsg];
setMessages([...next, { role: "assistant", content: "" }]);
setText("");
abortRef.current = new AbortController();
const { token, sessionId } = await getBehestToken();
const openai = new OpenAI({
apiKey: token,
baseURL: BASE_URL,
dangerouslyAllowBrowser: true, // safe: token is short-lived and per-user
defaultHeaders: {
"X-Session-Id": sessionId,
...(threadId ? { "X-Thread-Id": threadId } : {}),
},
});
const stream = await openai.chat.completions.create(
{ messages: next, stream: true },
{ signal: abortRef.current.signal }
);
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content ?? "";
setMessages((m) => {
const copy = [...m];
const last = copy[copy.length - 1];
copy[copy.length - 1] = { ...last, content: last.content + delta };
return copy;
});
}
}
return (
<div>
{messages.map((m, i) => (
<div key={i}>
<b>{m.role}:</b> {m.content}
</div>
))}
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
<button onClick={() => abortRef.current?.abort()}>Stop</button>
</div>
);
}Why dangerouslyAllowBrowser is safe here
OpenAI's SDK prints a warning when run in the browser because raw OpenAI keys leak if shipped to clients. The JWT you pass here is:
- Short-lived (15 min TTL by default),
- Scoped to one user (
uidclaim from your verified session), - Revokable via key rotation,
- Rate-limited per-user per-tier.
If it leaks, the blast radius is one user for 15 minutes — not your whole account.
6. Run
# Terminal 1 — token server
BEHEST_KEY=behest_sk_live_... BEHEST_BASE_URL=https://amber-fox-042.behest.app tsx server/token.ts
# Terminal 2 — Vite dev
npm run devOpen http://localhost:5173, sign in (via your app's auth), chat. Check https://behest.ai/app → Usage — requests are grouped by user_id.
Troubleshooting CORS quickly
If the browser console shows a CORS error, open DevTools → Network → click the failed /v1/chat/completions or /v1/auth/mint request → Headers tab:
- No
Access-Control-Allow-Originon the response → your origin isn't in Allowed Origins. Fix in dashboard, save, retry (takes effect on the next mint). Access-Control-Allow-Originpresent but doesn't match → you're running on a different port/protocol than configured. Add the exact origin string.- Works in
curlbut not the browser → almost always CORS.curldoesn't enforce it; browsers do.
Next steps
- Multi-conversation chat — threads + sessions end-to-end
- Streaming UI — cancel, reconnect, typewriter effect
- Error handling — 402 upgrade prompts, 429 backoff
- Auth providers: Supabase · Clerk · Auth0