---
name: getting-started
description: >
  Wire up @persona/layout-editor-remote-module in a Persona dashboard app: register
  personaLayoutModules in vite.config.ts, author modules with defineLayoutModule using an .html
  document entry, add a zod configSchema and an RJSF uiConfigSchema (labels, field order,
  DataSourceField pickers), and talk to the host from the iframe with LayoutModuleBridge (layout
  data, host context such as layoutType / layoutObjectId / isReadOnly / dynamicHeight,
  resolveDataPath, height, loaded signal). Activate when adding a layout module, integrating this
  package for the first time, wiring a module document to the dashboard host, or debugging a
  manifest that fails to build or validate (duplicate key, missing defaultSize entry, render path
  not found), a module that never receives data from the host, a host loading card that never
  clears, or an iframe whose height collapses or keeps growing under dynamic height.
metadata:
  type: core
  library: '@persona/layout-editor-remote-module'
---

# Getting started with @persona/layout-editor-remote-module

Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — lets Persona
dashboard apps declare a layout module directly in a typical app's `vite.config.ts`, without editing
the host. The plugin ensures each module's HTML document is built and emits a
`{ modules: RemoteModuleRegistrationManifestV1[] }` JSON document the host fetches and validates.

This package is publicly available from the npm registry.

## Install

```sh
yarn add @persona/layout-editor-remote-module
```

`vite` (`^6 || ^7 || ^8`) and `zod` are peer dependencies. Vite 5 is unsupported because the plugin
relies on the Vite 6 Environment API; `react` / `react-dom` (`^17 || ^18 || ^19`) are optional peers
needed only by the `bridge-frame` entry.

Build the iframed document against `@persona/layout-editor-remote-module` 0.5.3 or later. Older
frames reject any host message that carries a key they don't know, so they stop working once the
host sends new context fields such as `dynamicHeight`.

## Setup (vite.config.ts)

```ts
import { defineConfig } from 'vite';
import {
  defineLayoutModule,
  personaLayoutModules,
} from '@persona/layout-editor-remote-module/vite-plugin';

export default defineConfig({
  plugins: [
    personaLayoutModules({
      modules: [
        defineLayoutModule({
          key: 'investigation_console',
          displayName: 'Investigation Console',
          descriptionShort: 'Full investigation workspace',
          allowAddingToLayout: true,
          layouts: ['case'],
          defaultSize: { case: { width: 12, height: 40 } },
          render: { entry: './index.html' },
        }),
      ],
    }),
  ],
});
```

An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists
specific layouts, `defaultSize` must provide an entry for each one.

Optional fields: `allowDynamicHeight` (exposes the option only; the host sizes the iframe to the
module's reported height only when the user turns "dynamic height" on for that placement, and the
module learns this through [`context.dynamicHeight`](#host-context)), `badge`
(`'beta' | 'deprecated' | 'improved' | 'new'`), `category`
(`'build_your_own' | 'components' | 'management' | 'related_records'`; the host defaults to
`build_your_own`), `categoryCustom` (a free-form module-library category label, up to 80 characters;
the host renders it verbatim — it is not translated — and groups modules that share the same
trimmed, case-sensitive string, even across different module sources. When both are set,
`categoryCustom` overrides `category`, and a label equal to one of the host's built-in category
labels lands in "Other"; keep setting `category` as a fallback for hosts that predate
`categoryCustom`), `icon` (an image path relative to the module's origin, such as
`/assets/icon.svg`; the library shows a generic icon when it's omitted, fails to load, or is
rejected as a cross-origin or non-http(s) URL), `internalUsersOnly` (a hint the host may use to
narrow visibility, never widen it), and `configSchema` / `uiConfigSchema` (below).

`entry` is resolved relative to the Vite **root** (`config.root`, or `process.cwd()` when unset) —
not relative to `vite.config.ts`'s own directory when `root` is overridden elsewhere in the config.

In a multi-environment build (e.g. with `@cloudflare/vite-plugin`), the plugin adds module documents
only to the `client` environment's build inputs and writes the manifest only into the client
environment's `build.outDir`. Non-client environments (workers/SSR) never see the `.html` entries.

Source: `src/vite-plugin.ts` (`configEnvironment`, `closeBundle`).

## Core Patterns

### Document delivery — iframed page

A module's `render.entry` must end in `.html`: the host iframes the page. Any other extension fails
during Vite config resolution with a clear error. Use `path` for a client-routed SPA whose
`index.html` already renders the whole app — it names the logical route the SPA's own router should
land on, not the file's build output path:

```ts
defineLayoutModule({
  key: 'investigation_console',
  displayName: 'Investigation Console',
  descriptionShort: 'Full investigation workspace',
  allowAddingToLayout: true,
  layouts: ['case'],
  defaultSize: { case: { width: 12, height: 40 } },
  render: { entry: './index.html', path: '/embed/investigations/console' },
});
```

If the `.html` file is already one of the app's own build entries (its main `index.html`), the
plugin detects that and doesn't inject it again.

### `configSchema` — validated module configuration

Author config as zod; the plugin converts it to JSON Schema for the host's config panel:

```ts
import { z } from 'zod';

defineLayoutModule({
  layouts: ['case', 'account'],
  configSchema: z.object({ ownerId: z.string().default('current_case.id') }),
  defaultConfig: { account: { ownerId: 'current_account.id' } },
});
```

`defaultConfig` overrides top-level schema defaults for new placements. Each set key must validate
against `configSchema` and, when `layouts` is non-empty, be listed there. Keep `configSchema` to
plain data shapes; hosts predating `defaultConfig` ignore it, so retain a sensible schema default.

### `uiConfigSchema` — config panel layout

The host renders the config panel with RJSF. Pass an RJSF `uiSchema` as `uiConfigSchema`; it is
already plain data, so the plugin copies it into the manifest unchanged. Its top-level keys are the
`configSchema` property names, and each one holds that property's `ui:*` entries:

```ts
defineLayoutModule({
  // ...
  configSchema: z.object({
    threshold: z.number().min(0).max(1).default(0.7),
    title: z.string().optional(),
  }),
  uiConfigSchema: {
    'ui:order': ['title', 'threshold'],
    threshold: { 'ui:widget': 'range' },
  },
});
```

- **Labels and help text** come from the property's JSON Schema `title` / `description` — set them
  with zod's `.meta({ title, description })` — and fall back to the property name. Override them per
  field with `'ui:options': { title, description }`; the description shows as the label's tooltip.
- **Field order** is `'ui:order'` at the top level (use `'*'` for "everything else").
- **Controls are picked from the schema** without a `uiConfigSchema` entry: an enum renders as a
  dropdown, an array of enum values with `uniqueItems: true` in its JSON Schema as a multi-select, a
  boolean as a checkbox, and an array of objects as a list whose items open in an editing panel.
- **Everything is JSON.** The manifest is static JSON, so function-valued RJSF options
  (`createItem`, `itemLabel`, …) and custom React fields/widgets cannot be supplied by a module —
  only the `ui:field` names and options the host already registers, listed below.

#### `DataSourceField` — pick a value from the record

`'ui:field': 'DataSourceField'` turns a property into the host's data-source input: an
autocompleting method-chain input (e.g. `current_case.id`, or `query.<variable>` for a layout
variable) checked against the platform schema. Declare the property as a **string** — the field then
stores the chain itself as a string:

```ts
defineLayoutModule({
  // ...
  layouts: ['account'],
  defaultSize: { account: { width: 6, height: 24 } },
  configSchema: z.object({
    accountId: z.string().optional(),
  }),
  uiConfigSchema: {
    accountId: {
      'ui:field': 'DataSourceField',
      'ui:options': {
        title: 'Account ID',
        description: 'Account whose transactions are monitored',
      },
    },
  },
});
```

`ui:options` it reads:

- `title`, `description` — label and tooltip; default to the schema's `title` / `description`.
- `expectedSchemaType` — the platform object type the chain must resolve to (e.g. `'case'`,
  `'user'`); a chain resolving to another type is flagged. Unset means any chain is accepted. An
  empty value is never validated.
- `rootPath` — limits validation to chains under one top-level property (e.g. `'current_case'`).
  Only set it when `layouts` names a single layout that has that root; a chain outside `rootPath`
  still saves but is not validated.

The config holds the **chain, not its value**. Resolve it at render time with `resolveDataPath` from
`LayoutModuleBridge` (below), skipping an unset field:

```tsx
import { useEffect, useState } from 'react';
import {
  type Json,
  type LayoutModuleBridgeRenderProps,
} from '@persona/layout-editor-remote-module/bridge-frame';

function TransactionMonitor({
  data,
  resolveDataPath,
}: Pick<LayoutModuleBridgeRenderProps, 'data' | 'resolveDataPath'>) {
  const chain = (data as { accountId?: string } | null)?.accountId;
  const [accountId, setAccountId] = useState<Json>(null);

  useEffect(() => {
    setAccountId(null);
    if (!chain) return;
    let current = true;
    resolveDataPath(chain).then(
      (value) => current && setAccountId(value),
      () => current && setAccountId(null),
    );
    return () => {
      current = false;
    };
  }, [chain, resolveDataPath]);

  // ...
}
```

The host pushes config updates into the mounted module, so the chain can change or be cleared at any
time: reset the resolved value whenever it does, and ignore responses to an outdated chain.
`resolveDataPath` resolves to `null` when the chain can't be resolved on the current record and
rejects on a host error or timeout, so handle both.

#### Other `ui:options`

- `errorMessages` — per-keyword validation messages, e.g.
  `{ 'ui:options': { errorMessages: { pattern: 'Use letters only.' } } }` (keys are JSON Schema
  keywords: `pattern`, `minLength`, `maximum`, …).
- On an enum multi-select: `unsetSelectsAll: true` shows an unset value as every option selected,
  for a key whose absence means "all" and should stay absent.
- On an array of objects: `identifierField` (item property that labels each row), `emptyText`,
  `editLabel` (the editing panel's title), `listAriaLabel`, and `inlineItems: true` to render the
  items inline instead of as a list with an editing panel.

### `bridge-frame` — talking to the host from the iframed document

The host renders a document-delivered module in an iframe and speaks to it over `postMessage`. Wrap
the module's React root in `LayoutModuleBridge` from the `bridge-frame` entry; it owns the whole
handshake and hands the module a render prop:

```tsx
import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-frame';

export function App() {
  return (
    <LayoutModuleBridge>
      {({ data, context, resolveDataPath }) => (
        <RiskSummary data={data} context={context} resolveDataPath={resolveDataPath} />
      )}
    </LayoutModuleBridge>
  );
}
```

- `children` is only invoked once the host has sent the initial layout data — it never sees
  `data: undefined`. Until then `fallback` (default `null`) is rendered, hidden under the host's
  loading card. After the first render the bridge posts `loaded` and the host removes the card, so
  render any data-loading state from `children`.
- `data` is the module's persisted config as JSON, and re-renders whenever the host pushes an
  update.
- `context` (`RemoteModuleContext`) is where the module is rendered — host-owned facts the module
  cannot derive itself. It arrives with the initial data and re-renders whenever it changes (see
  below).
- `resolveDataPath(dataPath, variableMap?)` returns `Promise<Json>`; the host owns the
  implementation. It rejects if the host reports an error or does not answer within
  `resolveTimeoutMs` (default 30s). Outside an iframe it rejects immediately, so a module opened
  directly in a tab still renders.
- Height is reported automatically: the bridge wraps `children` in a `display: flow-root` div and
  reports the height the frame document needs, including `body` and ancestor margins, padding, and
  borders, so the host can size the iframe (`observeHeight={false}` to opt out). The host uses the
  reported height only when [`context.dynamicHeight`](#host-context) is true; otherwise, the iframe
  keeps its grid size.
- A render error thrown inside `children` is caught, reported to the host, and `fallback` is shown
  after the host loading overlay is dismissed.
- Mount exactly one `LayoutModuleBridge` per document.

Props: `fallback?: ReactNode`, `observeHeight?: boolean` (default `true`),
`resolveTimeoutMs?: number`, `allowedHostOrigins?` (to add host origins outside the built-in
allow-list). Built-in origins include `*.withpersona.com`, `*.withpersona-staging.com`, `localhost`,
and `127.0.0.1`. Dev and preview dashboards must be added explicitly; compose the built-in predicate
with an app-specific check:

```tsx
import {
  LayoutModuleBridge,
  personaDashboardHostOrigins,
} from '@persona/layout-editor-remote-module/bridge-frame';

<LayoutModuleBridge
  allowedHostOrigins={(origin) => personaDashboardHostOrigins(origin) || myDevHost(origin)}
>
  {({ data }) => <RiskSummary data={data} />}
</LayoutModuleBridge>;
```

#### Host context

`RemoteModuleContext` (exported as a type from `bridge-frame`) has:

- `layoutType` — known values are `'account' | 'case' | 'transaction'`, but its type is
  `LayoutType | (string & {})` and the bridge accepts layout types added after the module's release.
  Handle unknown values with a default branch rather than a switch that tries to cover every case.
- `layoutObjectId` — the ID of the object the layout is rendered for (e.g. the viewed case's ID on a
  `case` layout), or `null` when there is none.
- `isReadOnly` — the host object is locked for edits; hide or disable mutating actions.
- `isInEditor` — rendered inside the layout editor rather than on a live record.
- `isUsingStubData` — the host is feeding placeholder data instead of a real record; skip network
  calls or side effects keyed off the record.
- `dynamicHeight` — `boolean`, optional in the type; `RemoteModuleFrame` always sets it. It is true
  when the host sizes the iframe to the height the bridge reports. When true, size to content
  (`height: auto`) instead of filling the viewport. When false or absent, the iframe has a fixed
  grid height, so filling it (`100vh`) is fine. Treat a `null` context (not embedded) like false;
  see
  [Viewport-relative height under dynamic height](#high-viewport-relative-height-under-dynamic-height).

`context` is `null` only when the document is not embedded (opened directly in a tab); inside
`children` it is otherwise always set, because `children` runs only after `init`.

```tsx
import {
  LayoutModuleBridge,
  type RemoteModuleContext,
} from '@persona/layout-editor-remote-module/bridge-frame';

function RiskSummary({ context }: { context: RemoteModuleContext | null }) {
  if (!context?.layoutObjectId) {
    return <EmptyState />;
  }
  return <RiskScore caseId={context.layoutObjectId} readOnly={context.isReadOnly} />;
}
```

The protocol only ever adds fields. Receivers built on 0.5.3+ ignore unknown message types and strip
unknown keys, so new context fields may appear in later host releases without a frame upgrade.

Source: `src/bridge/protocol.ts`.

#### Access token and environment id

The host still appends `access_token`, `environment_id`, and `locale` to the iframe `src`. The app
hosting the module consumes and strips those parameters itself, so by the time `LayoutModuleBridge`
runs they have already been handled; the bridge neither reads nor strips them. Keep the host
iframe's `referrerpolicy` at its default `no-referrer` (or use `strict-origin`) so the URL
parameters cannot leak through subsequent requests.

## Common Mistakes

### [HIGH] Reading layout data outside `LayoutModuleBridge`

Do not invent APIs such as `requestDataSomehow()` or `window.resolveDataPath`. `data` and
`resolveDataPath` exist only in the `LayoutModuleBridge` render prop; pass them to nested components
from there.

Source: `src/bridge/iframe/LayoutModuleBridge.tsx`.

### [MEDIUM] Host loading card stays visible forever when embedded

The host removes its loading card once `LayoutModuleBridge` has rendered `children`, so a card that
never clears usually means the handshake didn't complete. Check, in order: the document is really
cross-origin-embedded by a Persona host (the default `allowedHostOrigins` predicate rejects unknown
parents — pass your own only for a non-Persona dev host); the frame's
`@persona/layout-editor-remote-module` is 0.5.3 or later (a frame on 0.5.2 or older rejects an
`init` whose context has keys it doesn't know, e.g. `dynamicHeight`, sent by hosts on 0.8.0+, so
`children` never runs); and the host is rendering it through `RemoteModuleFrame` (bare `<iframe>`s
never send `init`). For a direct `createModuleBridge` integration, also confirm that a bridge
created with `signalsLoaded: true` calls `signalLoaded()`. Opening the document directly in a tab is
_not_ embedded — `data` and `context` resolve to `null` and `children` renders.

Source: `src/bridge/iframe/createModuleBridge.ts`, `src/bridge/origin.ts`, `src/bridge/protocol.ts`.

### [HIGH] Viewport-relative height under dynamic height

Wrong: the module root uses `height: 100vh` or `min-height: 100vh` (or `height: 100%` on `html` /
`body` / root) regardless of context. With dynamic height on, the bridge reports the iframe's own
height, so the module can never shrink. And because the reported height includes the frame
document's `body`/ancestor margin and padding, `100vh` or `min-height: 100vh` combined with any body
margin or padding reports a height larger than the iframe, so the iframe grows on every report.

Correct: switch to `auto` when `context?.dynamicHeight` is true:

```tsx
const sizing = context?.dynamicHeight ? 'content' : 'viewport';
// root: height: sizing === 'content' ? 'auto' : '100vh'
//       min-height: sizing === 'content' ? 'auto' : '100vh'
```

Source: `src/bridge/protocol.ts` (`remoteModuleContextSchema.dynamicHeight`),
`src/bridge/host/RemoteModuleFrame.tsx`.

### [HIGH] `DataSourceField` on a non-string property, or read as the resolved value

Declare a `DataSourceField` property as `z.string()`. On a non-string property the host stores a
structured data-source object instead of the chain string, and `resolveDataPath` only accepts a
string — it resolves to `null`. On a string property the stored value is the method chain the user
typed (e.g. `"current_account.id"`), not the account ID: pass `data.accountId` to `resolveDataPath`
rather than using it directly.

### [MEDIUM] A `ui:field` name the host doesn't register

A `ui:field` name the host has no component for falls back to RJSF's default field for the
property's type — no build or validation error, just a plain input instead of the intended control.
Spell `'DataSourceField'` exactly; module-supplied field components are not possible (see
"Everything is JSON" above).

### [HIGH] `key` uses the wrong charset or collides with another module

`key` must match `^[a-z0-9_]+$` and be unique within the app — it's embedded verbatim into a
persisted module identity string and DOM ids. A bad charset fails zod validation at build time
(`closeBundle`); a duplicate throws
`duplicate module key "..." — keys must be unique within an app`. Never change a released module's
`key` — it's written into saved layouts.

Source: `src/schema.ts` (`moduleKeySchema`), `src/vite-plugin.ts` (`assertUniqueKeys`).

### [HIGH] `defaultSize` missing an entry for a listed layout

Wrong:

```ts
defineLayoutModule({
  layouts: ['case', 'account'],
  defaultSize: { case: { width: 4, height: 20 } }, // no `account` entry
});
```

Correct:

```ts
defineLayoutModule({
  layouts: ['case', 'account'],
  defaultSize: {
    case: { width: 4, height: 20 },
    account: { width: 6, height: 24 },
  },
});
```

The manifest schema requires an entry in `defaultSize` for every layout named in `layouts`; a
missing one fails validation in `closeBundle` with
`defaultSize must have an entry for every layout in \`layouts\``. This error surfaces at build time,
not at the defineLayoutModule call site.

This requirement applies to each listed layout; an empty `layouts` imposes no `defaultSize`
requirement, and the host falls back to its own default size.

Source: `src/schema.ts` (`remoteModuleRegistrationManifestV1Schema` `.refine`).

### [MEDIUM] `render.entry` is not an HTML document

The entry must be an `.html` document. Other extensions fail in `config()` before the plugin checks
whether the file exists:

`[persona-layout-modules] <key>: render "<entry>" must be an .html document — only document-delivered modules are supported`

### [MEDIUM] `render.entry` doesn't resolve to a real file

`personaLayoutModules` fails fast in `configResolved` — before running a full build — when an entry
path doesn't exist on disk: `render "..." does not resolve to a file (...)`. This is nearly always a
relative-path mistake: `entry` resolves against the Vite **root**, not against `vite.config.ts`'s
directory, so a non-default `root` in the same config changes what a given relative `entry` points
at.

Source: `src/vite-plugin.ts` (`checkDeliveryExists`).

### [MEDIUM] `manifestPath` set to a directory instead of a file

Use a file path such as `/manifests/my-app.json`, not a directory such as `/manifests/`.
`manifestPath` defaults to `DEFAULT_MANIFEST_PATH` (`/_layout_modules_manifest.json`); a leading `/`
is optional and stripped either way. Code outside `vite.config.ts` that needs the default (e.g. a
subapp server adding CORS on the manifest route) should import `DEFAULT_MANIFEST_PATH` from the
runtime-safe `.` entry to avoid pulling in Vite.

Source: `src/vite-plugin.ts` (`PersonaLayoutModulesOptions.manifestPath` doc comment),
`src/manifest-path.ts`.
