DEV Community

Cover image for Your Model Just Invented a Form. Build a Tiny UI Component Gate in TypeScript.
Bobby Hall Jr
Bobby Hall Jr

Posted on

Your Model Just Invented a Form. Build a Tiny UI Component Gate in TypeScript.

Your model returned a form.

It had a submit button.

The action pointed somewhere you never approved.

Intelligent UI is having a moment.

  • On October 7, 2026, OpenAI published GPT-6 and Intelligent UI for everyone. Intelligent UI rolled out that day to Plus, Pro, Business, and Enterprise, with Free and Go the next day. Responses can include graphics, tappable buttons, forms, charts, and interactive experiences. OpenAI built a library of native streamable components plus a compiler that processes the interface as the model generates it. The model chooses layout. Training evaluates clarity, usefulness, and completeness. When text alone is best, it can stay text. Chat experience only in that release. Work and Codex models were not changing. Paid tiers get GPT-6 Sol. Free and Go get GPT-6 Luna.
  • The same day, Anthropic introduced Claude Haiku 5.5 for high-volume, cost-sensitive work: classification, routing, and similar short tasks. A cheap, fast model is a natural fit for the question "is this UI tree allowed?" even when a larger model drafts the tree.
  • On October 8, 2026, Google Cloud wrote about agents delivering finished work into docs and inbox flows, with cost controls in the mix. Agents that ship finished artifacts raise the same render question: what are you willing to mount without a human in the loop?

Different products. Same direction.

Providers are teaching models to emit interactive UI.

That is powerful.

It is also dangerous if you render whatever the model invents.

A component the model invented is a proposal. A gate that allowlists what you will mount is a product.

So let's build a tiny version.

By the end, you'll run one command:

npx tsx gate.ts
Enter fullscreen mode Exit fullscreen mode

And watch eight scripted UI trees hit a catalog, take one repair, then accept or reject.

No API key.

No real model.

Just TypeScript.

One honesty note: the "model" here is a fixture table. The pass rates and ship floor are example inputs. Swap in your provider's complete call and keep the gate.

This is also distinct from a schema gate for routing decisions. That post validates JSON shapes. This one validates a UI tree against component types, props, and action policies before render.

Table of Contents

  1. What We Are Building
  2. Project Setup
  3. Step 1: Define the Component Catalog
  4. Step 2: Validate the Tree
  5. Step 3: Script the Failure Modes
  6. Step 4: One Repair, Then Decide
  7. Step 5: Metrics and a Ship Gate
  8. Where It Breaks Down
  9. The Bigger Idea

Code: github.com/bobbyhalljr/tiny-ui-gate

What We Are Building

Allowlist, validate, repair once, then render or reject

Seven allowlisted components.

Text, Button, Input, Form, Chart, Card, and Stack.

Each has a closed prop list.

Buttons and forms may only target an action allowlist.

Raw JavaScript in actions is banned.

Trees deeper than four levels are rejected.

Charts must carry numeric series.

The mock model returns a valid card, an unknown widget, a form aimed off-allowlist, a javascript: button, a chart missing its title, a depth bomb, a chart with string "series", and a text node with unknown props.

The gate does four things:

  1. Parse the tree.
  2. Validate against the catalog and action policy.
  3. If invalid, repair once (strip unknown props locally, or feed issues back).
  4. Accept or reject. Never invent a new component type.

It's also a small version of the question behind Helix: when the interface changes, can you show what was allowed, what was blocked, and why?

Project Setup

You will need Node.js 18 or newer.

mkdir tiny-ui-gate
cd tiny-ui-gate

npm init -y
npm install --save-dev typescript tsx @types/node
Enter fullscreen mode Exit fullscreen mode

Save the following blocks, in order, as gate.ts. The full file is in the repo. The excerpts below are the parts that matter.

Step 1: Define the Component Catalog

type PropSpec = {
  type: "string" | "number" | "boolean" | "number[]" | "string[]";
  required?: boolean;
  enum?: string[];
  actionUrl?: boolean;
  noRawJs?: boolean;
};

type ComponentSpec = {
  props: Record<string, PropSpec>;
  children?: string[] | "*";
  maxChildren?: number;
};

const CATALOG: Record<string, ComponentSpec> = {
  Text: {
    props: {
      value: { type: "string", required: true },
      tone: { type: "string", enum: ["body", "title", "muted"] },
    },
    children: [],
  },
  Button: {
    props: {
      label: { type: "string", required: true },
      action: { type: "string", required: true, actionUrl: true, noRawJs: true },
      variant: { type: "string", enum: ["primary", "secondary", "danger"] },
    },
    children: [],
  },
  Form: {
    props: {
      title: { type: "string", required: true },
      action: { type: "string", required: true, actionUrl: true, noRawJs: true },
    },
    children: ["Input", "Button", "Text"],
    maxChildren: 8,
  },
  Chart: {
    props: {
      title: { type: "string", required: true },
      series: { type: "number[]", required: true },
      labels: { type: "string[]" },
    },
    children: [],
  },
  // Input, Card, Stack follow the same pattern in gate.ts
};

const ACTION_ALLOWLIST = new Set([
  "https://app.example.com/confirm",
  "https://app.example.com/cancel",
  "https://app.example.com/submit-feedback",
  "app://navigate/home",
  "app://navigate/settings",
]);

const MAX_DEPTH = 4;
Enter fullscreen mode Exit fullscreen mode

Seven types. Closed props. Actions on a short list. Depth capped.

OpenAI's Intelligent UI post describes a library of native streamable components and a compiler that processes the interface as the model generates it. Your product still needs the list of what you will mount.

If the catalog is fuzzy, the renderer will be too.

Step 2: Validate the Tree

type Issue = { path: string; message: string };

function looksLikeRawJs(s: string): boolean {
  const t = s.trim().toLowerCase();
  if (t.startsWith("javascript:")) return true;
  if (t.includes("onclick") || t.includes("eval(") || t.includes("document.")) return true;
  return false;
}

function validateNode(node: unknown, path: string, depth: number): Issue[] {
  const issues: Issue[] = [];
  if (depth > MAX_DEPTH) {
    issues.push({ path, message: `depth ${depth} exceeds max ${MAX_DEPTH}` });
    return issues;
  }
  const n = node as Record<string, unknown>;
  const spec = CATALOG[n.type as string];
  if (!spec) {
    issues.push({
      path: `${path}.type`,
      message: `unknown component ${JSON.stringify(n.type)}`,
    });
    return issues;
  }
  const props = (n.props ?? {}) as Record<string, unknown>;
  for (const [key, pspec] of Object.entries(spec.props)) {
    if (pspec.required && !(key in props)) {
      issues.push({ path: `${path}.props.${key}`, message: "required prop missing" });
    }
  }
  for (const key of Object.keys(props)) {
    if (!(key in spec.props)) {
      issues.push({ path: `${path}.props.${key}`, message: "prop not in allowlist" });
    }
  }
  for (const [key, pspec] of Object.entries(spec.props)) {
    if (!(key in props)) continue;
    const v = props[key];
    if (typeof v === "string" && pspec.noRawJs && looksLikeRawJs(v)) {
      issues.push({ path: `${path}.props.${key}`, message: "raw JS not allowed" });
    }
    if (typeof v === "string" && pspec.actionUrl && !ACTION_ALLOWLIST.has(v)) {
      issues.push({ path: `${path}.props.${key}`, message: `action not on allowlist` });
    }
    if (pspec.type === "number[]") {
      if (!Array.isArray(v) || !v.every((x) => typeof x === "number")) {
        issues.push({ path: `${path}.props.${key}`, message: "series must be numeric" });
      }
    }
  }
  for (const [i, child] of ((n.children as unknown[]) ?? []).entries()) {
    issues.push(...validateNode(child, `${path}.children[${i}]`, depth + 1));
  }
  return issues;
}
Enter fullscreen mode Exit fullscreen mode

No React. No iframe sandbox. Just the checks this catalog needs.

You can plug the same issues list into a real renderer later. The interesting part is not the framework. It is what you refuse to mount.

Validation without a reject path is just a prettier crash.

Step 3: Script the Failure Modes

const FIXTURES = [
  {
    id: "valid",
    label: "valid UI tree",
    first: JSON.stringify({
      type: "Card",
      props: { title: "Order status" },
      children: [
        { type: "Text", props: { value: "Out for delivery.", tone: "body" } },
        {
          type: "Button",
          props: { label: "Track", action: "https://app.example.com/confirm" },
        },
      ],
    }),
  },
  {
    id: "unknown-comp",
    label: "unknown component",
    first: JSON.stringify({ type: "MagicWidget", props: { spell: "renderAnything" } }),
    repair: JSON.stringify({ type: "MagicWidget", props: { spell: "renderAnything" } }),
  },
  {
    id: "bad-action",
    label: "form action off allowlist",
    first: JSON.stringify({
      type: "Form",
      props: { title: "Feedback", action: "https://evil.example/phish" },
      children: [{ type: "Button", props: { label: "Send", action: "https://evil.example/phish" } }],
    }),
    repair: JSON.stringify({
      type: "Form",
      props: { title: "Feedback", action: "https://app.example.com/submit-feedback" },
      children: [
        {
          type: "Button",
          props: { label: "Send", action: "https://app.example.com/submit-feedback" },
        },
      ],
    }),
  },
  {
    id: "raw-js",
    label: "button with raw JS",
    first: JSON.stringify({
      type: "Button",
      props: { label: "Click me", action: "javascript:alert(document.cookie)" },
    }),
    repair: JSON.stringify({
      type: "Button",
      props: { label: "Click me", action: "https://app.example.com/confirm" },
    }),
  },
  // Also in gate.ts: missing-prop, depth-bomb, bad-series, extra-props
];
Enter fullscreen mode Exit fullscreen mode

These are the shapes that show up when a model is free to invent UI: a component not in your library, a form posting somewhere you never listed, a button that wants to run script, a chart missing its title, nested stacks that blow the tree, string labels pretending to be a series, and props that look like event handlers.

The unknown-component fixture matters most. A repair loop that invents MagicWidgetV2 is still a reject.

If the type is not in the catalog, do not mount it.

Step 4: One Repair, Then Decide

function stripUnknownProps(node: unknown): unknown {
  if (node === null || typeof node !== "object" || Array.isArray(node)) return node;
  const n = node as Record<string, unknown>;
  if (typeof n.type !== "string" || !(n.type in CATALOG)) return node;
  const spec = CATALOG[n.type];
  const propsIn = (n.props ?? {}) as Record<string, unknown>;
  const propsOut: Record<string, unknown> = {};
  for (const key of Object.keys(propsIn)) {
    if (key in spec.props) propsOut[key] = propsIn[key];
  }
  const out: Record<string, unknown> = { type: n.type, props: propsOut };
  if (Array.isArray(n.children)) out.children = n.children.map(stripUnknownProps);
  return out;
}

function gate(fixture: Fixture): Outcome {
  const firstParsed = parseJson(mockComplete(fixture, "first"));
  if (!firstParsed.ok) {
    return { status: "reject", pass: "first", issues: [{ path: "$", message: firstParsed.error }] };
  }
  const firstIssues = validateTree(firstParsed.value);
  if (firstIssues.length === 0) {
    return { status: "accept", pass: "first", value: firstParsed.value as UiNode };
  }
  return repairOnce(fixture, firstParsed.value);
}
Enter fullscreen mode Exit fullscreen mode

The sequence is short on purpose.

Parse. Validate. If anything is wrong, take one constrained repair. Validate again. Stop.

For unknown props, the harness strips locally. It does not ask the model to invent CSS. For off-allowlist actions and missing titles, the fixture simulates one model retry. For MagicWidget, the retry stays invalid and the gate rejects.

In a real stack, mockComplete becomes your provider call. A Haiku-class model can sit on the "is this tree allowed?" path while a larger model drafts UI. Keep the catalog in your harness either way.

One repair is a strategy. Infinite repair is a hang.

Step 5: Metrics and a Ship Gate

const n = rows.length;
const passAt1 = rows.filter((r) => r.status === "accept" && r.pass === "first").length;
const passAtRepair = rows.filter((r) => r.status === "accept").length;
const rejected = rows.filter((r) => r.status === "reject").length;

console.log(`pass@1       ${(passAt1 / n).toFixed(2)}`);
console.log(`pass@repair  ${(passAtRepair / n).toFixed(2)}`);
console.log(`reject rate  ${(rejected / n).toFixed(2)}`);

const shipFloor = 0.75; // example threshold
console.log(`gate: ${passAtRepair / n >= shipFloor ? "SHIP" : "HOLD"}`);
Enter fullscreen mode Exit fullscreen mode

Three numbers.

pass@1 is how often the first tree was already catalog-valid.

pass@repair is how often you had a mountable tree after one constrained retry.

reject rate is how often you should show a fallback instead of inventing UI.

The ship floor here is an example: 0.75 after one repair. Pick yours from traffic, not from a blog.

Run it:

npx tsx gate.ts
Enter fullscreen mode Exit fullscreen mode

Pass rates and ship gate from the CLI

You should see:

tiny-ui-gate  fixtures=8 (example data)

id            label                      result   pass     detail
------------------------------------------------------------------------------------------------
valid         valid UI tree             accept   first    type=Card
unknown-comp  unknown component         reject   repair   $.type: unknown component "MagicWidget"...
bad-action    form action off allowlist accept   repair   type=Form
raw-js        button with raw JS        accept   repair   type=Button
missing-prop  missing required props    accept   repair   type=Chart
depth-bomb    depth bomb                accept   repair   type=Stack
bad-series    chart non-numeric series  accept   repair   type=Chart
extra-props   unknown props stripped    accept   repair   type=Text

== metrics ==
pass@1       0.13  (1/8)
pass@repair  0.88  (7/8)
reject rate  0.13  (1/8)

gate: SHIP  (pass@repair >= 0.75 required)
Enter fullscreen mode Exit fullscreen mode

Only one fixture passed on the first try.

Six more became valid after the repair.

One stayed unknown. The gate rejected it instead of inventing a renderer.

That is the whole point.

Accept catalog-valid UI. Reject the rest. Count both.

Where It Breaks Down

Provider Intelligent UI Is Not Your Gate

OpenAI's Intelligent UI ships a native component library and a compiler in their chat experience. That is the right direction for ChatGPT. Your product still owns the list of components you will mount, the actions those components may fire, and the decision to show a text fallback. Keep the provider feature. Keep the gate too.

A Tiny Catalog Is Not a Design System

Seven components will not cover your app. Production needs versioned registries, accessibility, and visual QA. The pattern stays the same: closed types, closed props, closed actions, one repair, reject.

Repair Can Invent a "Valid" Lie

Feeding errors back can make the model invent a confirm URL that was never in the ticket. Catalog validity is not semantic truth. If the action moves money or deletes data, pair the gate with a human step.

Depth Caps and Series Checks Are Heuristics

MAX_DEPTH = 4 is an example. So is "series must be numeric." Build your matrix from production failures: unknown types, action drift, script-shaped strings, and nested stacks.

Eight Fixtures Are a Smoke Test

pass@1 of 0.13 here is an artifact of how the fixtures were written. On a provider path that already streams from a closed component library, first-pass rates should be much higher. Still measure reject rate.

The Bigger Idea

Intelligent UI is not a prompt trick.

It is a contract between a probabilistic sampler and a deterministic renderer.

Model UI tree
  ↓
Parse JSON
  ↓
Validate catalog + actions ──→ issues
  ↓
One repair (strip or retry)
  ↓
Validate again
  ↓
Render, or reject and fall back to text
Enter fullscreen mode Exit fullscreen mode

The catalog provides the types.

The validator provides the evidence.

The repair provides one cheap second chance.

The reject path provides the stop.

The metrics provide the ship decision.

Your model just invented a form. Make it earn the render.


Software should explain itself.

I'm building Helix around this idea: every change should come with the why. What changed, what got better, what got worse, and the evidence behind it.

What critical engineering knowledge is your team losing right now?

See what Helix reveals →

Top comments (0)