> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moda.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Browser SDK

> Control the Moda iframe with the versioned postMessage protocol.

The browser side of Canvas Embed SDK is a small `postMessage` protocol between your page and the Moda iframe.

<Note>
  The beta protocol version is `2026-05-27`. Always send and validate the `channel`, `version`, and `sessionId` fields.
</Note>

<Note>
  **There are two version numbers, and they are not the same.** They live in different layers and are set in different places:

  | Where                      | Field          | Value        | Layer                         |
  | -------------------------- | -------------- | ------------ | ----------------------------- |
  | Session API request header | `Moda-Version` | `2026-05-01` | REST API (your backend)       |
  | postMessage envelope field | `version`      | `2026-05-27` | Browser protocol (the iframe) |

  They version independently and will diverge further over time. The iframe **silently drops** any message whose `version` doesn't match the protocol version, so a transposed value fails with no error — send `2026-05-27` in the envelope, and `2026-05-01` in the API header. See [Session API](/canvas-embed/session-api) for the header.
</Note>

## Message envelope

Every command and event uses the same envelope:

```ts theme={null}
type ModaEmbedMessage<TPayload = unknown> = {
  channel: 'moda.embed';
  version: '2026-05-27';
  sessionId: string;
  requestId?: string;
  type: string;
  payload?: TPayload;
};
```

Use `requestId` to correlate command responses.

## Minimal helper

```ts title="moda-embed-client.ts" theme={null}
const CHANNEL = 'moda.embed';
const VERSION = '2026-05-27';

type Listener = (payload: unknown, message: any) => void;

export class ModaEmbedClient {
  private listeners = new Map<string, Set<Listener>>();
  private targetOrigin: string;

  constructor(
    private iframe: HTMLIFrameElement,
    private sessionId: string,
    embedUrl: string
  ) {
    this.targetOrigin = new URL(embedUrl).origin;
    window.addEventListener('message', this.handleMessage);
  }

  destroy() {
    window.removeEventListener('message', this.handleMessage);
    this.listeners.clear();
  }

  on(type: string, listener: Listener) {
    const set = this.listeners.get(type) ?? new Set<Listener>();
    set.add(listener);
    this.listeners.set(type, set);
    return () => set.delete(listener);
  }

  post(type: string, payload?: unknown, requestId = crypto.randomUUID()) {
    this.iframe.contentWindow?.postMessage(
      {
        channel: CHANNEL,
        version: VERSION,
        sessionId: this.sessionId,
        requestId,
        type,
        payload,
      },
      this.targetOrigin
    );
    return requestId;
  }

  private handleMessage = (event: MessageEvent) => {
    if (event.origin !== this.targetOrigin) return;
    if (event.source !== this.iframe.contentWindow) return;

    const message = event.data;
    if (message?.channel !== CHANNEL) return;
    if (message.version !== VERSION) return;
    if (message.sessionId !== this.sessionId) return;

    this.listeners.get(message.type)?.forEach((listener) => listener(message.payload, message));
    this.listeners.get('*')?.forEach((listener) => listener(message.payload, message));
  };
}
```

The `iframe` you pass in needs the right capability attributes — clipboard paste and the file picker for image upload depend on them:

```html theme={null}
<iframe
  src="EMBED_URL"
  title="Moda canvas"
  allow="clipboard-read; clipboard-write; fullscreen"
  referrerpolicy="strict-origin-when-cross-origin"
  style="width: 100%; height: 100%; border: 0"
></iframe>
```

Export downloads happen in **your** page (you call `a.click()` on the returned `dataUrl`), so the iframe needs no `downloads` permission. See [Security and Production](/canvas-embed/security-production#iframe-attributes) for the full rationale and CSP guidance.

## Handshake and lifecycle

There are **two distinct milestones**, signalled by two different events:

1. **Session ready** — the iframe posts `ready` once, as soon as it has loaded its session. Payload is minimal: `{ canvasId, mode, externalUser? }`. After this you can send session-scoped commands (`save`, `chat.send`, `setReadonly`).
2. **Canvas interactive** — the canvas finishes loading and rendering separately. This is signalled by `canvas:changed` with `{ status: 'loaded', page, pageCount, category, pages }` (and an initial `page:changed`). Gate page/export/edit commands (`focusPage`, `export`, `insertText`, `insertImage`) on this, not on `ready`.

`ready` does **not** fire a second time with page info — page state arrives via `canvas:changed`/`page:changed`. (You *can* get a `ready` carrying page state, but only as a reply to `host.hello` — see below.)

### Use `host.hello` to win the load race

The iframe's first `ready` can fire before your page has attached its `message` listener (a classic iframe-load race). `host.hello` is the robust fix: the iframe replies with `ready` every time it receives one, and that reply **includes the current page state if the canvas is already interactive**. Poll it until you get a `ready`, then stop:

```ts theme={null}
client.on('ready', (payload) => {
  console.log('Moda embed ready', payload);
  // payload includes { page, pageCount, category, pages } when this `ready`
  // is a host.hello reply sent after the canvas became interactive.
});

// Poll host.hello until the first `ready`, then clear the interval.
const helloTimer = setInterval(() => {
  client.post('host.hello', { parentOrigin: window.location.origin });
}, 250);
client.on('ready', () => clearInterval(helloTimer));
```

## Commands

### Save

```ts theme={null}
client.post('save');
```

Events:

* `canvas:saving`
* `canvas:saved`
* `canvas:save_error`

### Export

```ts theme={null}
client.post('export', { format: 'png', page: 1 });
client.post('export', { format: 'pdf' });
client.post('export', { format: 'pptx' });
```

PDF exports emit a selectable text layer by default. Pass `flatten: true` to rasterize every
page instead — useful when a canvas uses a custom font whose glyphs must be reproduced
exactly, at the cost of selectable text and a larger file:

```ts theme={null}
client.post('export', { format: 'pdf', flatten: true });
```

`export:completed` returns the file inline as a base64 `dataUrl` — the host triggers the download, the iframe never does:

```ts theme={null}
client.on('export:completed', (payload: any) => {
  const a = document.createElement('a');
  a.href = payload.dataUrl;
  a.download = payload.filename;
  a.click();
});
```

<Warning>
  The entire export is base64-encoded and passed inline through `postMessage` — there is **no size cap, chunking, or hosted-URL fallback**, and the SDK times out after \~120s. A single PNG/JPEG is fine, but a multi-page PDF or PPTX (or a multi-page raster export, which comes back as a `data:application/zip` URL) can run to many megabytes in memory. For large or multi-page documents, prefer the [Public API export](/canvas-embed/common-workflows#export-from-the-iframe) — it returns a durable URL and supports background polling.
</Warning>

### Focus a page

```ts theme={null}
client.post('focusPage', { page: 2 });
```

Event:

* `page:changed` with `{ page, pageCount, category, pages }`

### Toggle readonly

```ts theme={null}
client.post('setReadonly', { readonly: true });
client.post('setReadonly', { readonly: false });
```

This is useful when your app needs to temporarily block editing while another workflow is running. It cannot grant editing to a non-edit session.

### Insert text

```ts theme={null}
client.post('insertText', {
  text: 'Approved by legal',
  x: 120,
  y: 120,
  width: 360,
  height: 80,
});
```

### Insert image

Use `File` or `Blob` when the user picks or pastes a local image. The iframe uploads it using its scoped embed browser credential.

Embed uploads currently accept PNG, JPEG, GIF, and WebP files up to 25 MB. SVG uploads are not accepted in embedded sessions.

```ts theme={null}
client.post('insertImage', {
  file,
  filename: file.name,
  mimeType: file.type,
  x: 160,
  y: 220,
  width: 400,
});
```

Use `url` when your app already has an HTTPS image URL that Moda can render.

```ts theme={null}
client.post('insertImage', {
  url: 'https://cdn.example.com/generated/product-shot.png',
  x: 160,
  y: 220,
  width: 400,
});
```

### Chat modes

For `ui.chat: "inside"`, the iframe renders Moda's hosted chat panel. Your app does not need to send chat commands, but it can still listen for `chat:*` events for analytics, logging, or mirrored UI.

For `ui.chat: "external"`, your app owns the chat UI and sends prompts to the Moda agent.

```ts theme={null}
client.post('chat.send', { message: 'Make this design more concise and on-brand.' });
client.post('chat.stop');
client.post('chat.newThread');
```

#### `chat:message` — the one that matters

Each `chat:message` is a **complete, self-contained message appended to the conversation — not an incremental delta.** The text lives in `text`, and the author in `sender`. Do not accumulate or diff these; render each as its own bubble keyed by `id`.

```ts theme={null}
type ChatMessagePayload = {
  id: string;                          // stable per message — use as your render key
  sender: 'user' | 'ai' | 'system';
  text: string;                        // full message text (NOT a delta)
  timestamp: string;                   // ISO 8601
};

client.on('chat:message', (payload: ChatMessagePayload) => {
  appendChatBubble(payload.sender, payload.text); // no `?? message ?? delta ?? content`
});
```

Incremental, in-flight streaming (thinking, tool steps, progress) is surfaced through `chat:progress` and `chat:tool_call`, never as `chat:message` deltas.

#### All chat events

| Event                | Payload                                                                                                                                                              |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat:request_ack`   | `{ status: 'queued' \| 'running' \| 'rejected' \| 'busy' \| 'duplicate' \| 'started' \| 'queued_client', requestId?, queuePosition?, reason?, message?, threadId? }` |
| `chat:message`       | `{ id, sender: 'user' \| 'ai' \| 'system', text, timestamp }` — full message, see above                                                                              |
| `chat:progress`      | Streaming progress (thinking / tool-progress); shape varies — `{ tool_call_id?, tool_name?, status?, message? }`                                                     |
| `chat:tool_call`     | `{ executionId, name, status: 'started' \| 'completed' \| 'failed', result?, message? }`                                                                             |
| `chat:canvas_update` | `{ canvasId, requestId? }` — the agent changed the canvas; re-read state if you mirror it                                                                            |
| `chat:completed`     | `{ summary, request_id?, message_id?, suggestions? }`, or `{ status: 'new_thread' }` after `chat.newThread`                                                          |
| `chat:stopped`       | `{ summary?, request_id? }`, or `{ status: 'stopping' }`                                                                                                             |
| `chat:error`         | Always carries `message`; may include `code`, `request_id`, and `action: 'reauthenticate' \| 'retry' \| 'contact_support' \| 'none'`                                 |

### Refresh session

The iframe proactively emits `session:refresh_requested` ahead of expiry (by default 60
seconds before the browser token lapses), so you can refresh in response instead of
running your own timer:

```ts theme={null}
client.on('session:refresh_requested', async () => {
  const refreshed = await refreshEmbedSession();

  client.post('session.refresh', {
    token: new URL(refreshed.embed_url).searchParams.get('token'),
    expiresAt: refreshed.expires_at,
    browserToken: refreshed.browser_token,
    browserTokenExpiresAt: refreshed.browser_token_expires_at,
  });
});
```

If a refresh doesn't land in time, the iframe emits `session:expired` (or `session:revoked`)
so you can surface a re-authentication prompt.

## Events

| Event                       | Payload                                                                                                                                                                                                                                                                                                                                                 |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready`                     | `{ canvasId, mode, externalUser? }`. Only the `host.hello` reply variant additionally includes `{ page, pageCount, category, pages }` (and only once the canvas is interactive) — the initial `ready` does not.                                                                                                                                         |
| `size:changed`              | `{ width, height }`                                                                                                                                                                                                                                                                                                                                     |
| `selection:changed`         | Current selection metadata                                                                                                                                                                                                                                                                                                                              |
| `canvas:changed`            | Variants by context: on load `{ canvasId, status: 'loaded', page, pageCount, category, pages }`; on command `{ command, nodeId? }` or `{ readonly }`                                                                                                                                                                                                    |
| `canvas:saving`             | `{ canvasId, autoSave }`                                                                                                                                                                                                                                                                                                                                |
| `canvas:saved`              | `{ canvasId, version, savedAt, autoSave }`                                                                                                                                                                                                                                                                                                              |
| `canvas:save_error`         | `{ canvasId, message, autoSave }`                                                                                                                                                                                                                                                                                                                       |
| `page:changed`              | `{ page, pageCount, category, pages }`; `page` is 1-based and `pages` contains `{ id, name, hidden }`                                                                                                                                                                                                                                                   |
| `export:completed`          | `{ status, canvasId, format, filename, mimeType, dataUrl, pageCount, pages }`                                                                                                                                                                                                                                                                           |
| `export:failed`             | `{ canvasId, format, message }`                                                                                                                                                                                                                                                                                                                         |
| `session:refresh_requested` | `{ expiresAt, msUntilExpiry, scope }` — proactive early warning that the session is approaching expiry. Fired ahead of expiry (by default 60 seconds before the browser token lapses). `scope` is `'browser_token'` or `'session'` (whichever expires first). Respond by refreshing and posting `session.refresh`.                                      |
| `session:refreshed`         | `{ expiresAt }` — the iframe's acknowledgement of a `session.refresh` command                                                                                                                                                                                                                                                                           |
| `session:expired`           | `{ expiresAt, msUntilExpiry: 0, scope }` — the session/browser token has expired. Emitted at the scheduled expiry, or immediately if a request returns a `401` with an `expired` code. Fires at most once per token.                                                                                                                                    |
| `session:revoked`           | `{ expiresAt, msUntilExpiry: 0, scope }` — a request returned a `401` with a `revoked` code (the session was revoked server-side).                                                                                                                                                                                                                      |
| `load:error`                | `{ message, code?, status? }`. For session/auth failures, `status` is the HTTP status (`401`/`403`/`404`/`503`) and `code` the category (`authentication`/`permission`/`not_found`/`upstream_error`). Client-side failures (renderer init, command validation) carry `message` only. See the [error-code table](/canvas-embed/session-api#error-codes). |

## Keyboard and paste behavior

When the iframe has focus, Moda handles normal editor shortcuts:

* Undo/redo
* Copy, cut, paste
* Pasted image files and screenshots
* Delete, duplicate, group, ungroup, arrange
* Arrow nudging
* Tool shortcuts like `V`, `T`, `R`, `O`, `L`, and `P`

When your parent app has focus, keyboard events belong to your app. If you want parent-level buttons or shortcuts, send explicit commands such as `save`, `export`, or `chat.send`.
