- Problem: teams pick one generation level for the whole product. Text is too weak for comparisons, and sandboxed code is too risky and slow for a yes/no answer.
- Forces: expressiveness vs. control, latency, testability (§1.2).
- Solution: choose a rung per response. Escalate only when the lower rung can't express the answer. Make the router explicit and loggable.
- In the wild: CopilotKit's display-only → controlled → declarative → open-ended split (docs); Interfaces for Humans EXP-006 (site).
- Sketch:
rung = needsInteraction ? (inCatalog ? "R3" : "R4") : needsVisual ? "R2" : "R1" - Failure modes: escalating for novelty; a router that never de-escalates.
- Test: label 100 prompts with their minimal rung and measure over-escalation rate.
Pattern language
45 patterns for interfaces where the model only proposes.
Every pattern has the same fields: problem, forces, solution, where it exists in the wild, a sketch, how it fails, and how to test it. Names marked ◆ were coined in this guide where the field had no name yet. Start with the essay The model only proposes for the four-plane map they all hang from.
Generation
- Problem: free-form UI generation can't be branded, validated or trusted.
- Forces: creativity vs. guarantees; catalog size vs. prompt size (EXP-002, "how many tools an agent can hold").
- Solution: the model composes only allowlisted components whose props have schemas. Generate the prompt from the catalog, so generation and validation share one source of truth.
- In the wild: A2UI
catalogIdandsupportedCatalogIds(spec); json-renderdefineCatalog+catalog.prompt()(repo). - Sketch:
defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string() }), description: "…" } } }) - Failure modes: a catalog bloated past what the model can use well; escape hatches like a
Htmlcomponent that silently reintroduce R4. - Test: fuzz specs against the catalog; track the share of outputs that need repair.
- Problem: models produce specs that are almost valid.
- Forces: each repair costs a model round trip; deterministic fixes are cheap but limited.
- Solution: validate, then fix what you can algorithmically. Send only structured errors (path + one-sentence message) back to the model, with a bounded number of retries.
- In the wild: A2UI
error { code: "VALIDATION_FAILED", path, message }; VegaChat (paper). - Sketch:
for (i of [0,1]) { e = validate(s); if (!e.length) break; s = autofix(s,e) ?? await repair(s,e) } - Failure modes: repair loops that "fix" by deleting content; unbounded retries.
- Test: a corpus of known-broken specs; measure fix rate and content preservation.
- Problem: a spec fails validation at the end of a stream and the user sees nothing, or a crash.
- Forces: honesty vs. polish.
- Solution: every rung has a defined downgrade. R4 → R3 → R2 → R1 table → R0 text. Show the downgrade visibly, with a "retry richer" option.
- In the wild: A2UI client-side
errormessages let the agent re-plan; Markdown tables as the universal fallback. - Sketch: see
render()in §1.2. - Failure modes: fallback text that claims the UI rendered; silently dropped data.
- Test: inject validation failures at each rung and assert the fallback contains every data field.
- Problem: models copy numbers into specs and drift from the source of truth.
- Forces: convenience vs. fidelity; context size.
- Solution: the model emits structure and encodings over named data handles. The runtime binds the real data and computes the aggregates.
- In the wild: Vega-Lite named data sources (docs); A2UI bindings to the data model.
- Sketch:
{ "data": { "name": "q3_sales" }, "mark": "bar", "encoding": { "y": { "field": "revenue", "aggregate": "sum" } } } - Failure modes: handles leaking private data into the prompt; the model inventing handle names.
- Test: reject any
data.valuesfrom the model; property-test that rendered totals equal the database totals.
- Problem: stored generated UI breaks or changes meaning when components evolve.
- Forces: storage cost vs. auditability; rehydration drift (§2.7).
- Solution: persist the spec +
catalogId@version + a data snapshot. For approvals, also persist a render hash or screenshot of what was shown. - In the wild: A2UI fixes
catalogIdfor a surface's lifetime. - Sketch:
{ spec, catalog: "acme/ui@3.2", data, shownHash } - Failure modes: "latest catalog" rendering of historical approvals.
- Test: re-render the archive with each catalog release and diff against the stored hashes.
State
- Problem: nobody knows who may write a field, so the user and the agent overwrite each other.
- Forces: flexibility vs. predictability.
- Solution: declare a writer, observers, conflict rule and model visibility per JSON Pointer, and enforce it in the patch applier (§2.1).
- In the wild: partial versions exist in protocols: MCP Apps tool
visibility; AG-UI activity vs. conversation messages. - Sketch:
"/trip/notes": { writer: "user", rule: "user-wins", modelVisible: true } - Failure modes: a map that drifts from the code; a "both" writer everywhere.
- Test: a property test where random interleaved user and agent writes never violate the map.
- Problem: sending full state on every change is slow; sending only deltas risks divergence.
- Forces: bandwidth vs. robustness.
- Solution: a snapshot to establish the baseline, deltas for steady state, and a snapshot again on reconnect, patch failure or detected divergence.
- In the wild: AG-UI
STATE_SNAPSHOT/STATE_DELTA(docs). - Sketch:
on(delta) → try apply atomically; catch → requestSnapshot() - Failure modes: non-atomic application leaving half-patched state; snapshots that clobber unsent client edits.
- Test: drop or reorder deltas in replay and assert convergence after resync.
- Problem: agent patches are computed against stale state.
- Forces: concurrency vs. agent simplicity.
- Solution: prefix patches with RFC 6902
testops on every assumption. Reject atomically on mismatch, then re-plan from a fresh snapshot. - In the wild: RFC 6902
test(RFC); the AG-UI reference client's atomic apply. - Sketch: §2.3
guarded() - Failure modes: guarding too little (only the target path, not its dependencies); livelock when the user keeps typing.
- Test: the Lab 1 simulator; count lost user edits (target: 0).
- Problem: direct agent edits to human-authored artefacts feel invasive and are hard to review.
- Forces: speed vs. control.
- Solution: agent writes land in a suggestion layer that is accepted or rejected per hunk. Make it persistent and shared (tracked changes) when others need to see it.
- In the wild: Tiptap AI Toolkit suggestions and tracked changes (docs); code-agent diff review.
- Sketch:
doc.applyAsSuggestion(patch, { author: "agent", runId }) - Failure modes: suggestion floods; stale suggestions after the base text changes.
- Test: measure accept rate per suggestion type, plus time-to-review.
- Problem: regenerating a whole UI to change one number is slow, flickers and loses local state.
- Forces: simplicity of one blob vs. update efficiency.
- Solution: components bind to data-model paths. Data updates never touch structure, and vice versa.
- In the wild: A2UI
updateComponentsvsupdateDataModel; json-render$state/$bindState. - Sketch:
{ "id": "price", "type": "Text", "text": { "path": "/quote/total" } } - Failure modes: bindings to paths that never arrive; the agent never learning about local two-way edits until an action fires.
- Test: stream data-only updates and assert zero component remounts.
- Problem: linear undo can't express "go back and try with a different budget".
- Forces: storage, UI complexity, side effects.
- Solution: checkpoint each step. Expose replay and fork as a branch tree, and mark which checkpoints are downstream of world effects.
- In the wild: LangGraph time travel (docs); Claude Code
/rewind. - Sketch: §2.4 diagram.
- Failure modes: implying the world rewinds too; interrupts and side effects re-firing on replay.
- Test: fork past an effect and assert the UI warns that the effect will not be undone.
- Problem: UI state reconstructed from ad-hoc stores can't be replayed, debugged or tested.
- Forces: log size vs. reproducibility.
- Solution: treat the event stream as the truth, make the UI a pure fold over it, and compact with snapshots.
- In the wild: AG-UI events +
MESSAGES_SNAPSHOT(docs). - Sketch:
const ui = events.reduce(reducer, initial) - Failure modes: reducers with hidden I/O; non-deterministic IDs.
- Test: record a session, replay it, and diff the projections byte for byte.
- Problem: optimistic agent claims get reversed silently after the human has acted on them.
- Forces: calm UI vs. honesty.
- Solution: scale how visible a rollback is to how much the human relied on the optimistic state: quiet for shape fixes, loud with an explanation for world effects (§2.6).
- In the wild: React
useOptimisticcovers the quiet case (docs); the loud case is mostly missing in products. - Sketch:
rollback(change, { loudness: reliedOn(change) }) - Failure modes: toast-only rollback of a booking.
- Test: inject late failures and check that the notification channel matches the reliance level.
Streaming
- Problem: components swap type mid-stream, or render empty until the end.
- Forces: natural schema order vs. stream order.
- Solution: order keys as type → id → small structural fields → large text → arrays. Prefer flat adjacency lists.
- In the wild: A2UI's flat component list with ID references.
- Sketch:
z.object({ type: z.literal("chart"), id: z.string(), title: z.string(), rows: z.array(Row) }) - Failure modes: providers that don't preserve key order [model-dependent].
- Test: replay token streams and count component-type changes after first paint (target: 0).
- Problem: partial numbers and enums flicker into wrong values.
- Forces: perceived speed vs. correctness.
- Solution: render unsettled strings as provisional, and unsettled numbers, enums and booleans as placeholders. Style provisional content distinctly (§3.2).
- In the wild: the AI SDK
input-streamingtool part state exposes partial input for exactly this. - Sketch:
{settled ? <Price v={n}/> : <Skeleton w="4ch"/>} - Failure modes: "settled" heuristics fooled by keys repeated inside strings.
- Test: truncate the stream at every byte and assert no number is ever shown that differs from its final value.
- Problem: effects triggered by partial tool arguments.
- Forces: eagerness vs. validity.
- Solution: preview on args deltas; execute only after the end event and successful validation of the complete JSON.
- In the wild: AG-UI's rule (per summary); Anthropic's
eager_input_streamingwarning (docs). - Sketch:
on("TOOL_CALL_END", id => { const a = JSON.parse(buf[id]); schema.parse(a); run(a) }) - Failure modes: UI-side "auto-apply" of streamed edits (e.g. live code insertion) that are actually effects.
- Test: cut streams before END and assert zero effects.
- Problem: orphan fragments flash, then the layout jumps.
- Forces: first paint vs. stability.
- Solution: buffer until the root is known, then mount a skeleton derived from the declared structure and fill it in place.
- In the wild: A2UI buffers updates until a component with ID
rootexists (spec). - Sketch: §3.3 sequence diagram.
- Failure modes: a root that arrives last, which turns this into a blank wait; skeletons whose size differs from the content.
- Test: measure cumulative layout shift across replayed streams.
- Problem: "Stop" leaves users unsure what happened.
- Forces: simplicity vs. honesty.
- Solution: after a cancel, list what was committed, what was abandoned, and what is unknown (in flight). Offer compensating actions for the committed items.
- In the wild: AG-UI 1.0
RUN_FINISHED.outcome: cancelledand AI SDK 7 abort reasons enable it; products rarely render it. - Sketch:
{ committed: [...], abandoned: [...], unknown: [...] } - Failure modes: treating
disconnectasstop. - Test: cancel at random points in a scripted run and assert the receipt matches the server's ledger.
Control & consent
- Problem: confirmation fatigue on trivial actions, and too little friction on dangerous ones.
- Forces: safety vs. flow; arguments change the consequence.
- Solution: classify each effect by reversibility class × blast radius, computed from its arguments, and map the class to a treatment (§4.4).
- In the wild: AI SDK 7
toolApproval(docs); WebMCPconsequentialHint/readOnlyHint(spec); the Interfaces for Humans reversibility ladder. - Sketch: §4.4
toolApproval - Failure modes: a static per-tool class; "approve all" buttons.
- Test: the Lab 3 harness; measure confirms per session vs. irreversible errors that escape.
- Problem: effects dispatched before, or without, a visible confirmation.
- Forces: generated UIs are written by a model that doesn't reason about event ordering.
- Solution: a host-side gate that holds effect-bearing requests until a host-rendered confirmation showing matching arguments has been visible and acted on.
- In the wild: RUI (interfacesforhuman.com).
- Sketch: §4.5 sequence diagram.
- Failure modes: effects that leave through
sendBeacon, WebSockets or form posts the gate doesn't see; visibility judged by DOM presence alone. - Test: RUI-style real-browser runs with request interception, plus checks for visibility, occlusion and opacity.
- Problem: the thing approved is not the thing executed (edited args, replay, forged approvals).
- Forces: convenience vs. integrity.
- Solution: bind each approval to
hash(tool, callId, args). Sign it server-side, and verify before execution and on replay. - In the wild: AI SDK 7
experimental_toolApprovalSecret(HMAC) (docs). - Sketch:
sig = hmac(secret, tool + callId + canonical(args)) - Failure modes: non-canonical JSON hashing; secrets that differ across instances.
- Test: mutate args after approval and assert the run is rejected.
- Problem: binary approve/deny forces the human to reject a nearly-right action and re-prompt.
- Forces: flexibility vs. auditability.
- Solution: approval UIs expose the arguments as an editable form generated from the schema. Edited arguments replace the originals, and the audit trail keeps both.
- In the wild: AG-UI
editedArgs(replace, not merge) (interrupts); Agent Inboxallow_edit; LangGraph review-and-edit. - Sketch:
resume: [{ interruptId, status: "resolved", payload: { approved: true, editedArgs } }] - Failure modes: deep-merge semantics that resurrect fields the human deleted.
- Test: edit by deleting a field and assert it is absent at execution.
- Problem: irreversible-bounded effects need confirmation, which is slow.
- Forces: speed vs. regret.
- Solution: execute "optimistically" in the UI but delay the real effect by N seconds behind a visible undo. This converts the effect class temporarily.
- In the wild: Gmail Undo Send (help).
- Sketch:
const t = setTimeout(commit, 10_000); showUndo(() => clearTimeout(t)) - Failure modes: closing the tab mid-window (commit server-side, not via client timers); agents chaining on uncommitted effects.
- Test: kill the client during the window and assert server-side consistency.
- Problem: "Approve send_bulk_update?" can't be meaningfully judged.
- Forces: cost of a dry run vs. quality of consent.
- Solution: run the effect in plan mode and show its diff on the world (rows, recipients, rendered email). Approval references the plan's hash.
- In the wild: Terraform
plan/applyas the archetype (docs); code-agent diffs. - Sketch:
const plan = await tool.plan(args); approve(plan.hash); tool.apply(plan) - Failure modes: a plan that is stale at apply time (re-check the precondition).
- Test: change the world between plan and apply and assert that apply refuses.
- Problem: resuming a paused run re-executes code and duplicates effects.
- Forces: simple node code vs. runtime semantics.
- Solution: put side effects after
interrupt(), or make them idempotent (upserts, idempotency keys from the interrupt ID). Make replays of a resume safe. - In the wild: LangGraph: "the runtime restarts the entire node from the beginning" (docs); AG-UI: "replaying the same resume must be safe".
- Sketch:
await api.charge({ idempotencyKey: interruptId, ... }) - Failure modes: logging, emails and analytics before the interrupt.
- Test: the Lab 5 time-travel simulator; resume twice and assert one effect.
Visibility
- Problem: humans can't steer what they can't see coming.
- Forces: planning latency vs. steerability.
- Solution: show an editable plan before acting. Track item states, and visibly diff the plan when the agent revises it.
- In the wild: Cursor Plan Mode (changelog); Claude Code plan mode and todos.
- Sketch:
plan: { id, items: [{ text, state: "pending"|"active"|"done"|"dropped" }], revision } - Failure modes: plans as decoration, where the agent ignores the edits; silent plan drift.
- Test: edit a plan item and assert that the execution trace follows the edit.
- Problem: one verbosity level for every user and every moment.
- Forces: attention vs. oversight.
- Solution: ambient → summary → detail → forensic, each linking to the next (§5.1). Reasoning lives in forensic.
- In the wild: AG-UI activity vs. reasoning events; trace tools as the forensic tier.
- Sketch:
<Status/> → <Summary/> → <Timeline/> → <Trace/> - Failure modes: reasoning text promoted to the detail tier as if it were an explanation.
- Test: a task-based study measuring time-to-detect a planted agent error at each tier.
- Problem: in shared surfaces, agent edits appear from nowhere.
- Forces: distraction vs. awareness.
- Solution: the agent publishes presence (identity, focus, status, TTL), and its edit region is visibly provisional.
- In the wild: Liveblocks agent
setPresence(blog); tldraw agent viewport (kit). - Sketch:
setPresence(room, { userId: "agent-1", data: { editing: "title-1", status: "Thinking…" } }) - Failure modes: a ghost presence after a crash (use a TTL); fake "typing" theatre.
- Test: kill the agent and assert the presence clears within the TTL.
- Problem: reviewing agent output by re-reading everything doesn't scale.
- Forces: granularity vs. throughput.
- Solution: present changes as hunks with accept/reject per hunk, grouped by intent, with tests or previews attached.
- In the wild: Cursor agent review pane; Tiptap suggestions; Copilot coding agent pull requests.
- Sketch:
hunks.map(h => <Hunk h={h} onAccept onReject why={h.intent}/>) - Failure modes: "Accept all" as the default; diffs too large to read.
- Test: seed known bad hunks and measure catch rate vs. diff size.
- Problem: background agents interrupt via chat, or block silently.
- Forces: responsiveness vs. focus.
- Solution: an inbox of typed decisions (accept / edit / respond / ignore, enabled per item), with deadlines (
expiresAt) and batching. - In the wild: LangChain Agent Inbox (repo); AG-UI interrupt
expiresAt. - Sketch:
{ id, reason, allowed: ["accept","edit"], expiresAt, preview } - Failure modes: inboxes nobody checks; expiry that defaults to "proceed".
- Test: simulate a load of N decisions per hour and measure decision latency and expiry outcomes.
- Problem: one global autonomy setting is too permissive somewhere and too restrictive elsewhere.
- Forces: trust grows unevenly.
- Solution: a matrix of scope (path, tool, tenant) × reversibility class → policy, editable by the user, with suggestions ("you approved this 20 times; auto-approve?").
- In the wild: Karpathy's autonomy slider (notes); AI SDK 7 per-tool approval functions.
- Sketch:
policy["fs:src/**"]["user"] = "auto"; policy["shell:*"]["*"] = "ask" - Failure modes: auto-promotion without decay; a scope that matches too broadly.
- Test: replay a historical run under a policy and count would-be escapes.
- Problem: static agent graphs hide loops and dynamic fan-out; traces hide structure.
- Forces: overview vs. fidelity.
- Solution: overlay execution on topology (visit counts, latency heat, a superstep scrubber) and click through to spans.
- In the wild: Langfuse agent graphs (beta) (docs); LangSmith Studio (docs).
- Sketch:
edges.map(e => ({ ...e, visits: count(trace, e), p95: lat(trace, e) })) - Failure modes: a graph layout that changes between runs.
- Test: plant a 14-iteration loop and assert it is visible at the overview level.
Reverse channel
- Problem: the agent reasons over a DOM dump or nothing.
- Forces: token cost, privacy, fidelity.
- Solution: components declare what the agent should know, as curated typed state with descriptions. Secrets are excluded by the authority map.
- In the wild: AG-UI shared state; CopilotKit readable/context hooks; MCP Apps
ui/update-model-context. - Sketch:
useCopilotReadable({ description: "Visible invoices (max 50)", value: rows.slice(0,50).map(pick("id","client","amount")) })(CopilotKit v1 hook; later versions may rename it [unverified]) - Failure modes: unbounded arrays; stale values.
- Test: token budget assertions; a privacy lint for fields marked
modelVisible: false.
- Problem: "fix this" and "refund these" are ambiguous.
- Forces: precision vs. effort.
- Solution: send the current selection (IDs + version) with every message, and render the referent back in the agent's proposal ("refunding r3, r7").
- In the wild: tldraw sends the user's recent actions with each agent request; Liveblocks anchors comments to elements so "this" is never ambiguous.
- Sketch:
send(msg, { selection: { ids, version } }) - Failure modes: selection changing between send and act (see R4).
- Test: scripted ambiguity cases; check that the referent shown matches the referent used.
- Problem: humans and agents operate the same app through different, divergent paths.
- Forces: one implementation vs. two audiences.
- Solution: each interactive component exposes a human affordance and a typed tool that drive the same state transition. Annotate with stable names.
- In the wild: WebMCP
registerToolwith annotations (spec); Agentable UIdata-ui/data-tool(site). - Sketch: §7
InvoiceTable - Failure modes: tool and UI handlers drifting apart; tools that bypass validation the UI enforces.
- Test: contract tests asserting the tool path and the click path produce identical state.
- Problem: the agent acts on UI state that has since changed.
- Forces: freshness vs. round trips.
- Solution: stamp every context item with a version. Proposals cite the versions they used, and the UI re-confirms or rejects on mismatch.
- In the wild: A2UI action messages carry
timestampand resolvedcontext; AG-UI resume validation. Full version-citing is rare. - Sketch:
if (proposal.basedOn !== selection.version) reconfirm() - Failure modes: versions per page rather than per field (constant false conflicts).
- Test: the §7 selection race as an automated test.
- Problem: you can't see what the agent sees.
- Forces: fidelity vs. cost.
- Solution: generate a deterministic agent view (roles, names, states,
data-uiIDs, tools) and show it side by side with the human view during development. - In the wild: tldraw request assembly (screenshot + shapes + clusters + actions); Interfaces for Humans EXP-003.
- Sketch:
agentView(root) = walkA11y(root).map(n => [n.role, n.name, n.state, n.dataset.ui, n.dataset.tool]) - Failure modes: an agent view that differs from what the production agent actually receives.
- Test: snapshot the agent view in CI; fail on unlabeled interactive nodes.
Safety
- Problem: instructions in tool output or pages get obeyed.
- Forces: utility of reading the web vs. obedience.
- Solution: label context by provenance. Proposals derived from tainted context can't trigger consequential effects without host-rendered confirmation, and can never reach unbounded effects.
- In the wild: WebMCP
untrustedContentHint; Brave's research on Comet (blog). - Sketch:
if (call.taint && cls >= "operator") return "user-approval" - Failure modes: taint laundering through summaries.
- Test: a red-team corpus with planted instructions (Lab 8).
- Problem: R4 code can exfiltrate or reach the host.
- Forces: capability vs. containment.
- Solution: a separate-origin iframe with
allow-scriptsbut notallow-same-origin, deny-by-default CSP with declared domains, and all effects through host-mediated tools. - In the wild: MCP Apps (overview); Claude artifacts.
- Sketch:
<iframe sandbox="allow-scripts" src="https://sandbox.example/…" csp="connect-src 'none'"> - Failure modes:
allow-same-origin+allow-scriptson the same origin, which defeats the sandbox. - Test: an escape test suite (cookie access, top navigation, fetch to undeclared hosts).
- Problem: generated UI can draw a convincing fake "Approve" or show different amounts than it submits.
- Forces: integrated look vs. integrity.
- Solution: confirmations, amounts, recipients and identity are rendered only by the host, outside generated surfaces, from the bound arguments (C3).
- In the wild: the host-rendered approval parts and interrupts in AI SDK and AG-UI enable it; MCP Apps hosts decide display modes.
- Sketch:
<HostConfirm args={bound.args} class={cls}/>(never<iframe>…Confirm…</iframe>) - Failure modes: chrome that looks the same as generated UI; spoofable by lookalike styling.
- Test: a visual-distinctness check; assert no effect is released on an in-frame click alone.
- Problem: screenshot tests pass while behaviour is wrong.
- Forces: cost of real browsers vs. confidence.
- Solution: run generated UIs in a real browser, perform the task, intercept every effect, and grade each by reversibility class and by what was visible at dispatch.
- In the wild: RUI (interfacesforhuman.com), EXP-001 "grading effects rather than pixels".
- Sketch:
page.route("**", r => effects.push({ req: r.request(), visible: snapshotA11y() })) - Failure modes: effects outside HTTP (WebSocket, beacon); flaky visibility heuristics.
- Test: a suite of seeded-bug UIs with known verdicts.
- Problem: streaming bugs appear only at specific cut points.
- Forces: combinatorial space.
- Solution: replay recorded streams truncated at every chunk boundary (and every byte for JSON). Also reorder within the allowed reorderings, and inject invalid JSON at the end.
- In the wild: Anthropic's guidance that streamed tool input may be invalid and should come back as
is_error(docs). - Sketch:
for (i of range(stream.length)) assertInvariants(render(stream.slice(0,i))) - Failure modes: invariants too weak ("doesn't crash").
- Test: invariants: no effect before END; no number changes after display; no component type swap.
- Problem: streamed UI either floods or silences screen readers.
- Forces: immediacy vs. usability.
- Solution:
aria-busywhile streaming, a polite announcement on settle, state-change announcements for approvals and errors, and no focus theft. - In the wild: WAI-ARIA live regions (spec) [synthesis; validate with users].
- Sketch:
<div aria-live="polite" aria-busy={streaming}>{settled && summary}</div> - Failure modes: live regions mounted after content; per-token announcements.
- Test: an automated screen-reader transcript (e.g. Guidepup) on replayed streams.
- Problem: models generate dark patterns by default.
- Forces: conversion pressure in prompts vs. user interests.
- Solution: leave manipulative primitives out of the catalog (pre-checked paid options, fake timers, confirm-shaming), and lint generated copy and defaults.
- In the wild: evidence of need: 55.8% of LLM components contained deceptive patterns (Chen et al.).
- Sketch:
lint: Checkbox.checked===true && price>0 → error - Failure modes: manipulation through copy alone.
- Test: the deceptive-pattern checklist applied to every generated flow; track the rate over time.
What experts know that beginners don't
- The model owns nothing. It proposes. Every architecture decision is about who promotes a proposal into a projection or a commitment, and on whose authority.
- Resuming re-runs the node. In LangGraph, "the runtime restarts the entire node from the beginning" on resume, and interrupts re-fire during time travel. Anything before
interrupt()must be idempotent (docs). - Rewinding the thread doesn't rewind the world. Undo boundaries are as wide as the instrumented effect channel. Claude Code's rewind reportedly doesn't cover shell-command changes (guide).
- Key order is a UX decision. Put the discriminant first or watch components morph mid-stream. Partial numbers are worse than no numbers.
- Never act on partial arguments. Act on
TOOL_CALL_ENDplus validation. Eagerly streamed tool input may never become valid JSON (Anthropic). - HTTP 200 means nothing in a stream. Failures arrive later as
RUN_ERROR. Success is an event, not a status code. - What the human saw isn't what the model knows. AG-UI activity messages never return to the agent, and A2UI inputs don't reach the agent until an action fires. You must route "what was on screen" back deliberately.
- Approvals must bind to arguments, and edits replace rather than merge. Otherwise "approved" means "approved something like this". See AI SDK 7 HMAC-signed approvals and AG-UI
editedArgs. - Reversibility belongs to the arguments, not the tool.
send_emailto one person and to a list are different classes. Classify at call time. - Confirmations only count if the host renders them. Generated UI can draw a perfect fake Approve button or display $40 and submit $4,000. Trusted chrome is non-negotiable for irreversible effects.
- More confirmations can make you less safe. Rubber-stamping is the steady state of over-gated systems. Spend attention where undo is impossible.
- Last-write-wins between an agent and a person is a data-loss bug, not a design choice. Use guarded patches, leases, suggestions or CRDTs.
- A CRDT merges characters, not meaning. Concurrent agent and human edits can merge cleanly and still be semantically wrong. That's why suggestion layers exist even in CRDT-based editors.
- Accessibility debt is now agent debt. Agents read the accessibility tree and
data-*contracts. The unnameddivblocks both audiences. - Everything in model context is potentially public. Widget state, readable state and tool output can be extracted by an injection elsewhere in the context. Keep secrets out of the context plane.
- Schema-valid doesn't mean truthful, especially for charts. Keep data by reference and recompute aggregates outside the model.
- Models produce dark patterns by default. That was 55.8% of components in one CHI '26 study, more when prompts stress business goals. The catalog and lint are your ethics layer.
- Autonomy is set by verification cost, not model capability. Raise it where diffs, tests and undo make checking cheap (Karpathy's generation–verification loop).
- Persist specs, catalogs and what was shown. Re-rendering an old approval with today's components is rehydration drift, and it breaks audit.