---
title: "Hosted mode"
description: "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.

```tsx title="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:

```bash
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](#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](/docs/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).

> **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

**With your coding agent (MCP)**

Connect the [mordn MCP server](/docs/mcp) 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](#login-logout-and-account-switches): 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.

**With the API**

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.

```bash
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


- **`enabled`** · `boolean`

  Turns the public `/v1/hosted/<key>` endpoint on or off. Off = `404` for every request; the settings and key are kept.

- **`allowedOrigins`** · `string[]`

  Exact origins (`scheme://host[:port]`, no path) allowed to use the key. Up to 50. Include your local dev origin while developing.

- **`jwksUrl`** · `string \| null`

  HTTPS URL of the JSON Web Key Set that signs your users' tokens. `null` disables signed-in identity (anonymous only).

- **`issuer`** · `string \| null`

  Expected `iss` claim. Recommended whenever you set `jwksUrl`.

- **`audience`** · `string \| null`

  Expected `aud` claim. Supabase uses `authenticated`; Clerk tokens have none.

- **`userClaim`** · `string` · default `sub`

  The claim that holds the stable user id.

- **`anonymousEnabled`** · `boolean` · 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.

- **`dailyTurnLimit`** · `number` · default `2000`

  Chat turns per UTC day across all users of this agent. Over the limit, turns answer `429` until midnight.


### Identity presets

**Supabase Auth**

| Setting | Value |
| --- | --- |
| `jwksUrl` | `https://<project>.supabase.co/auth/v1/.well-known/jwks.json` |
| `issuer` | `https://<project>.supabase.co/auth/v1` |
| `audience` | `authenticated` |

```tsx
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.

**Clerk**

| Setting | Value |
| --- | --- |
| `jwksUrl` | `https://<frontend-api>/.well-known/jwks.json` — Frontend API URL from Clerk → API keys |
| `issuer` | `https://<frontend-api>` |
| `audience` | leave empty |

```tsx
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.

**Any OIDC provider**

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.

**Cookie sessions (Auth.js, Lucia, custom)**

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.

**Anonymous only**

Set `jwksUrl: null` and `anonymousEnabled: true`, and mount with the `anonymous` prop:
```tsx
<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


- **`publishableKey`** · `string`

  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.

- **`authSessionKey`** · `string \| 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.

- **`anonymous`** · `boolean` · default `false`

  Send a persisted visitor id when no token is available. Only honoured when the agent has `anonymousEnabled`.

- **`hostedBaseUrl`** · `string` · default `https://api.mordn.com`

  Override for self-hosted chat-api deployments.


Anonymous script-tag embeds can use these data attributes:

```html
<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. `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

**[Vite + Supabase quickstart](/docs/quickstart/vite)**

The end-to-end version for a Vite SPA.

**[MCP server](/docs/mcp)**

Let your coding agent enable hosted mode and write the component.

**[Security model](/docs/security)**

Why browser identity is never authorization.

**[Theming](/docs/guides/theming)**

Popup, inline, and the full-page layout.
