Skip to main content

    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:5173http://localhost:3000https://localhost:5173. No trailing slash.


    2. Scaffold

    bash
    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 openai

    Frontend env (safe to expose):

    bash
    # .env.local
    VITE_BEHEST_BASE_URL=https://amber-fox-042.behest.app

    3. 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_id MUST 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.

    ts
    // 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

    ts
    // 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)

    tsx
    // 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 (uid claim 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

    bash
    # 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 dev

    Open 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-Origin on the response → your origin isn't in Allowed Origins. Fix in dashboard, save, retry (takes effect on the next mint).
    • Access-Control-Allow-Origin present but doesn't match → you're running on a different port/protocol than configured. Add the exact origin string.
    • Works in curl but not the browser → almost always CORS. curl doesn't enforce it; browsers do.

    Next steps

    Enterprise Token FinOps: Enforce hard budgets and attribute costs per session.

    Learn more