Skip to content

Form-Builder Embed Protocol (v1)

The standalone form-builder SPA can be loaded by a host page inside an <iframe> and driven over postMessage. This page is the canonical specification of the wire protocol: every envelope shape, every kind, every security check.

For a step-by-step integration walkthrough, see Embedding the Form Builder. For a runnable reference host, see packages/form-builder-app/public/embed-host.html (served at /embed-host.html by pnpm --filter @floh/form-builder-app dev).


Activation

The SPA enters embed mode when loaded with the URL query parameter ?embed=1. Loading without the flag — even inside an iframe — keeps standalone behaviour intact (branded chrome, localStorage persistence). The flag is the single source of truth so an iframe sandbox alone cannot accidentally suppress chrome.

https://forms.example.com/?embed=1&hostOrigin=https%3A%2F%2Fhost.example.com
Parameter Required Purpose
embed yes Must be the literal string 1. Any other value (including true, yes, empty) keeps the SPA in standalone mode.
hostOrigin yes* URL-encoded origin the host page expects messages from. The SPA refuses to start the bridge when missing (state: no-origin).

*Strictly speaking the SPA boots either way, but with hostOrigin unset the bridge state stays no-origin and no postMessage listener is installed. The chrome is still suppressed (so the editor renders full-bleed inside the iframe), but the host cannot drive it.


Envelope

Every message — in either direction — is wrapped in a versioned envelope:

interface EmbedEnvelope<K extends string, P> {
  v: 1; // EMBED_PROTOCOL_VERSION
  nonce: string; // per-render session nonce
  kind: K; // see allow-lists below
  id?: string; // reserved for future request/response correlation
  payload: P;
}

The receiver MUST drop any message that:

  • has event.source other than the expected window (parent for the guest, the iframe's contentWindow for the host);
  • has event.origin !== expectedOrigin;
  • fails the envelope shape check (isEmbedEnvelope in packages/form-builder-app/src/app/embed/protocol.ts);
  • has a missing or wrong nonce;
  • has a kind not in the allow-list for the receiving direction (sending a guest→host kind to the guest is a protocol violation).

Drops are silent. Hosts can subscribe to console.debug for inspection during development.


Nonce handshake

The nonce is minted by the guest (the form-builder iframe) on load and echoed by the host on every subsequent send. The sequence is:

  1. Guest loads, calls EmbedBridgeService.start().
  2. Guest mints a nonce (crypto.randomUUID() in production) and posts ready to its window.parent. The nonce is in the envelope and in the ready payload (so the host can read it before invoking the envelope validator).
  3. Host stores the nonce; sends init (and every later host→guest message) with that nonce in the envelope.
  4. Either side drops any inbound message whose nonce does not match the active session.

Reloading the iframe (designer template edit, host re-mount, …) mints a fresh nonce, so messages buffered by either side from the prior render are silently dropped instead of being applied to the current session.

For this reason, hosts MUST treat every inbound ready as a session reset — replace the stored nonce with the new one and re-send init so the new render boots into the expected state. Pinning only the very first ready's nonce permanently breaks the bridge after the first reload because every subsequent host→guest send fails the nonce check on the new session.


Direction allow-lists

host → guest

Kind Payload type When sent
init InitPayload Once per session, after receiving ready. Replaces the buffer with the host's bytes. May also carry initial host-supplied hints.
context:update ContextUpdatePayload Whenever the host's view of preview.context changes (workflow vars edited, etc.).
host:hints:update HostHintsUpdatePayload Whenever the host's declared workflow variables or context tokens change. Replaces both lists wholesale; an empty array clears a list. Triggers a refreshed change so the host's view of validationIssues reflects whether host tokens now resolve a previously-broken Display.contextScope.

guest → host

Kind Payload type When sent
ready ReadyPayload Once on guest load, before the host has supplied initial state.
change ChangePayload On every parsed-and-validated edit of the form package.
error ErrorPayload Internal guest failure (malformed inbound payload, applied edit threw, …).

Both lists are disjoint. Receiving a guest→host kind on the inbound side at the guest is a protocol violation and the bridge silently drops it.


Payload shapes

// guest → host
interface ReadyPayload {
  /** Nonce the host should echo on every host→guest message. */
  nonce: string;
  /** Always equal to EMBED_PROTOCOL_VERSION (currently 1). */
  protocolVersion: 1;
}

interface ChangePayload {
  /**
   * Current Monaco buffer contents — JSONC editor text, NOT a parsed
   * object. JSONC is a superset of JSON: it accepts // and /* … *\/
   * comments and trailing commas, both of which the form-builder
   * preserves verbatim so authors can leave notes for themselves
   * inside the package. Hosts that only need the structured value
   * MUST parse with `jsonc-parser` (or any JSONC-aware parser),
   * not bare `JSON.parse`. Hosts that persist the bytes verbatim
   * (the common case) do not need to parse at all.
   */
  formPackage: string;
  /** True iff `validateUiSchema` passes against the parsed package. */
  valid: boolean;
  /**
   * Empty when `valid: true` OR when the JSON itself didn't parse —
   * a syntax-error buffer has nothing schema-shaped to validate
   * against. Every other failure mode (parsed but missing
   * `schema`/`uiSchema`, validateUiSchema threw, structural rule
   * failures) emits at least one issue describing the cause.
   */
  validationIssues: readonly ValidationIssue[];
  /**
   * True from the moment the buffer first diverges from the most
   * recently `init`-loaded text. Stays true until the next `init`;
   * not reset by host persistence (the guest has no signal that the
   * host accepted the bytes). Host-applied `context:update` writes
   * are NOT user edits and do NOT flip this flag.
   */
  dirty: boolean;
}

interface ErrorPayload {
  code: string; // opaque code (e.g. "init-malformed")
  message: string; // human-readable English
}

// host → guest
interface InitPayload {
  /**
   * JSONC editor buffer text (NOT strict JSON). Fed directly into
   * the Monaco buffer, so any `// line` or `/* block */` comments
   * and trailing commas survive verbatim. Hosts persisting the
   * `change.formPackage` text from a prior session can replay
   * those exact bytes here — the comments and trailing commas
   * the author left in are preserved end-to-end. Hosts that
   * synthesise an init from a parsed object should serialise via
   * `JSON.stringify` (no comments to preserve), which is a valid
   * JSONC subset and accepted as-is.
   */
  formPackage: string;
  /** Optional initial preview.context dictionary. Must be JSON-serialisable. */
  context?: Record<string, unknown>;
  /**
   * Optional initial workflow-variable hints. Surfaced in the form
   * builder's Palette ("Workflow variables" section), the
   * OutputVariable input's autocomplete `<datalist>`, and the
   * "Use variable" chip strip. Empty / omitted = standalone form
   * builder UI (no host-supplied variables to hint).
   */
  workflowVariables?: ReadonlyArray<WorkflowVariableHint>;
  /**
   * Optional initial context-token hints. Surfaced in the
   * `Display.contextScope` picker as `<optgroup>`s alongside the
   * author's `preview.context` entries. Empty / omitted = picker
   * shows only the author's preview.context.
   */
  contextTokens?: ReadonlyArray<ContextTokenHint>;
}

interface ContextUpdatePayload {
  /** Replaces preview.context wholesale via a surgical Monaco edit. */
  context: Record<string, unknown>;
}

/**
 * Replaces both hint lists wholesale. The form builder snapshots
 * the most recent payload — older sends are not merged. Hosts that
 * want to clear a list send an empty array; omitting a list and
 * sending only the other is NOT supported (both fields are
 * required) so the host's view of "what hints does the guest
 * currently see?" stays unambiguous.
 */
interface HostHintsUpdatePayload {
  workflowVariables: ReadonlyArray<WorkflowVariableHint>;
  contextTokens: ReadonlyArray<ContextTokenHint>;
}

/**
 * Hint shape for a single workflow variable the host expects the
 * form to populate at run time. Mirrors the design-time-relevant
 * subset of Floh's `VariableDefinition` — runtime fields like
 * persisted defaults are intentionally omitted.
 */
interface WorkflowVariableHint {
  /** Identifier per `^[A-Za-z_$][A-Za-z0-9_$]*$`, max 200 chars. */
  name: string;
  /** Coarse type tag — one of the `VariableHintType` literals
   *  declared below (e.g. "string", "number", "boolean", "json"). */
  type: VariableHintType;
  /** True iff the workflow requires this variable at run start. */
  required: boolean;
  /** Optional human-readable description, max 4096 chars. */
  description?: string;
}

/**
 * Hint shape for a single host-supplied context token the form may
 * bind a `Display.contextScope` to. The picker maps each `path`
 * segment to a JSON-Pointer `#/context/<seg>/<seg>` so the bound
 * scope round-trips through the existing JSON Schema unchanged.
 *
 * Wire-boundary validation rules (parser is **fail-closed at the
 * array level**  any single bad element rejects the whole
 * `host:hints:update` / `init` payload):
 *
 * - **Identifier shape**: every dot-segment of `path` must match
 *   `^[A-Za-z_$][A-Za-z0-9_$]*$` (no empty segments, no hyphens,
 *   no leading digits).
 * - **Reserved segments**: `path` MUST NOT contain `__proto__`,
 *   `prototype`, or `constructor` anywhere. Defends against
 *   prototype-pollution sinks in downstream walkers (see
 *   `.cursor/rules/form-builder/gotchas/2026-04-30-host-token-prototype-pollution-defense.mdc`).
 * - **Source/path consistency**: the FIRST segment of `path` must
 *   exactly equal the `source` field. A
 *   `{ source: "submitter", path: "targetUser.email" }` token
 *   would let the picker bucket the binding under `submitter`
 *   while the runtime resolved against `targetUser` (silent
 *   fallback at run time), so the parser rejects the mismatch
 *   instead.
 * - **Path length**: `path.length <= 390` (the schema's full
 *   `Display.contextScope.maxLength` is 400; 390 leaves room
 *   for the `#/context/` prefix the picker prepends).
 * - **String length cap**: `path` and `description` each
 *   `<= 4096` chars (most strings on the wire share this cap).
 * - **Source enum**: `source` must be one of the closed literals
 *   below — an unknown source fails the parse and the entire
 *   payload is rejected. Adding a new source is a coordinated
 *   protocol revision (parser + picker grouping + runtime
 *   context generator must all learn the new tag).
 */
interface ContextTokenHint {
  /** Dot-joined access path (e.g. "submitter.id"). See validation
   *  rules in the JSDoc above — identifier-shape, reserved-segment,
   *  source/path-consistency, and 390-char length checks all
   *  enforced at the wire boundary. */
  path: string;
  /** Coarse type tag for the token's value. */
  type: VariableHintType;
  /** Origin source — drives the picker's `<optgroup>` grouping.
   *  Closed literal union; the FIRST segment of `path` must
   *  match this value (see source/path consistency in the JSDoc
   *  above). */
  source: "submitter" | "targetUser" | "workflow";
  /** Optional human-readable description, max 4096 chars. */
  description?: string;
}

/**
 * Coarse type tag, mirrored verbatim from `@floh/shared`'s
 * `VariableType`. Kept as a string-literal union (not a type
 * import) at the iframe boundary so the standalone form builder
 * has no host-package dependency. The wire-boundary parser is
 * closed over this literal union — an unknown tag fails the parse
 * and the entire `host:hints:update` / `init` payload is rejected,
 * matching the parser's behaviour for `ContextTokenHint.source`.
 * Adding a new tag is a coordinated protocol revision (parser +
 * picker + designer surfaces must all learn it together) so older
 * form-builder bundles fail loudly rather than silently rendering
 * an opaque variant the rest of the UI can't reason about.
 */
type VariableHintType =
  | "string"
  | "number"
  | "boolean"
  | "date"
  | "json"
  | "user"
  | "role"
  | "group"
  | "user_or_group";

Bridge state

The guest's EmbedBridgeService.getState() returns one of:

State Meaning
disabled ?embed=1 was not on the URL. Bridge is inert; standalone mode.
no-parent ?embed=1 was set but window.parent === window (standalone tab). No one to talk to; bridge does nothing.
no-origin ?embed=1 set but hostOrigin query parameter is missing or empty. The bridge refuses to install the listener — defensive default.
version-mismatch Reserved for future envelope-version checks against a peer.
active Listener installed, ready posted to parent, change events flowing on every edit.

A host can detect any non-active state by simply not receiving ready within a reasonable timeout (no protocol-level negative ACK exists today — the only "I'm here" signal is ready itself).


Security checks at a glance

Check Enforced by Failure mode
event.source === expected window Both sides Silent drop
event.origin === expected origin Both sides Silent drop
isEmbedEnvelope(data) shape valid Both sides Silent drop
Envelope v === 1 Both sides Silent drop
Envelope nonce === session nonce Both sides Silent drop
kind in direction allow-list Both sides Silent drop
Payload shape narrows Each kind Guest emits error; host SHOULD ignore

The host SHOULD additionally pin the iframe with a CSP frame-src allow-list that only includes the form-builder origin (see Embedding the Form Builder § CSP).


Protocol versioning

EMBED_PROTOCOL_VERSION is exported as a literal 1. Bumping it implies a breaking change to the envelope or to one of the existing payloads. Adding a new kind to one of the allow-lists is not breaking and does not bump the version — old hosts will simply ignore the new kind (silent drop) and old guests will silently drop host→guest sends of unknown kinds.

The reserved id field on the envelope is the forward-compat hook for adding request/response round-trips (e.g. request:currentcurrent:state) without an envelope version bump.