Skip to content

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.

code
src/components/Assistant.tsx
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:

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

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

  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.

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.
userClaimstringdefault: sub
The claim that holds the stable user id.
anonymousEnabledbooleandefault: 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.
dailyTurnLimitnumberdefault: 2000
Chat turns per UTC day across all users of this agent. Over the limit, turns answer 429 until midnight.

Identity presets

SettingValue
jwksUrlhttps://<project>.supabase.co/auth/v1/.well-known/jwks.json
issuerhttps://<project>.supabase.co/auth/v1
audienceauthenticated
code
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.

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.
anonymousbooleandefault: false
Send a persisted visitor id when no token is available. Only honoured when the agent has anonymousEnabled.
hostedBaseUrlstringdefault: https://api.mordn.com
Override for self-hosted chat-api deployments.

Anonymous script-tag embeds can use these data attributes:

code
<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 modeHosted mode
Needs a server routeYesNo
IdentityYour route reads your sessionJWT your provider signed, verified by mordn
Secret in your deploymentMORDN_CHAT_KEYNone (publishable key is public)
Model runs onYour gateway credentials, in your processmordn
Anonymous visitorsUp to your getUserIdOpt-in per agent
Custom server tools / RAG in your codeYesConfig-driven only (knowledge, hosted MCP tools)
Rate limitYoursPer-agent daily turn cap

Verify before shipping

  • From the allowed origin: open the widget, send a message, reload — the conversation is still there. bootstrap returns 200 in the network tab.
  • From any other origin (or curl with no Origin): 403.
  • Signed out, without anonymous: the widget shows the sign-in state; the request is 401. With anonymous: 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 allowedOrigins before you deploy.

Next steps