Forms
Give a role a JSON Schema and every row on that role gets a resolve form: fields, validation, conditional sections, layout and a help panel, all rendered from the schema. The schema is versioned, rows pin the version they were written for, and the server can enforce it on every surface.
{ "title": "Inspect part", "type": "object", "x-lt-layout": "two-column", "x-lt-help": "### {{metadata.serialNumber}}\n\nCheck the part against the drawing in the envelope.", "required": ["result"], "properties": { "result": { "type": "string", "title": "Result", "x-lt-options": [ { "value": "pass", "label": "Pass" }, { "value": "rework", "label": "Send to rework" } ] }, "defect": { "type": "string", "title": "Defect", "x-lt-showIf": "resolver.result=rework", "x-lt-options": "lookup.defects.items" }, "measurement": { "type": "number", "title": "Measured length (mm)", "minimum": 0, "x-lt-maximum": "envelope.maxLength" }, "notes": { "type": "string", "format": "textarea", "title": "Notes", "x-lt-span": 2 } }}The form belongs to the role
Section titled “The form belongs to the role”The role is the unit of UI: the role decides who sees a row, its schema decides what they see, and the version decides which edition renders. A workflow can park rows on many roles, and each role owns its own form, so declare every resolve form on its role.
Declare the form in code with start():
await start({ database: { connectionString: process.env.DATABASE_URL }, roles: [ { role: 'inspector', title: 'Inspector', form_schema: INSPECTOR_FORM, enforce_schema: true }, ], workers: [/* ... */],});Or save it at runtime:
| Surface | Save a new version |
|---|---|
| HTTP | PATCH /api/roles/:role with form_schema and an optional change_summary |
| SDK | lt.roles.update |
| CLI | ltc roles save-schema inspector --file schema.json --summary "Added defect" |
| MCP | update_role |
| Dashboard | The schema editor at /admin/roles/:role/schema |
Versions and pins
Section titled “Versions and pins”Every save that changes a role’s form appends an immutable snapshot to lt_role_schemas and advances the role’s current version. A role’s first form is version 1.
const decision = await conditional<InspectionV3>(signalId, { role: 'inspector', metadata: { serialNumber }, schemaVersion: 3, // renders and validates v3, whatever comes later});| Behavior | Detail |
|---|---|
| Pinned row | Renders and validates against its snapshot forever. The pin is stored as metadata.schema_version |
| Unpinned row | Uses the role’s latest form when the row is fetched |
| Missing version | A pin naming a version that does not exist fails at creation |
| Evolve | Change the schema, bump the pin and the payload type together. Open rows keep their edition |
Inspect versions with GET /api/roles/:role/schema?version=N, GET /api/roles/:role/schema/versions, lt.roles.getSchema, lt.roles.listSchemaVersions, ltc roles schema <role> --version N, ltc roles schema-versions <role>, or the get_role_schema and list_role_schema_versions MCP tools.
Which schema a row uses
Section titled “Which schema a row uses”metadata.form_schema, a full schema embedded on the row, when present.- The role’s snapshot at
metadata.schema_version, when the row is pinned. - The role’s latest form.
How a form reads the row
Section titled “How a form reads the row”Tokens in the form read from context domains with domain.path (in conditions and option paths) or {{domain.path}} (in text templates):
| Domain | Reads |
|---|---|
escalation |
The row’s own columns, such as {{escalation.id}} |
metadata |
The row’s facets |
envelope |
The row’s unindexed context |
payload |
The data the workflow attached on the legacy path |
resolver |
The live form values as the person edits |
lookup |
Knowledge entries the row references. See Lookups |
Two envelope keys shape the form. envelope.formDefaults prefills fields. x-lt-bind on a field maps the flat form value into a nested resolver payload ("x-lt-bind": "customer.email"), and prefill reverses the same map.
Field types
Section titled “Field types”| Declared type | Renders as |
|---|---|
boolean |
Checkbox |
boolean with x-lt-options |
A select emitting true or false |
number, integer |
Number input |
string |
Text input. format adds date, date-time, email, uri, password, textarea |
scalar with enum or x-lt-options |
Select |
array with x-lt-options |
Multi-select |
object |
Nested fields, or a widget |
A password field is replaced by a short-lived ephemeral token before the payload is stored. Plain JSON Schema keywords (required, minimum, maximum, minLength, maxLength, pattern, readOnly, title, description) work as written.
The x-lt-* vocabulary
Section titled “The x-lt-* vocabulary”The tokens group by job. Each links to the reference.
| Group | Tokens | Use it to |
|---|---|---|
| Layout | x-lt-layout, x-lt-order, x-lt-section, x-lt-span, x-lt-bind, x-lt-help |
Arrange fields in sections and columns, map them to the payload, write side-panel help |
| Display | x-lt-display |
Render read-only facts as a dense label and value list |
| Show if | x-lt-showIf, x-lt-hide-if-empty, x-lt-hidden |
Show a field only when another value says so |
| Validation | x-lt-options, x-lt-minimum, x-lt-maximum, x-lt-min-length, x-lt-max-length, x-lt-pattern-error, x-lt-require-any, x-lt-require-sum, x-lt-nullable |
Constrain choices and bounds, including bounds read from the row |
| Widgets | x-lt-widget (file-upload, code-editor, json, signature, rich-text, markdown, checklist, attachment), x-lt-source, x-lt-require-all, x-lt-default-checked |
Rich inputs, and checklists driven by runtime data |
| Embeds | x-lt-widget (link, escalation, escalation-list), x-lt-href, x-lt-query, x-lt-columns, x-lt-actions, x-lt-submit-guard |
Show related rows in the form, and hold submit until they drain |
| Footer | x-lt-labels, x-lt-submit-on-claim |
Rename the footer controls, or claim and submit in one gesture |
| Transition | x-lt-transition, x-lt-transition-message, x-lt-transition-done |
Wait after submit and open the follow-on row for the same person |
| Roster | x-lt-widget: "roster", x-lt-roster |
List every member a container expects and check each off as it arrives |
| Scan | x-lt-scan, x-lt-scan-submit |
Fill a field by scanning, checked against what the row expects |
| Invoke | x-lt-widget: "invoke", x-lt-invoke |
Start another workflow from the form with a payload mapped from the row |
| List schema | list_schema with x-lt-layout (active-history, facet-table, facet-board), x-lt-card, x-lt-row-action |
Format the role’s whole list page |
| Viewport | x-lt-viewport |
Replace the generated form with your own app in an iframe |
Conditional fields
Section titled “Conditional fields”x-lt-showIf takes a domain.path. The field shows when the value is truthy. ! inverts, =VALUE and !=VALUE compare, and an array requires every condition. resolver. conditions react live as the person edits:
"removal_reason": { "type": "string", "x-lt-showIf": "resolver.decision=remove" }A field hidden at submit time never blocks submission, on the client or on the server.
Validation runs twice
Section titled “Validation runs twice”The dashboard runs one validation pass before submit and lists every error in a side panel. With enforce_schema: true on the role, the server runs the same module on every resolve surface and answers violations with one 422 body. See Schema enforcement.
