> This page is for Embed.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.spreadspace.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.spreadspace.app/_mcp/server.

# Plain JavaScript loader

> The @spreadspace/embed loader for embedding SpreadSpace in any page, as a module or a script tag: a token-isolated iframe, back and exit controls, theme sync, and token refresh.

**`@spreadspace/embed`** embeds a SpreadSpace surface in any web page through a
secure, token-isolated `<iframe>`, with no framework and no build step when you
load the script file. `mount()` renders the surface and wires the host side of
the embed protocol: back navigation, an Exit control, theme sync, and token
refresh that never exposes a token to your page. It draws the back arrow and
the Exit control in your page, over the frame, so a host without React gets the
full workspace experience. `@spreadspace/react` is a thin layer over this
loader, so both packages behave the same way.

## Install

As a module:

```bash
npm i @spreadspace/embed
```

Or as a script file, which defines a `SpreadSpaceEmbed` global with the same
members as the module:

```html
<script src="https://unpkg.com/@spreadspace/embed@1/dist/spreadspace-embed.iife.js"></script>
```

## Quick start

Call `mount()` with the element to render into and a `getHandle` callback.
`getHandle` calls **your** backend, which mints a single-use embed handle
server-side and returns the `signed_url` from the response. The loader loads
that URL as the iframe source. The callback runs on the initial load and again
on every token refresh, so your page never holds a long-lived credential.

```ts
import { mount } from '@spreadspace/embed';

const embed = mount('#spreadspace', {
  surface: 'spreading',
  loanId: '9f8b7c6d5e4a3b2c1d0e4f5a',
  // getHandle calls YOUR backend, which mints a handle server-side and returns
  // the `signed_url`. It receives { surface, loanId, reason? } and returns a
  // full embed URL, never a token.
  getHandle: async ({ surface, loanId }) => {
    const res = await fetch('/my-backend/spreadspace/iframe-url', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ surface, loanId }),
    });
    const { signed_url } = await res.json();
    return signed_url;
  },
  chrome: { exit: { href: '/applications/42' } },
});
```

From a script tag, the same call runs against the `SpreadSpaceEmbed` global:

```html
<div id="spreadspace" style="height: 100vh"></div>
<script src="https://unpkg.com/@spreadspace/embed@1/dist/spreadspace-embed.iife.js"></script>
<script>
  SpreadSpaceEmbed.mount('#spreadspace', {
    surface: 'spreading',
    loanId: '9f8b7c6d5e4a3b2c1d0e4f5a',
    getHandle: async ({ surface, loanId }) => {
      const res = await fetch('/my-backend/spreadspace/iframe-url', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ surface, loanId }),
      });
      const { signed_url } = await res.json();
      return signed_url;
    },
    chrome: { exit: { href: '/applications/42' } },
  });
</script>
```

The element you mount into is the frame's box. The default `heightMode` is
`'fill'`: the iframe takes the element's full height and the frame scrolls its
own content, so give the element a real height, such as `100vh` or a flex
track. The loader sets `position: relative` on the element when it has no
positioning of its own, so the controls can sit over the frame.

Your backend endpoint mints the handle and returns its `signed_url`. It reads
the embed-minting key, the one that holds `embed:write`, from
`SPREADSPACE_EMBED_API_KEY`. In TypeScript, with `@spreadspace/sdk`:

```ts
import { SpreadSpace } from '@spreadspace/sdk';

const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_EMBED_API_KEY! });

// The page sends { surface, loanId }. loan_id is the name on the SpreadSpace wire.
app.post('/my-backend/spreadspace/iframe-url', express.json(), async (req, res) => {
  const handle = await client.embed.iframeUrls.create({
    loan_id: req.body.loanId,
    surface: req.body.surface,
    scopes: ['documents:read', 'extractions:read', 'spreads:read'],
    external_user_id: req.session.userId,
  });
  res.json({ signed_url: handle.signed_url });
});
```

[Mint from your server](/embed/server) has the same mint in Python, C# and
cURL, and shows how to name write scopes so an analyst can finalize or upload.

`external_user_id` keeps the viewing user's boards, view settings, and memo
templates across sessions. Derive it from your backend's own session rather
than the browser request, so one user cannot open another's saved state. The
[Mint from your server](/embed/server#per-user-state) page covers the field's
contract.

## Options

| Option                            | Type                                 | Notes                                                                                                                                                                                                                       |
| --------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `surface`                         | `'spreading' \| 'documents'`         | Which surface to render.                                                                                                                                                                                                    |
| `loanId`                          | `string`                             | Loan the surface is scoped to. Must match the loan the handle was minted for.                                                                                                                                               |
| `getHandle`                       | `(ctx) => Promise<string> \| string` | Mints a fresh single-use handle. Called for the initial load and every refresh; `ctx.reason` is set on a refresh.                                                                                                           |
| `chrome`                          | `false \| { back?, exit? }`          | The controls drawn over the frame. Defaults to `{ back: true }`. See [Chrome](#chrome).                                                                                                                                     |
| `theme`                           | `'light' \| 'dark' \| 'system'`      | `'system'` (default) follows the page's `prefers-color-scheme` and tracks changes. Pushed to the frame when it loads and again when it reports ready.                                                                       |
| `heightMode`                      | `'grow' \| 'fill'`                   | `fill` (default) makes the iframe `height: 100%` of its element and the frame scrolls internally; `grow` sizes the iframe to the frame's reported content height, so your page scrolls.                                     |
| `initialHeight`                   | `string \| number`                   | `grow` only: the height before the frame's first report. Defaults to `'480px'`.                                                                                                                                             |
| `embedOrigin`                     | `string`                             | Override the embed app origin. Only needed when `getHandle` returns a bare handle id instead of the `signed_url`.                                                                                                           |
| `styleNonce`                      | `string`                             | Nonce copied onto the injected `<style>` element, for a `style-src` policy that admits nonced styles.                                                                                                                       |
| `onReady`                         | `(payload) => void`                  | Fired once the frame shell has mounted.                                                                                                                                                                                     |
| `onError`                         | `(payload) => void`                  | The mint failed or the frame reported an error; `fatal` distinguishes severity.                                                                                                                                             |
| `onUiState`                       | `(payload) => void`                  | Frame chrome state: `modalOpen` (a blocking overlay is up in the frame) and `canNavigateBack`. Sent once after ready and on every change. The loader's own controls already honor it; use this for any further host chrome. |
| `onNav`                           | `(payload) => void`                  | Called when the frame posts a `nav` message. The hosted surfaces post none: upload runs inside the frame when the session holds `documents:write`.                                                                          |
| `onDiagnostic`                    | `(message, detail?) => void`         | Dropped or rejected protocol messages.                                                                                                                                                                                      |
| `title` / `className` / `sandbox` |                                      | Iframe element passthroughs. `title` defaults to `'SpreadSpace'`.                                                                                                                                                           |

## Chrome

The loader draws two controls over the frame's top rail, in the seats the
frame reserves for them: a round back arrow at the left edge, which walks the
frame's own navigation back one step, and an Exit control at the right edge,
the one control that leaves SpreadSpace. Both follow the theme.

* `chrome: false` draws nothing.
* `chrome.back` (default `true`) draws the back arrow.
* `chrome.exit` is drawn only when you give it somewhere to go. `href` renders
  a link, `onExit` renders a button, and both together render a link that also
  calls `onExit`. `label` replaces the visible text `Exit`; keep it to one short
  word, because the frame reserves a fixed seat for it.

The back arrow is disabled until the frame reports ready, while the frame
reports a modal open, and whenever the frame reports nowhere to go back to.
Exit is disabled while the frame reports a modal open. A disabled control is
dimmed, inert and marked `aria-disabled="true"`.

The controls are sized in your page's `rem`, and the frame reserves matching
insets on its rail, so they never overlap the frame's own controls.

## Instance

`mount()` returns an instance:

| Member            | Notes                                                                                             |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| `iframe`          | The underlying `<iframe>` element.                                                                |
| `status`          | `'minting'`, `'loading'`, `'ready'` or `'error'`.                                                 |
| `ui`              | The frame's last `{ modalOpen, canNavigateBack }` report.                                         |
| `navigateBack()`  | Walks the frame's own navigation back one step. The frame does nothing when it has nowhere to go. |
| `reload()`        | Mints a fresh handle and reloads the frame, for example after the user re-authenticates.          |
| `setTheme(theme)` | Applies a theme to the controls and pushes it to the frame.                                       |
| `destroy()`       | Removes the iframe and the controls, and drops every listener, timer and pending refresh.         |

## Refresh

When the frame's token nears expiry or is rejected, the frame asks your page
for a fresh handle. The loader calls `getHandle` again with a `reason` of
`expiring` or `unauthorized` and answers the frame with the handle from the
URL you return; the frame is not reloaded. Return a fresh `signed_url` on every
call, because a handle is single-use.

## The security model

Your page never holds a SpreadSpace token. You give the loader a `getHandle`
function that calls your backend, which mints a short-lived, single-use handle
and returns a `signed_url`. The loader loads that URL as the iframe source.
Inside the iframe, which is same-origin to SpreadSpace, the handle is exchanged
for the real embed token, which stays inside the iframe. When the token nears
expiry or is rejected, the iframe asks your page over `postMessage` for a fresh
handle, the loader calls `getHandle` again, and the iframe re-exchanges it. A
handle is worthless off SpreadSpace's origin and expires in seconds; the token
never crosses the iframe boundary into your page.

The minted token holds the scopes the mint names and is locked to the single
`loan_id` the handle was minted for. Read scopes are granted by mint
authority; `documents:write`, `extractions:write`, `extractions:edit` and
`spreads:write` are granted only when the mint names them and only if the
minting key holds them. Inspect the returned `scopes` if your integration
depends on it.

## Scopes

The API key your backend uses to mint handles needs the **`embed:write`** scope.
Issue a key from the dashboard at **Settings → Live API** (or **Settings →
Sandbox API** for a test key) and grant it `embed:write`. The samples read
that key from `SPREADSPACE_EMBED_API_KEY`. The browser-facing token minted from
that handle never carries `embed:write`, so a leaked browser token cannot mint
further tokens or widen its own scope.

## Advanced: the protocol module

The typed message envelope and both messengers are exported, also as
`@spreadspace/embed/postmessage`, for integrators who want to drive a
hand-rolled iframe:

```ts
import { createHostMessenger, type NavPayload } from '@spreadspace/embed/postmessage';
```

`createHostMessenger` verifies the frame origin, pins inbound messages to your
iframe's `contentWindow`, correlates each token-refresh request and response by
id, and replies with a handle. The response payload is type-branded so that
including a token field is a compile error.