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:
BEHEST_KEY=behest_sk_live_xxxxxxxxxxxx
BEHEST_BASE_URL=https://your-slug.behest.appProduction
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
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:
// 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
userobject exposesid,handle, androles— 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 passtiertobehest.auth.mint.
Register it in your app entry:
// 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.
// 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
npm run devSign 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_KEYstays server-side. It's read only insidenew Behest()in the module, never bundled to the client.user_idcomes from the verified Modelence session, not from the request body. The handler readscontext.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-Originon the response → origin missing from Allowed Origins. Fix in dashboard, save, retry. - Header present but doesn't match → protocol/port/trailing-slash mismatch.
curlworks but browser fails → always CORS. Browsers enforce;curldoesn't.
Next steps
- Multi-conversation chat — thread list + history
- Error handling — 402 upgrade prompt, 429 backoff
- Auth modes — switch to local signing (zero outbound mint calls)
- Streaming UI — cancel, reconnect, typewriter