Forms as data
A Modyra form can be a JSON document rather than code — produced by a service, a CMS, a visual editor, or a language model. Whatever produced it, treat it as untrusted input.
This is the same shape the agent-UI ecosystem is converging on — declarative JSON rendered natively client-side, as in Vercel’s json-render, Google’s A2UI and MCP-UI. A form is the highest-stakes case of that pattern: agent-emitted UI must validate, cancel safely and never execute. The contract below is the bounded surface an agent is allowed to speak, and the parser is the boundary that keeps it honest.
The supported path is:
external text -> JSON.parse -> parseDynamicForm() or parseDynamicFields() -> framework renderer or headless bindings -> server-side validation on submissionThe parser accepts a bounded set of field kinds, validator options, layout nodes and rule operators. It rejects or reports unsupported structures before they reach a renderer. This reduces the attack surface but does not replace application-level authorization, server validation or safe DOM rendering.
Trust boundaries
Section titled “Trust boundaries”- Parse runtime input before constructing a form. TypeScript types do not validate JSON.
- Do not pass labels, messages or option text to HTML sinks. Render them as text.
- Validate submitted values again on the server.
- Apply application-specific limits for payload size, nesting and storage.
- Use strict mode before publishing or registering a stored contract. Lenient mode is useful for editor previews where partial diagnostics are expected.
- Treat prompts as generation guidance, not as an enforcement mechanism. The parser and server schema remain authoritative.
The JSON contract
Section titled “The JSON contract”Either a bare array of fields, or a versioned envelope
({ "version": 5, "fields": [...] }). Version 5 is the current one; 2, 3 and 4
still parse, and a declared version 1 is rejected wholesale.
Do not copy that list into a prompt or a validator. It is written here for a
reader and it goes stale the moment the contract gains a word — which has now
happened three times. The parser is the authority and says so in its own
refusal: a document at a version it does not know is named, with the set it
does. MDY_DYNAMIC_MEMBER_ARRIVALS (@modyra/core) records which member
arrived with which version, so a generator that wants to target the oldest
version carrying the members it needs can read it rather than guess.
Common field properties:
| Property | Type | Notes |
|---|---|---|
name |
string | required, unique, no ., not __proto__/prototype/constructor |
kind |
string | required — one of the kinds below |
label |
string | optional |
placeholder |
string | optional |
initialValue |
any | optional |
validators |
object | optional — see below |
Kinds (MDY_DYNAMIC_FIELD_KINDS is the source of truth):
kind |
Extra properties | Value type |
|---|---|---|
text, textarea, email, password |
— | string |
number, slider |
min, max, step (> 0) |
number |
checkbox, toggle |
— | boolean |
select, radio, multiselect, segmented |
options (required: { value, label, disabled? }[]) |
value / value[] |
datepicker, timepicker |
— | date/time string |
validators (all optional): required (boolean), email (boolean),
min / max (finite numbers), minLength / maxLength (finite
numbers, minLength ≤ maxLength), pattern (RegExp source string,
≤ 256 chars).
System prompt template
Section titled “System prompt template”Copy-adapt this to constrain the model to the contract:
You generate form configurations as JSON for a strict renderer.
OUTPUT RULES- Respond with a single JSON object, no markdown fences, no commentary: { "version": 4, "fields": [ ... ] }- Every field: { "name", "kind", "label", "placeholder"?, "initialValue"?, "validators"? }.- "name" must be a unique camelCase identifier. No dots, never "__proto__", "prototype" or "constructor".- "kind" MUST be one of: text, textarea, email, password, number, slider, checkbox, toggle, select, radio, multiselect, segmented, datepicker, daterange, timepicker, colors, file. Do not invent other kinds.- Kinds select/radio/multiselect/segmented REQUIRE "options": [{ "value": <string|number|boolean>, "label": <string> }, ...].- Kinds number/slider accept "min", "max", "step" (numbers, min ≤ max).- "validators" may only contain: required (boolean), email (boolean), min, max, minLength, maxLength (numbers), pattern (regex source string without slashes, e.g. "^[A-Z]{2}\\d{4}$").- Anything outside this contract is discarded by the renderer, so stay inside it. Ask for clarification instead of inventing kinds.
USER REQUEST: <the user's form description goes here>Even with a perfect prompt, the parser stays the enforcement layer —
prompts reduce waste, parseDynamicFields() enforces the supported contract.
End-to-end example
Section titled “End-to-end example”A (simulated) model response — deliberately containing four mistakes:
import { parseDynamicFields } from "@modyra/core";
const llmResponse = JSON.stringify({ version: 4, fields: [ { kind: "text", name: "fullName", label: "Full name", validators: { required: true, minLength: 2 } }, { kind: "email", name: "email", label: "Work email", validators: { required: true, email: true } }, { kind: "select", name: "plan", label: "Plan", options: [ { value: "free", label: "Free" }, { value: "pro", label: "Pro" }, ], validators: { required: true } }, { kind: "slider", name: "satisfaction", label: "Satisfaction", min: 0, max: 10, initialValue: 5 }, { kind: "datepicker", name: "startDate", label: "Start date" },
// — the model's mistakes, all dropped with dev-mode warnings: { kind: "richtext", name: "bio", label: "Bio" }, // unknown kind { kind: "select", name: "country", label: "Country" }, // missing options { kind: "text", name: "__proto__", label: "x" }, // reserved name { kind: "text", name: "fullName", label: "dup" }, // duplicate name ],});
// 5 fields kept, 4 dropped — the form still renders.const fields = parseDynamicFields(JSON.parse(llmResponse));Rendering it needs no framework:
import { mountMdyForm } from "@modyra/plain";
const mounted = mountMdyForm(document.getElementById("host"), fields, { onSubmit: (value) => console.log(value), // partial: a disabled field is not submitted});// mounted.form is the running @modyra/core form; mounted.dispose() unmounts everything.and each binding has its own way of drawing the same field list — in Angular:
<mdy-dynamic-form [fields]="fields" (submitted)="onSubmitted($event)"> <button type="submit">Send</button></mdy-dynamic-form>onSubmitted(event: { value: Record<string, unknown> }): void { // { // fullName: "Ada Lovelace", // email: "ada@example.com", // plan: "pro", // satisfaction: 7, // startDate: "2026-08-01", // } console.log(event.value);}- The parser is the contract; drawing it is the renderer’s part. Every
binding reads the same
MdyDynamicField[]and wires the same validators throughbuildDynamicFieldValidators()— including the automaticoneOf/eachOneOfanti-tampering whitelist for option-based kinds — so value, validation and error semantics do not vary by framework. - What differs is how much drawing a binding does for you. Some ship a
component that renders the catalogue from the field list
(
<mdy-dynamic-form>,mountMdyForm); others hand you the headless handles and let you render your own controls (useMdyDynamicFormin React and Preact, paired withuseMdyFieldand its siblings). Mapping eachMdyDynamicField.kindto your own controls is always available. layoutis applied by@modyra/plainand@modyra/angular.rulesare applied byapplyDynamicRules(form, rules)in@modyra/core, whichmountMdyFormcalls for you when you pass them:mountMdyForm(container, result.fields, { layout: result.layout, rules: result.rules }). A host rendering its own controls calls it directly on the form it built. Pass them: a form built without them behaves as though the array were empty, and a rule sayingdisabledis the difference between a value being sent and not.- CMS/storage use case: same contract, same parser — see the UI toolkit for the versioning notes.
- Keep the schema of your domain out of the prompt when possible: a smaller, fixed contract is what makes the output predictable enough to validate.
Contract v2: layout and declarative rules
Section titled “Contract v2: layout and declarative rules”Version 2 preserves the v1 field contract and adds optional layout and
rules. Both remain data-only: rules use a fixed operator allowlist and may
only reference declared field names. There are no expressions, callbacks,
HTML fragments, or arbitrary URLs.
A layout node is a section (with children) or a columns row (with
columns). A slot holds either a field name or another layout node, so a
column row can sit inside a section. Two constraints the parser enforces:
nesting is capped at MDY_LAYOUT_MAX_DEPTH (32) — a guard against hostile
input that bounds recursion through an arrangement arriving from outside, not
a limit on how much a form may ask — and a field may be placed
only once — the same field in two slots would render twice and bind one
value to both controls.
{ "version": 2, "id": "business-signup", "fields": [ { "name": "customerType", "kind": "select", "options": [ { "value": "private", "label": "Private" }, { "value": "business", "label": "Business" } ] }, { "name": "vatNumber", "kind": "text", "label": "VAT number" } ], "layout": [ { "kind": "section", "id": "identity", "children": [ "customerType", { "kind": "columns", "id": "vat", "columns": [["vatNumber"], []] } ] } ], "rules": [ { "effect": "visible", "target": "vatNumber", "when": { "field": "customerType", "operator": "equals", "value": "business" } } ]}Contract v3: a slot that moves with the screen
Section titled “Contract v3: a slot that moves with the screen”Version 3 adds one thing to v2 and changes nothing else: where a single child sits, and whether it shows, per screen size. A v2 document is a v3 document with the version number raised.
In v2 a layout child is a field name. In v3 it can also be a slot — the same field, plus placement:
{ "version": 3, "layout": [ { "kind": "columns", "id": "address", "at": { "base": 1, "md": 2 }, "columns": [ [{ "ref": "city", "at": { "md": { "column": 1 } } }], [{ "ref": "region", "at": { "base": { "hidden": true }, "md": { "column": 2 } } }] ] } ]}The breakpoints are base, sm, md and lg. A section can carry the same at, so a group is
layout-able for a screen size like anything else.
The row’s track count stays on the row (at on the columns node), where v2 put it. There is one
spelling for it, because a second way to say the same thing leaves every reader deciding which wins.
Use parseDynamicForm(input, { mode: "lenient" }) for AI previews: valid
fields survive and diagnostics explain rejected fields, layout nodes, and
rules. Use mode: "strict" before publishing a stored contract or accepting
it into an API registry: any diagnostic makes ok false and returns no
renderable fields. parseDynamicFields() remains backward compatible and
accepts v2, v3, v4 and the legacy bare field array; a declared v1 envelope is
refused wholesale, with a warning naming the version to set.
The machine-readable schema is spec/dynamic-form-v4.schema.json (v3 is spec/dynamic-form-v3.schema.json, and a v3 document is a v4 document with the version raised), with
spec/dynamic-form-v2.schema.json for documents that stay on v2. Point a
document’s $schema at one and an editor underlines a malformed field as it
is written, with no extension installed. Rust services can use the matching
sdk/rust/modyra-contract crate; TypeScript and Rust run against the same
conformance fixtures.
The schema checks shape, and that is all it can check. A cross-reference is
invisible to JSON Schema: a layout slot naming a field that does not exist, a
second field with a name already taken, a validation reading a path nothing
declares — every one of those passes the schema and fails
parseDynamicForm. Treat a green schema as “well-formed”, never as “valid”,
and keep the parser in the path. npm run test:contract-schema holds the
schema to the kinds and slots the parser accepts, so the two stay describable
as one document.
Cross-field validations are parsed, and no renderer mounts them
Section titled “Cross-field validations are parsed, and no renderer mounts them”A v4 document may carry a top-level validations array — an expression, a
message, and optionally the path that wears the error. It says what a single
field cannot: an end that must follow a start, a confirmation that must match
what it confirms, a total that has to add up.
parseDynamicForm accepts and reports it, and buildDynamicValidations
compiles it into form-level validators that fire. No shipped renderer reads
it — the only consumer is Studio’s live preview, which is not a published
package. Mounting a document gives you its per-field rules as native constraints
and drops its cross-field ones, silently: a document its author believes is
invalid renders valid and submittable.
Until a renderer takes them, apply them yourself. The parsed slot and the compiler are both public, so the rules travel with the document either way:
import { parseDynamicForm, buildDynamicValidations, createForm, field,} from "@modyra/core";
const parsed = parseDynamicForm(doc, { mode: "strict" });
// The fields are the document's; the validators are the slot no renderer takes.const form = createForm( { start: field(10), end: field(5) }, { validators: buildDynamicValidations(parsed.validations) },);
form.f.end.errors(); // [{ message: "End must follow start", … }]The expression vocabulary is closed: equals, notEquals, isEmpty,
isNotEmpty, lengthAtLeast, lengthAtMost, greaterThan,
greaterThanOrEqual, lessThan, lessThanOrEqual, in, notIn, matches,
and and/or/not to combine them. An operand is a literal or a
{ "path": "…" } reference to a declared field. A validation whose when is
true is the failing case, and its message is what the reader sees;
without target the message lands on every path the expression reads.
For contracts written as TypeScript literals rather than JSON,
@modyra/eslint-plugin reports the same parser diagnostics in the editor.
Rust business object to Angular renderer
Section titled “Rust business object to Angular renderer”The Rust workspace includes a runnable Axum example that demonstrates the
same contract without an LLM. Rust converts a checkout business configuration
(countries, defaults, quantity constraints) into Contract v2 and exposes it at
GET /v1/forms/checkout. The Angular demo fetches the response as unknown,
runs parseDynamicForm(input, { mode: "strict" }), and passes only accepted
fields to <mdy-dynamic-form>.
Rust CheckoutConfiguration -> DynamicFormV2 -> GET /v1/forms/checkout -> Angular HttpClient<unknown> -> parseDynamicForm(..., { mode: "strict" }) -> <mdy-dynamic-form [fields]="parsed.fields">Run the Rust server and Angular demo in separate terminals:
cargo run --manifest-path sdk/rust/Cargo.toml \ -p modyra-axum-form-server-examplenpm run demo:angularThe demo also sends the completed value to Rust at
POST /v1/forms/checkout/submissions and displays either the generated
submission ID or normalized field errors. CORS is restricted to the Angular
dev origin (http://localhost:4200).
The cross-language checkout now uses recursive Contract v2 nodes. Rust emits
a shipping group and an items array; strict parsing expands the accepted
initial structure to shipping.city, shipping.zip, items.0.sku, and
items.0.qty.
Those names are paths, and every renderer reads them as such: a schema built
from them declares the structure the path describes, so the mounted form reads
back { shipping: { city, zip } } rather than a field literally called
shipping.city (ADR 0031). One limit is worth knowing before you rely on it —
a path cannot say whether items.0 was an array row or the record key "0",
so a document’s array comes back as a group keyed "0", "1". A form that
must round-trip a list declares array() in its own schema.
Recursive group and array nodes
Section titled “Recursive group and array nodes”Contract v2 can use a recursive root schema instead of the legacy flat
fields list. A group maps named children to dotted paths; an array repeats
a field/group item descriptor and expands its initial rows to indexed paths.
A nested document has no depth limit, deliberately — a form’s shape is the
author’s business. What the parser does bound is what expansion costs: no
generated path may exceed MDY_MAX_DYNAMIC_PATH_LENGTH (512) characters, and
the declaration walk itself stops at MDY_MAX_DECLARATION_WALK (100,000) so
a shape nothing has validated cannot run without end.
{ "version": 2, "schema": { "node": "group", "children": { "shipping": { "node": "group", "children": { "city": { "node": "field", "field": { "kind": "text" } }, "zip": { "node": "field", "field": { "kind": "text" } } } }, "items": { "node": "array", "initialValue": [{ "sku": "TSHIRT-BLK-M", "qty": 2 }], "item": { "node": "group", "children": { "sku": { "node": "field", "field": { "kind": "text" } }, "qty": { "node": "field", "field": { "kind": "number", "min": 1 } } } } } } }}For the current Angular renderer, recursive nodes compile to validated dotted and
indexed field paths such as shipping.city, items.0.sku, and
items.0.qty. This preserves nested submission semantics and initial
array rows. Interactive row insertion/removal remains owned by the typed
array() renderer path; Contract v2 currently renders the rows declared in
initialValue.