Skip to content
mordn
Esc
↑↓navigate↵open⌘Jpreview
On this page

Hosted mode

Run the chat agent with no server route. Mount the widget with a browser-safe publishable key; mordn runs the handler and verifies your users from the token your auth provider already signs.

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

No 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.

How it works

  1. publishableKey (pk_live_…) derives the endpoint: the widget talks to https://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.
  2. 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).
  3. The end user is identified by a token your identity provider signed — Supabase Auth, Clerk, Auth0, Firebase, Cognito, or your own OIDC-style issuer. getUserToken hands it to the widget; mordn verifies the signature against the JWKS you configured and takes the user id from the sub claim. Conversations are bound to that id exactly as they are bound to getUserId in server mode.
  4. 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-labelled anon: user id.
  5. The model runs on mordn, on the model in the agent’s published config, under a per-agent daily turn limit (default 2,000).

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
Turns the public /v1/hosted/<key> endpoint on or off. Off = 404 for every request; the settings and key are kept.
allowedOriginsstring[]
Exact origins (scheme://host[:port], no path) allowed to use the key. Up to 50. Include your local dev origin while developing.
jwksUrlstring | null
HTTPS URL of the JSON Web Key Set that signs your users’ tokens. null disables signed-in identity (anonymous only).
issuerstring | null
Expected iss claim. Recommended whenever you set jwksUrl.
audiencestring | null
Expected aud claim. Supabase uses authenticated; Clerk tokens have none.
userClaimstring
Default: sub
The claim that holds the stable user id.
anonymousEnabledboolean
Default: false
Accept requests with no token, identified by the widget’s X-Mordn-Visitor id. Histories are stored under anon:<visitor>; long-term memory is off for them.
dailyTurnLimitnumber
Default: 2000
Chat turns per UTC day across all users of this agent. Over the limit, turns answer 429 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

  • authSessionKey and resetAuth() 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 getUserToken for hosted identity, not Authorization or X-Mordn-Visitor in generic headers. Those headers are not an auth subscription.
  • On logout, return null. With explicit anonymous opt-in on the widget and anonymousEnabled on 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

publishableKeystring
Turns on hosted mode and derives apiBase. Do not pass apiBase alongside it (the widget warns and ignores it).
getUserToken() => Promise<string | null> | string | null
Returns the signed-in user’s token or null. 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 | null
Hosted mode, 0.23.0+: reactive local lifecycle marker. Change on login/logout/account switch; keep stable on same-session token refresh. Never sent as identity or used as a storage scope.
anonymousboolean
Default: false
Send a persisted visitor id when no token is available. Only honoured when the agent has anonymousEnabled.
hostedBaseUrlstring
Default: https://api.mordn.com
Override for self-hosted chat-api deployments.

Anonymous 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

Next steps