Skip to content

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.

{
"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).

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.

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.

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:

workflows/inspection.ts
// Form schema on the role
const 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 payload
const 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.

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.

The parent and the iframe talk through window.postMessage.

// 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' }
// 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 }
<!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>

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 required array with no x-lt-showIf. A field hidden by a condition is waived from required, 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:init instead 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.
  • 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:init only to that origin.
  • lt:init carries the escalation context the app needs, including envelope, metadata and escalation_payload. Declare only trusted src origins.
  • With enforce_schema on the role, a faulty or compromised embedded app still resolves only within the schema’s contract: the server validates every payload.