DEV Community

LunarDrift
LunarDrift

Posted on

When AI Moves the Button: Build a Support Loop for Adaptive UIs

An adaptive interface can look impressive right up to the moment a user asks, “Where did the export button go?”

That question creates an uncomfortable tension for developers. The model may be capable of producing a plausible layout, but plausibility is not the same as operational safety. Support cannot investigate a screen that no longer exists, and developers cannot improve a system when the only evidence is a screenshot of an ephemeral UI.

The answer is not to preserve every pixel or require human approval for every spacing change. It is to treat generated UI as a versioned proposal with three properties:

  1. It can express only approved components and actions.
  2. Every rendered version can be identified and replayed.
  3. A human can freeze or replace it without asking the model for permission.

This tutorial builds that control loop with React, TypeScript, Zod, and PostgreSQL.

The architecture in one incident

Imagine an AI-generated account screen replaces a prominent Cancel subscription button with an ambiguous Manage plan menu.

A production-ready sequence should be:

Model proposes manifest
        ↓
Schema and policy validate it
        ↓
Server stores immutable revision
        ↓
Client renders revision ID ui_01J...
        ↓
User reports “I cannot find cancel”
        ↓
Report references ui_01J...
        ↓
Support replays that exact manifest
        ↓
Human freezes the scope and restores a known-good revision
Enter fullscreen mode Exit fullscreen mode

Notice what the model does not control: persistence, action permissions, deployment status, or rollback.

That boundary separates the demonstrated capability—generating structured interface proposals—from the hype that a model can safely “own” the interface. The difficult part remains human work: deciding which changes are harmless, which reports indicate real harm, and when novelty is no longer worth the uncertainty.

1. Generate a manifest, not executable UI code

Do not evaluate model-generated JSX, JavaScript, URLs, package names, or import statements. Give the model a small UI language whose actions already exist in your application.

// ui-contract.ts
import { z } from "zod";

const actionIds = [
  "open_profile",
  "open_billing",
  "cancel_subscription",
  "contact_support",
] as const;

const ActionIdSchema = z.enum(actionIds);

export type UINode =
  | { type: "heading"; text: string; level: 1 | 2 }
  | { type: "text"; text: string }
  | { type: "notice"; tone: "info" | "warning"; text: string }
  | { type: "button"; label: string; actionId: z.infer<typeof ActionIdSchema> }
  | { type: "stack"; gap: "sm" | "md" | "lg"; children: UINode[] };

const UINodeSchema: z.ZodType<UINode> = z.lazy(() =>
  z.discriminatedUnion("type", [
    z.object({
      type: z.literal("heading"),
      text: z.string().min(1).max(120),
      level: z.union([z.literal(1), z.literal(2)]),
    }),
    z.object({
      type: z.literal("text"),
      text: z.string().min(1).max(500),
    }),
    z.object({
      type: z.literal("notice"),
      tone: z.enum(["info", "warning"]),
      text: z.string().min(1).max(500),
    }),
    z.object({
      type: z.literal("button"),
      label: z.string().min(1).max(80),
      actionId: ActionIdSchema,
    }),
    z.object({
      type: z.literal("stack"),
      gap: z.enum(["sm", "md", "lg"]),
      children: z.array(UINodeSchema).min(1).max(20),
    }),
  ])
);

export const UIManifestSchema = z.object({
  contractVersion: z.literal(1),
  screen: z.enum(["account_home", "billing_home"]),
  root: UINodeSchema,
});

export type UIManifest = z.infer<typeof UIManifestSchema>;
Enter fullscreen mode Exit fullscreen mode

This contract does more than prevent syntax errors. It removes an entire supply-chain failure mode: a generated layout cannot introduce a convincing but nonexistent package because dependencies are not part of the language.

Package changes should still go through the normal repository, lockfile, CI, and human review process.

2. Keep actions outside the generated structure

A button in the manifest names an application capability; it does not define one.

// actions.ts
export const actions = {
  open_profile: () => window.location.assign("/account/profile"),
  open_billing: () => window.location.assign("/account/billing"),
  cancel_subscription: () =>
    window.location.assign("/account/billing/cancel"),
  contact_support: () => window.dispatchEvent(new Event("open-support")),
} satisfies Record<string, () => void>;
Enter fullscreen mode Exit fullscreen mode

The renderer resolves only these registered actions:

// GeneratedUI.tsx
import type { UINode } from "./ui-contract";
import { actions } from "./actions";

export function GeneratedNode({ node }: { node: UINode }) {
  switch (node.type) {
    case "heading": {
      const Tag = node.level === 1 ? "h1" : "h2";
      return <Tag>{node.text}</Tag>;
    }
    case "text":
      return <p>{node.text}</p>;
    case "notice":
      return <aside data-tone={node.tone}>{node.text}</aside>;
    case "button":
      return (
        <button type="button" onClick={actions[node.actionId]}>
          {node.label}
        </button>
      );
    case "stack":
      return (
        <div className={`stack stack-${node.gap}`}>
          {node.children.map((child, index) => (
            <GeneratedNode key={index} node={child} />
          ))}
        </div>
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

Authorization must still be enforced by the destination API. Hiding an action or omitting it from a manifest is not access control.

3. Store a revision before anyone sees it

A generated response should become an immutable revision. Do not overwrite a single current_ui JSON column, because that destroys the evidence support needs.

CREATE TABLE ui_revisions (
  id text PRIMARY KEY,
  scope text NOT NULL,
  parent_id text REFERENCES ui_revisions(id),
  status text NOT NULL CHECK (
    status IN ('proposed', 'active', 'rejected', 'frozen', 'superseded')
  ),
  manifest jsonb NOT NULL,
  policy_result jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE ui_scopes (
  scope text PRIMARY KEY,
  active_revision_id text NOT NULL REFERENCES ui_revisions(id),
  last_good_revision_id text NOT NULL REFERENCES ui_revisions(id),
  generation_enabled boolean NOT NULL DEFAULT true,
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE ui_feedback (
  id text PRIMARY KEY,
  revision_id text NOT NULL REFERENCES ui_revisions(id),
  category text NOT NULL CHECK (
    category IN ('cannot_find_action', 'misleading_copy', 'broken_action', 'other')
  ),
  expected_action text,
  message text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

Parse first, apply policy second, and only then store the proposal:

const parsed = UIManifestSchema.safeParse(modelOutput);

if (!parsed.success) {
  return { accepted: false, reason: "invalid_contract" };
}

const policy = evaluatePolicy(parsed.data);

await db.query(
  `INSERT INTO ui_revisions
     (id, scope, parent_id, status, manifest, policy_result)
   VALUES ($1, $2, $3, $4, $5, $6)`,
  [
    revisionId,
    scope,
    currentRevisionId,
    policy.autoActivate ? "active" : "proposed",
    parsed.data,
    policy,
  ]
);
Enter fullscreen mode Exit fullscreen mode

Keep the raw prompt out of this table unless you have a defined privacy and retention reason to store it. Support usually needs the resulting manifest and policy decision, not an indefinite archive of user context.

4. Make feedback identify the interface, not just the route

A route such as /account is insufficient when two visitors may receive different layouts. Put the revision ID on the rendered screen:

export function AdaptiveScreen({
  revisionId,
  root,
}: {
  revisionId: string;
  root: UINode;
}) {
  return (
    <main data-ui-revision={revisionId}>
      <GeneratedNode node={root} />
      <FeedbackForm revisionId={revisionId} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use categories that help triage rather than asking only, “How was this experience?”

function FeedbackForm({ revisionId }: { revisionId: string }) {
  async function submit(formData: FormData) {
    await fetch("/api/ui-feedback", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        revisionId,
        category: formData.get("category"),
        expectedAction: formData.get("expectedAction") || null,
        message: formData.get("message"),
      }),
    });
  }

  return (
    <form action={submit}>
      <label>
        What went wrong?
        <select name="category" required>
          <option value="cannot_find_action">I cannot find an action</option>
          <option value="misleading_copy">The wording is misleading</option>
          <option value="broken_action">An action does not work</option>
          <option value="other">Something else</option>
        </select>
      </label>

      <label>
        What were you trying to do?
        <input name="expectedAction" maxLength={120} />
      </label>

      <label>
        Details
        <textarea name="message" required maxLength={2000} />
      </label>

      <button type="submit">Send feedback</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Validate the same fields on the server. Also verify that revisionId exists; never trust a client-provided manifest.

5. Decide when a report should stop generation

Not every complaint deserves a global rollback. A small decision table keeps the response proportionate:

Signal Immediate action Follow-up
One subjective copy complaint Keep revision active Review during normal triage
Repeated “cannot find action” reports on one revision Freeze that revision Compare against its parent
Registered action throws or reaches the wrong destination Restore last-good revision Open an application bug
Billing, deletion, consent, or security action becomes ambiguous Disable generation for that scope Require human review before reactivation
Invalid manifest reaches a client Fall back immediately Treat as a contract enforcement defect

The important distinction is scope. A broken billing layout should not necessarily disable experimentation on a low-risk dashboard, but it should stop further billing mutations.

A rollback should be one database transaction, not another model request:

BEGIN;

UPDATE ui_revisions
SET status = 'frozen'
WHERE id = $1 AND status = 'active';

UPDATE ui_scopes
SET active_revision_id = last_good_revision_id,
    generation_enabled = false,
    updated_at = now()
WHERE scope = $2
  AND active_revision_id = $1;

COMMIT;
Enter fullscreen mode Exit fullscreen mode

If the second update affects zero rows, another revision may already be active. Return that conflict to the operator instead of silently claiming rollback succeeded.

6. Give support a replay view, not a screenshot hunt

Create an internal route such as:

/support/ui-revisions/:revisionId
Enter fullscreen mode Exit fullscreen mode

It should show:

  • the validated manifest;
  • its parent and current status;
  • the policy result;
  • feedback attached to that revision;
  • a render using the same component registry;
  • explicit Freeze and Restore last good controls;
  • whether the revision is still assigned to any scope.

The replay view must remain read-only by default. Rendering a historical manifest should not trigger analytics, navigation, billing operations, or other real actions. Replace action handlers with labels such as Would invoke: cancel_subscription.

This is also useful engineering practice for less-experienced developers. Instead of asking them to trust or reject “the AI,” they can inspect a concrete artifact, identify the violated invariant, and make a bounded decision. Judgment grows from examining failures, not from pretending automation removed the need to understand them.

7. Where AI belongs in the support loop

Once reports are linked to immutable revisions, AI can assist with tasks whose output remains advisory:

  • cluster reports that reference the same revision and expected action;
  • summarize differences between a revision and its parent;
  • propose clearer labels using the existing component contract;
  • suggest replay cases for a human to approve.

It should not:

  • close reports because their wording looks similar;
  • activate a replacement revision;
  • invent or install a package to render a new component;
  • decide that a billing or consent change is harmless;
  • execute an action while replaying a report.

The model can reduce reading and drafting work. The human still decides what the interface is allowed to mean.

Optional: add live conversation without coupling it to rollback

A structured form is useful for aggregation, but some users need a conversation. You can add a hosted chat widget while keeping revision storage and rollback inside your application.

For example, Knocket provides an embeddable live-chat widget installed with a script tag and does not require a custom chat backend. Visitors do not need an account to begin chatting.

Keep the revision reference visible and easy to copy rather than depending on undocumented widget metadata:

<p>
  Diagnostic code:
  <code id="ui-diagnostic">ui_revision=ui_01JABC123</code>
</p>
<button type="button" id="copy-ui-diagnostic">Copy diagnostic code</button>

<script>
  document
    .getElementById("copy-ui-diagnostic")
    .addEventListener("click", async () => {
      const value = document.getElementById("ui-diagnostic").textContent;
      await navigator.clipboard.writeText(value);
    });
</script>

<!-- Paste the Knocket script tag generated by its setup flow here. -->
Enter fullscreen mode Exit fullscreen mode

Messages can be handled in its unified inbox or routed to Telegram, where a quoted reply can be delivered back to the website visitor. The durable technical record should still be the revision and structured report in your own system; chat is the conversational return path, not the rollback mechanism.

Failure drills to run before release

The model names an unknown action

Expected result: schema validation rejects the complete proposal. Do not silently drop the button and render an incomplete screen.

A revision passes schema validation but hides a critical action

Schema validity does not prove product correctness. Add scope-specific policy rules, such as requiring cancel_subscription somewhere in the billing cancellation journey.

The current revision changes while support investigates

The report must continue pointing to its original immutable revision. The replay page should clearly state that the revision is no longer active.

The feedback endpoint is unavailable

Keep the diagnostic code visible so the user can include it in another support channel. Do not claim the report was received until the server acknowledges it.

The generated interface fails to render

Install an error boundary outside the generated subtree. It should replace the subtree with a static known-good navigation surface and expose the revision ID.

A historical replay invokes a real action

Treat this as a release blocker. Replay mode must inject inert handlers rather than import the production action registry.

The model suggests a new dependency

Ignore the suggestion at runtime. If the capability is genuinely needed, evaluate the package through registry verification, ownership review, lockfile changes, CI, and the same code-review process as any other dependency.

Release checklist

Before enabling adaptive UI for a scope, verify that:

  • [ ] Model output is parsed as data and never evaluated as code.
  • [ ] Components and action IDs come from explicit allowlists.
  • [ ] Server-side authorization remains independent of visibility.
  • [ ] Every displayed manifest has an immutable revision ID.
  • [ ] Feedback stores that revision ID rather than trusting submitted UI JSON.
  • [ ] Support can replay the exact manifest with inert actions.
  • [ ] A known-good revision exists before generation is enabled.
  • [ ] Rollback does not call the model.
  • [ ] High-risk scopes have stricter activation rules.
  • [ ] Dependency additions remain a repository and CI decision.
  • [ ] Users have a fallback route when generated UI cannot render.
  • [ ] Operators can disable generation per scope.

Adaptive interfaces do not remove frontend or support work. They move the work from manually arranging every screen toward defining contracts, recognizing unsafe semantics, and responding well when a proposal fails.

That is not a loss of developer value. It is a clearer description of where developer judgment is required.

Disclosure: I work on Knocket, so treat it as one implementation example rather than a neutral recommendation.

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY

This is the part many teams underestimate: once a UI becomes adaptive, reproducibility becomes a support and security requirement, not just a debugging convenience.

I especially like the emphasis on immutable revision IDs and replaying the exact interface a user saw. Without that, every support ticket turns into “it looked different on my screen,” which is almost impossible to investigate. AI can generate proposals, but humans still need deterministic artifacts they can audit, replay, and roll back. That’s what makes adaptive UIs operationally trustworthy.