Skip to main content

    Modelence Quickstart

    Ship a Behest-backed chat inside a Modelence app. Modelence already gives you auth + users out of the box, so all you need from Behest is a tiny mint mutation and a React client.

    Prerequisites: a Modelence project (npx create-modelence-app@latest my-app), a Behest project + key (behest_sk_live_...), Modelence's built-in auth enabled.


    1. Configure CORS (don't skip — silent killer)

    In Behest dashboard → Project → Settings → Allowed Origins, add every origin the browser will use:

    http://localhost:3000
    https://your-app.modelence.app
    https://your-custom-domain.com
    

    Click Save. Without this, every browser call fails with a CORS error before it reaches Behest. Exact-match: protocol + host + port, no trailing slash.


    2. Store the key

    Local dev

    Create or edit .modelence.env in your project root:

    env
    BEHEST_KEY=behest_sk_live_xxxxxxxxxxxx
    BEHEST_BASE_URL=https://your-slug.behest.app

    Production

    Modelence Cloud → your app → Environment Variables → add the same two keys. They sync on next deploy.

    BEHEST_KEY is never exposed to the browser — it only lives on the server. Swap behest_sk_live_... for behest_pk_... (+ BEHEST_KID, BEHEST_TENANT_ID, BEHEST_PROJECT_ID) later to switch to local-signing mode; no code change.


    3. Install the SDK

    bash
    npm install @behest/client-ts@beta openai
    • @behest/client-ts — server-only. Used inside the Modelence mutation to mint JWTs.
    • openai — used client-side to actually stream chat completions (Behest is OpenAI-SDK compatible).

    4. Create a Behest module

    Modelence groups server logic into modules under src/server/<feature>/. Add one for Behest:

    ts
    // src/server/behest/index.ts
    import { Module } from "modelence/server";
    import { Behest } from "@behest/client-ts";
     
    // Server-only. `new Behest()` reads BEHEST_KEY + BEHEST_BASE_URL from env.
    const behest = new Behest();
     
    export default new Module("behest", {
      mutations: {
        // Mint a short-lived per-user JWT. Called from the browser via
        // callMethod('behest.mint'). The browser then streams chat completions
        // against Behest directly with this token — no server pass-through.
        async mint(_args, { user }) {
          if (!user) {
            // Session is unauthenticated. Modelence's method runner will
            // surface this as a MethodError on the client.
            throw new Error("Unauthorized");
          }
     
          const { token, ttl, sessionId, expiresAt } = await behest.auth.mint({
            user_id: user.id, // Modelence user.id → Behest `uid` (1-to-1)
            ttl: 900,
          });
     
          // Return the base URL the token was minted against, so the browser
          // has nothing to hardcode. Single source of truth: the BEHEST_BASE_URL
          // env var from step 2.
          return {
            token,
            ttl,
            sessionId,
            expiresAt,
            baseUrl: process.env.BEHEST_BASE_URL!,
          };
        },
      },
    });

    Optional: tier lookup. Modelence's built-in user object exposes id, handle, and roles — not a billing tier. If you track tiers in a Modelence Store (e.g. dbProfiles), fetch before minting: const profile = await dbProfiles.findOne({ userId: user.id }); const tier = profile?.tier ?? 1; and pass tier to behest.auth.mint.

    Register it in your app entry:

    ts
    // src/server/app.ts
    import { startApp } from "modelence/server";
    import behestModule from "./behest";
    // ...your other modules
     
    startApp({
      modules: [behestModule /* , ... */],
    });

    5. Chat client

    The React client calls the Modelence mutation to get a token, then uses the OpenAI SDK to stream against Behest directly.

    tsx
    // src/client/Chat.tsx
    import { callMethod, useSession } from "modelence/client";
    import OpenAI from "openai";
    import { useRef, useState } from "react";
     
    type TokenBundle = {
      token: string;
      ttl: number;
      sessionId: string;
      expiresAt: number;
      baseUrl: string;
    };
     
    let cached: TokenBundle | null = null;
     
    async function getBehestToken(): Promise<TokenBundle> {
      const now = Math.floor(Date.now() / 1000);
      if (cached && cached.expiresAt - now > 60) return cached;
      cached = (await callMethod("behest.mint", {})) as TokenBundle;
      return cached;
    }
     
    export default function Chat() {
      const { user } = useSession();
      const [messages, setMessages] = useState<
        { role: "user" | "assistant"; content: string }[]
      >([]);
      const [input, setInput] = useState("");
      const abortRef = useRef<AbortController | null>(null);
     
      if (!user) return <p>Sign in to chat.</p>;
     
      async function send() {
        if (!input.trim()) return;
        const next = [...messages, { role: "user" as const, content: input }];
        setMessages([...next, { role: "assistant", content: "" }]);
        setInput("");
     
        abortRef.current = new AbortController();
        const { token, sessionId, baseUrl } = await getBehestToken();
     
        const openai = new OpenAI({
          apiKey: token,
          baseURL: `${baseUrl}/v1`,
          dangerouslyAllowBrowser: true, // safe: 15-min, per-user JWT
          defaultHeaders: { "X-Session-Id": sessionId },
        });
     
        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];
            copy[copy.length - 1] = {
              role: "assistant",
              content: copy[copy.length - 1].content + delta,
            };
            return copy;
          });
        }
      }
     
      return (
        <div>
          {messages.map((m, i) => (
            <p key={i}>
              <b>{m.role}:</b> {m.content}
            </p>
          ))}
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && send()}
          />
          <button onClick={() => abortRef.current?.abort()}>Stop</button>
        </div>
      );
    }

    6. Run it

    bash
    npm run dev

    Sign in (Modelence gives you /login out of the box), open the chat screen, send a message — the reply streams in. In the Behest dashboard → Usage, requests show up keyed to the signed-in Modelence user.id.


    Why this shape

    • BEHEST_KEY stays server-side. It's read only inside new Behest() in the module, never bundled to the client.
    • user_id comes from the verified Modelence session, not from the request body. The handler reads context.user.id (Modelence authenticates the RPC call before dispatch) and rejects unauthenticated callers — there's nothing to tamper with.
    • Tokens are per-user and short-lived. Safe to expose in the browser under dangerouslyAllowBrowser: true — they can only speak to Behest, only for this user, only for 15 minutes.
    • No chat bandwidth through your server. The browser streams from Behest directly.

    Troubleshooting CORS

    Browser console shows a CORS error? DevTools → Network → click the failed /v1/chat/completions or /v1/auth/mint request → Headers:

    • No Access-Control-Allow-Origin on the response → origin missing from Allowed Origins. Fix in dashboard, save, retry.
    • Header present but doesn't match → protocol/port/trailing-slash mismatch.
    • curl works but browser fails → always CORS. Browsers enforce; curl doesn't.

    Next steps

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

    Learn more