Hosted mode
The default install puts one route on your server: createMordnHandler runs there,
reads your verified session, and talks to mordn with a secret key. That is the right
shape when you have a server.
Many apps do not. A Vite single-page app, a Lovable or Bolt project, a static site
backed by Supabase — there is no Node route to mount anything on. Hosted mode is the
same widget, the same agent, the same history and knowledge, with the handler running on
api.mordn.com instead of in your app.
import { useEffect, useState } from 'react';
import type { Session } from '@supabase/supabase-js';
import { ChatWidget } from '@mordn/chat-widget';
import '@mordn/chat-widget/styles.css';
import { supabase } from '@/lib/supabase'; // your existing browser client
export function Assistant() {
const [auth, setAuth] = useState<{
session: Session | null;
revision: number;
ready: boolean;
}>({ session: null, revision: 0, ready: false });
useEffect(() => {
// INITIAL_SESSION supplies the starting session. Keep this callback synchronous.
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setAuth((previous) => {
// Local transition detection only; these ids are NOT sent to mordn.
const changed = (previous.session?.user.id ?? null) !== (session?.user.id ?? null);
return { session, revision: previous.revision + (changed ? 1 : 0), ready: true };
});
});
return () => subscription.unsubscribe();
}, []);
if (!auth.ready) return null;
return (
<ChatWidget
publishableKey="pk_live_…"
authSessionKey={auth.revision}
getUserToken={() => auth.session?.access_token ?? null}
/>
);
}Requires @mordn/chat-widget 0.23.0 or later for the lifecycle API:
npm install '@mordn/chat-widget@^0.23.0' ai @ai-sdk/reactNo route, no MORDN_CHAT_KEY, no model credential. The auth subscription is part of
this install: it supplies both the current session and a reactive lifecycle revision.
If your app already has an auth provider, integrate this boundary there instead of
creating a second source of truth. See Login, logout, and account switches.
Note
Which mode? If your app has app/api, pages/api, a Remix loader or an Express
server, use the Quickstart — identity stays in your process and the
model runs on your credentials. If it does not, or you want the shortest path to a working
agent, you are in the right place. The two modes can run side by side on one agent.
How it works
publishableKey(pk_live_…) derives the endpoint: the widget talks tohttps://api.mordn.com/v1/hosted/<key>/…directly. The key is public — it selects the agent, it authenticates nobody. It is fine in client code and in git.- Every request must come from an origin you allowlisted on the agent. Anything else
is a
403(the browser reports it as a CORS failure). - The end user is identified by a token your identity provider signed — Supabase
Auth, Clerk, Auth0, Firebase, Cognito, or your own OIDC-style issuer.
getUserTokenhands it to the widget; mordn verifies the signature against the JWKS you configured and takes the user id from thesubclaim. Conversations are bound to that id exactly as they are bound togetUserIdin server mode. - Optionally, anonymous visitors are allowed: a browser with no token gets a random
visitor id (persisted in
localStorage) and its own history under a clearly-labelledanon:user id. - The model runs on mordn, on the model in the agent's published config, under a per-agent daily turn limit (default 2,000).
The identity boundary, hosted edition
The browser still never asserts who it is. In server mode it cannot — your route reads the
session. In hosted mode it can only present a token somebody else signed, and mordn
refuses anything it cannot verify: a bad or expired token is a 401, never a fallback to
anonymous. Symmetric secrets (HS256) are not supported, on purpose — verifying one would
mean holding your signing secret. Supabase projects on the legacy shared secret must switch
to asymmetric signing keys (Authentication → JWT Keys) first.
Enable it
Connect the mordn MCP server and ask for it. New agent:
Create a mordn agent for this Vite app in hosted mode. It runs on https://app.example.com and http://localhost:5173 and uses Supabase Auth at https://xyz.supabase.co.
Your agent calls mordn_create_agent with runtime: "hosted", origin,
additionalOrigins and supabaseUrl, gets the publishable key back, and writes the
component. Check that the generated component also implements the
auth lifecycle: a token getter alone is not a
login/logout subscription. Existing agent: mordn_enable_hosted_mode does the same for an agent you
already have — call it again later to add your production domain; the key stays the same.
Hosted settings are part of the agent's tenant plane. Authenticate with any server
key for the agent (mck_live_…, readwrite) — this is the one place a server key is
involved, and it never reaches the browser.
curl -X PUT https://api.mordn.com/v1/hosted/settings \
-H "Authorization: Bearer $MORDN_CHAT_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"allowedOrigins": ["https://app.example.com", "http://localhost:5173"],
"jwksUrl": "https://xyz.supabase.co/auth/v1/.well-known/jwks.json",
"issuer": "https://xyz.supabase.co/auth/v1",
"audience": "authenticated",
"anonymousEnabled": false
}'The response carries settings.publishableKey. GET the same URL to read it back;
POST /v1/hosted/settings/rotate-key issues a new key and retires the old one
immediately.
Settings
enabledboolean/v1/hosted/<key> endpoint on or off. Off = 404 for every request; the settings and key are kept.allowedOriginsstring[]scheme://host[:port], no path) allowed to use the key. Up to 50. Include your local dev origin while developing.jwksUrlstring | nullnull disables signed-in identity (anonymous only).issuerstring | nulliss claim. Recommended whenever you set jwksUrl.audiencestring | nullaud claim. Supabase uses authenticated; Clerk tokens have none.userClaimstringdefault: subanonymousEnabledbooleandefault: falseX-Mordn-Visitor id. Histories are stored under anon:<visitor>; long-term memory is off for them.dailyTurnLimitnumberdefault: 2000429 until midnight.Identity presets
| Setting | Value |
|---|---|
jwksUrl | https://<project>.supabase.co/auth/v1/.well-known/jwks.json |
issuer | https://<project>.supabase.co/auth/v1 |
audience | authenticated |
getUserToken={async () => (await supabase.auth.getSession()).data.session?.access_token ?? null}Use the subscribed example above for login/logout/account switches; this getter alone
only reads a token. onAuthStateChange can emit SIGNED_IN again for the same user,
and TOKEN_REFRESHED renews the same session: neither should increment the revision
when the user is unchanged. SIGNED_OUT followed by SIGNED_IN increments it twice,
even for the same user and even if React batches those updates. Always retain the
latest session so token maintenance reads the refreshed token.
The MCP tools derive all three from supabaseUrl. The project must be on
asymmetric JWT signing keys (ES256/RS256), which every project created since 2025 is.
| Setting | Value |
|---|---|
jwksUrl | https://<frontend-api>/.well-known/jwks.json — Frontend API URL from Clerk → API keys |
issuer | https://<frontend-api> |
audience | leave empty |
import { useAuth } from '@clerk/clerk-react'; // '@clerk/nextjs' in Next.js
import { ChatWidget } from '@mordn/chat-widget';
import '@mordn/chat-widget/styles.css';
export function Assistant() {
const { getToken, sessionId, isLoaded } = useAuth();
if (!isLoaded) return null;
return (
<ChatWidget
publishableKey="pk_live_…"
authSessionKey={sessionId ?? null}
getUserToken={() => getToken()}
/>
);
}Render under your existing Clerk provider (and in a client component in Next.js).
sessionId identifies the actual sign-in session, not just the account: it becomes
null on logout and a new session id on the next login, including the same user.
It stays stable during token renewal. Do not substitute userId or the token itself.
Waiting for isLoaded prevents treating unresolved auth as signed out.
Point jwksUrl at the provider's key set (Auth0: https://<tenant>/.well-known/jwks.json,
Firebase, Cognito, WorkOS, Kinde, …), set issuer to what the provider puts in iss,
and set userClaim if the stable id is not in sub. getUserToken returns the
provider's ID or access token — whichever is a JWT signed by that key set.
These providers keep the session in a cookie and give the browser no JWT. If you have
some server, add a tiny endpoint that reads the verified session and signs a
short-lived JWT (RS256/ES256, sub = user id, iss = your origin, ~10 min) with a key
whose public half you serve at /.well-known/jwks.json; then
getUserToken={() => fetch('/api/chat-token').then(r => r.ok ? r.json().then(j => j.token) : null)}.
If you have no server at all, use anonymous visitors.
Set jwksUrl: null and anonymousEnabled: true, and mount with the anonymous prop:
<ChatWidget publishableKey="pk_live_…" anonymous />Each browser profile gets its own history. Add getUserToken the day the app gains
sign-in; existing anonymous histories stay separate.
Login, logout, and account switches
In 0.23.0+, pass authSessionKey (string | number | null) from your host's auth
subscription. Change it on login, logout, or account switch. A local incrementing
revision is a good default; a provider's unique sign-in session id also works. Publish
the new session and revision together in React state, as in the Supabase example.
Never use a static key, an access token, or a freshly generated value on every render.
A user id alone cannot represent a new login session for the same account. If your auth
adapter coalesces logout/login without reporting the signed-out transition, increment
the revision on the actual new login or use a unique session id instead.
getUserToken is read through a ref: stable getters and inline callbacks both work.
A changed callback identity or an unrelated re-render does not re-resolve auth.
The widget re-asks every four minutes for token maintenance; this is not an auth-state
subscription. Keep the lifecycle key stable on same-session token refreshes.
When the key changes, the widget discards the previous token, bootstrap scope and mounted conversation UI. It waits for the latest getter to settle before bootstrapping: no old-token request or temporary anonymous bootstrap is sent while that resolution is pending. It then mounts a fresh UI using only the opaque storage scope supplied by the server. Obsolete token and bootstrap results cannot replace the new session. A getter that never settles keeps the widget waiting; there is no automatic auth timeout.
Imperative alternative: resetAuth
Attach a React ref typed as ChatWidgetHandle (exported by @mordn/chat-widget) and
call widget.current?.resetAuth() from your existing auth transition handler. It works
in every layout and is equivalent to changing authSessionKey, even if the resolved
token or anonymous visitor id is unchanged. It is a no-op in server-route mode.
Update the source read by getUserToken before calling resetAuth(). For a
ref-backed session source, assign the next session to that ref first, then reset.
For a getter closing over React state, prefer committing the session and
authSessionKey together; calling reset immediately after setState can read the old
closure. Use one transition mechanism, not both, and never reset on every render or
on ordinary token renewal. This must be wired into the host's real auth events; adding
a ref or a constant key alone is not an integration.
Identity and storage responsibilities
authSessionKeyandresetAuth()are local lifecycle signals, not authorization. The key is never sent to the runtime or used as a storage scope. Only a signed token verified by the server establishes identity; never send a plain user id as authority.- Use
getUserTokenfor hosted identity, notAuthorizationorX-Mordn-Visitorin genericheaders. Those headers are not an auth subscription. - On logout, return
null. With explicitanonymousopt-in on the widget andanonymousEnabledon the agent, the persisted random visitor may resume its own history. Otherwise the runtime decides whether an unauthenticated request is allowed. - Reset isolates mounted state but does not erase persisted history or drafts.
If your sign-out/account-switch policy requires erasure, also call the exported
clearChatStorage(). The anonymous visitor id is intentionally retained. - Clear or update your own
conversationId,initialMessages, and other user-specific props on the same transition. Reset cannot identify another account's caller-supplied data. - Reset does not revoke tokens or undo requests already received by the server.
The server must authorize every request. Server-route (
apiBase) identity still comes from the verified server session; these hosted props do not replace that boundary.
Widget props
publishableKeystringapiBase. Do not pass apiBase alongside it (the widget warns and ignores it).getUserToken() => Promise<string | null> | string | nullnull. Called on mount, every four minutes for token maintenance, and after a lifecycle reset. Callback identity is ignored. Bootstrap waits for each session's token resolution; use authSessionKey or resetAuth() for auth transitions.authSessionKeystring | number | nullanonymousbooleandefault: falseanonymousEnabled.hostedBaseUrlstringdefault: https://api.mordn.comAnonymous script-tag embeds can use these data attributes:
<script
src="https://unpkg.com/@mordn/chat-widget/dist/embed.global.js"
data-publishable-key="pk_live_…"
data-anonymous
></script>getUserToken has no declarative form; sign-in-aware embeds use
MordnChat.init({ publishableKey, getUserToken }).
Server mode or hosted mode?
| Server mode | Hosted mode | |
|---|---|---|
| Needs a server route | Yes | No |
| Identity | Your route reads your session | JWT your provider signed, verified by mordn |
| Secret in your deployment | MORDN_CHAT_KEY | None (publishable key is public) |
| Model runs on | Your gateway credentials, in your process | mordn |
| Anonymous visitors | Up to your getUserId | Opt-in per agent |
| Custom server tools / RAG in your code | Yes | Config-driven only (knowledge, hosted MCP tools) |
| Rate limit | Yours | Per-agent daily turn cap |
Verify before shipping
- From the allowed origin: open the widget, send a message, reload — the conversation is
still there.
bootstrapreturns200in the network tab. - From any other origin (or
curlwith noOrigin):403. - Signed out, without
anonymous: the widget shows the sign-in state; the request is401. Withanonymous: a fresh visitor gets an empty history, never someone else's. - Without reloading: log out, log back in as the same user, then switch accounts. Each transition must change the lifecycle key (or call reset after updating the source), and must never display the previous account's mounted content or caller-supplied props.
- A same-user token refresh or unrelated re-render must not increment your lifecycle key.
- Two signed-in users cannot see each other's conversations.
- Your production domain is in
allowedOriginsbefore you deploy.