Custom UI embeds
For a fully custom UI (a document viewer, a multi-step editor, a specialized interface), replace the generated form with an iframe. The iframe owns its rendering: layout, widgets and draft persistence belong to the embedded app. The schema stays the submission contract: with schema enforcement on the role, every payload the iframe (or anything else) submits validates against it.
Reach for the schema-driven form first (Forms); use an iframe when the work needs a surface the schema cannot express.
Schema declaration
Section titled “Schema declaration”{ "x-lt-viewport": { "type": "iframe", "src": "https://app.acme.dev/review-form" }, "properties": {}}When x-lt-viewport is present, the dashboard renders an iframe in place of the standard form. The properties block still defines the resolver payload shape, and with enforcement on it binds every submission (see Submission rules).
Stage URLs
Section titled “Stage URLs”src is the editor: it renders full-bleed once the viewer holds the claim. Two optional URLs show the embedded app at the other stages of the item’s life, inside the standard page with its title and action bar:
{ "x-lt-viewport": { "type": "iframe", "src": "https://app.acme.dev/inspect?orderId={orderId}", "onPending": "https://app.acme.dev/preview?orderId={orderId}", "onResolved": "https://app.acme.dev/summary?reportUrl={report_url}" }}| Stage | When | URL | The embed |
|---|---|---|---|
pending |
Pending and not workable by the viewer: available, or claimed by someone else | onPending |
Shows; the action bar carries Claim |
claimed |
Pending and workable by the viewer | src |
Works; lt:submit resolves |
resolved |
Resolved | onResolved |
Shows the outcome |
Pending and resolved embeds are read-only: the parent ignores lt:submit and lt:escalate from them and sends them no lt:validate. Every lt:init carries stage, so one app can serve all three URLs and branch on it. Omit a stage URL to keep the default surface: the claim affordance while pending, the submitted values once resolved. A cancelled item always renders the default surface.
URL token substitution
Section titled “URL token substitution”Every stage URL supports {key} tokens: single-brace, flat key lookup. The dashboard expands them at render time from values merged across four sources, in priority order:
| Priority | Source | Field |
|---|---|---|
| 1 (highest) | resolver_payload |
The submitted resolution, populated once resolved, so onResolved can point at the outcome |
| 2 | escalation_payload |
Per-escalation context set by the workflow |
| 3 | envelope |
Input envelope set by the workflow |
| 4 (lowest) | metadata |
Row metadata |
A key present in several sources resolves from the highest-priority one. A key with no match stays as {key} in the final URL.
Example: inspection workbench
Section titled “Example: inspection workbench”An inspector role embeds an inspection editor at /inspect while claimed and a read-only summary at /summary before the claim and after the resolve. Each escalation carries orderId and serialNumber in its payload; the dashboard injects them into every stage URL:
// Form schema on the roleconst INSPECTION_FORM_SCHEMA = { 'x-lt-viewport': { type: 'iframe', src: `${BASE_URL}/inspect?orderId={orderId}&serialNumber={serialNumber}`, onPending: `${BASE_URL}/summary?orderId={orderId}&serialNumber={serialNumber}`, onResolved: `${BASE_URL}/summary?orderId={orderId}&serialNumber={serialNumber}`, }, properties: { report_url: { type: 'string', description: 'Object-storage URL of the finished inspection report, set by the embedded editor.', }, },};
// Workflow side: the wait is a normal conditional; the iframe submits the payloadconst inspection = await conditional<{ report_url: string }>(signalId, { role: 'inspector', description: 'Inspect the unit for this order.', escalation_payload: JSON.stringify({ orderId, serialNumber }),});if (inspection) { await attachReport(inspection.report_url);}At render time the dashboard produces:
https://app.acme.dev/inspect?orderId=ord-123&serialNumber=SN-456{orderId} and {serialNumber} resolve from escalation_payload, which outranks envelope and metadata, so the payload values win when the same keys appear there.
Common token patterns
Section titled “Common token patterns”| Goal | Source | Token |
|---|---|---|
| Per-escalation session or record ID | escalation_payload |
{sessionId}, {orderId} |
| Tenant context | escalation_payload or envelope |
{tenantId} |
| Role-wide base path, the same for all escalations | A constant in the schema | Write it directly in src |
| Configuration passed at escalation creation | envelope |
{formMode}, {locale} |
The {key} syntax in stage URLs is a flat key lookup across the merged sources. The {{domain.path}} syntax in x-lt-help, x-lt-active and list schema templates is a domain-routed deep path lookup.
Message protocol
Section titled “Message protocol”The parent and the iframe talk through window.postMessage.
Parent to iframe
Section titled “Parent to iframe”// Sent when the iframe signals ready, and on load{ type: 'lt:init', escalation: { id: string, type: string, subtype: string, description: string | null, status: string, priority: number, role: string, workflow_type: string | null, envelope: Record<string, unknown> | null, metadata: Record<string, unknown> | null, escalation_payload: Record<string, unknown> | null, }, schema: Record<string, unknown>, // the full form schema stage: 'pending' | 'claimed' | 'resolved', // which stage URL this embed serves}
// The parent asks the iframe to run its own submit flow{ type: 'lt:requestSubmit' }
// Sent on the action bar's submit attempt, so the iframe shows its validation errors{ type: 'lt:validate' }Iframe to parent
Section titled “Iframe to parent”// Ready to receive lt:init{ type: 'lt:ready' }
// Submit the response; the payload becomes the resolver payload{ type: 'lt:submit', payload: { approved: true, notes: '...' } }
// Route to a different role{ type: 'lt:escalate', target: 'senior-reviewer' }
// Resize the iframe height (capped at 2000px){ type: 'lt:resize', height: 600 }Minimal example
Section titled “Minimal example”<!DOCTYPE html><html><head><title>Custom review form</title></head><body> <div id="form"></div> <button id="submit">Approve</button>
<script> window.parent.postMessage({ type: 'lt:ready' }, '*');
window.addEventListener('message', (event) => { if (event.data.type === 'lt:init') { const { escalation } = event.data; document.getElementById('form').textContent = `Reviewing: ${escalation.description}`; } });
document.getElementById('submit').addEventListener('click', () => { window.parent.postMessage({ type: 'lt:submit', payload: { responseType: 'approved', reviewed_at: new Date().toISOString() }, }, '*'); }); </script></body></html>Submission rules
Section titled “Submission rules”The iframe replaces the rendering and keeps the contract. Declare submission rules on the same schema that carries x-lt-viewport, and turn on enforce_schema for the role:
{ "x-lt-viewport": { "type": "iframe", "src": "https://app.acme.dev/review-form" }, "required": ["responseType"], "properties": { "responseType": { "type": "string", "enum": ["approved", "rework", "hold"] } }}With enforcement on, the gate sits at the API, on every resolve surface: resolve by id, resolve_by_metadata, signal-key resolves, bulk, and the MCP tools all validate the payload against this schema before anything is written. A submission missing responseType, or carrying a value outside the enum, is answered with the canonical 422 and the escalation stays pending. A bridge or event handler that resolves rows generically with a minimal payload leaves these rows open; only a caller that states an explicit, legal responseType closes one.
To make the contract airtight:
- Put the deciding field in the root
requiredarray with nox-lt-showIf. A field hidden by a condition is waived fromrequired, so an unconditional field is the part of the contract that holds. - Bound the legal outcomes with an
enum; each of the embedded app’s action buttons submits one of them. - Read the contract from the schema in
lt:initinstead of duplicating it in the app. - Expect enforcement
422s in the dashboard’s standard errors panel, where the operator sees them. - Schema versions pin at escalation creation: rows created before a rule existed validate against their pinned version. Drain or migrate pending rows when adding a required field to an in-flight queue.
Security
Section titled “Security”- The iframe runs with
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-downloads". - The parent accepts messages only from the iframe’s declared origin, and posts
lt:initonly to that origin. lt:initcarries the escalation context the app needs, includingenvelope,metadataandescalation_payload. Declare only trustedsrcorigins.- With
enforce_schemaon the role, a faulty or compromised embedded app still resolves only within the schema’s contract: the server validates every payload.
