May 18, 2026 · 10 min read

What building Lena Agent taught me about generative UI

We built an agent that has no screens.

The agent layer wraps 37 tools exposed from our REST API. Tools cover identity, schools, members, courses, assignments, quizzes, CBT exams, announcements, calendar, search. Teachers and admins talk to the agent. It does the work.

The first version had screens. A /quizzes screen. A /courses screen. A /search screen. The agent could call any of the 37 tools, and the result of each tool call would route to the matching screen.

It worked. It also felt like every other LMS with a chat box bolted on. The agent could do impressive things, but the user spent half their attention navigating the wrapper UI and half on the agent. Worse, every time we added a tool, we had to design a screen for the result. The agent's capability outran the UI's expressiveness. We had screens that looked thin (just one data row, because the agent only returned one) and screens that looked overloaded (because the agent returned more than the screen was designed to hold).

The breakthrough was deleting the screens.

What replaced them

A small JSON schema of UI atoms. Twelve primitives in the first cut. The rough shape was something like:

type ViewPrimitive =
  | { kind: "canvas-step-card"; tool: string; status: "running" | "done" | "failed"; elapsed_ms: number; summary: string; raw_input?: object; raw_output?: object }
  | { kind: "course-tile"; id: string; title: string; teacher: string; thumbnail_url?: string }
  | { kind: "member-row"; id: string; name: string; role: "teacher" | "student" | "admin"; email: string }
  | { kind: "approval-tray"; proposed_action: string; primary_cta: string }
  | { kind: "raw-response"; data: object }
  | { kind: "status-pill"; tone: "neutral" | "warning" | "success"; text: string }
  // ... more

The agent's output, per turn, is a list of these primitives in the order they should render. The client knows how to render each primitive. There is no business logic in any React component. The component file is a renderer; the agent is the designer at runtime.

A complete agent turn looks like this:

User: "Create a five-question MCQ on photosynthesis for my biology class, publish it, and post an announcement."

Agent decides to call: who_am_i, set_active_role(teacher), list_courses(scope=mine, search=biology), create_quiz(course_id=..., title="Photosynthesis"), create_quiz_question x 5, publish_course(quiz_id=...), create_announcement(course_id=..., body="New quiz published").

Agent returns, per tool call, a canvas-step-card primitive. Then a final status-pill with text "All done. Quiz live. Class notified." Then optionally an approval-tray if the agent thinks the next action is to send personalized reminders.

The teacher sees seven step cards stream in over about eight seconds, each one expandable to show the raw API request and the raw response. At the end, the status pill. The whole UI was composed by the agent's response. No designer drew it.

What this unlocked

Three things, in order of how much they mattered.

Trust comes from receipts. Teachers do not trust a chatbot that says "done." Teachers do trust an agent whose work is visible. The step cards are visible work. A teacher who is curious can expand any card and see the exact API call. A teacher who is busy can ignore them. The mental model shifts from "the agent might be doing something wrong" to "I can audit the agent's work whenever I want." That audit-on-demand is the trust mechanism. It's also the same pattern Claude Code uses with its tool transcripts. Both products converged on it independently because both products are doing real work that users are right to be skeptical of.

The UI's expressive range scales with the agent's capability automatically. Add the 38th tool, the agent uses it, the canvas step card renders, no new design work. In the screens-per-tool model, that addition would have been a sprint.

Teachers stopped feeling like they had two products in one. The "agent + app" mental model collapsed to "agent."

The hard parts

Generative UI is not free. Three things bit us.

Latency. Streaming primitives over SSE is cheap. Composing them is also cheap. The expensive part is the model deciding which primitive to use, especially when the answer involves rich data. Our first cut had the model deciding primitives one at a time as it streamed, which felt slow. The fix was a two-phase generation: the model decides the shape of the response first (which primitives, in what order), then fills in the contents. Phase one is small and fast; phase two streams.

Invalid primitive responses. Sonnet 4.6 occasionally returns a JSON object that doesn't match the schema. About 1 in 200 turns. The fallback is a stock layout: a single text block with the model's prose, then a generic action-tray with the cancel option. We log the malformed response, file it, and adjust the system prompt. Over a month the rate dropped to roughly 1 in 1500.

Caching is hard. With static screens, you cache the screen and re-fetch the data. With generative UI, you can't cache the screen because there is no screen to cache. You can cache the data layer aggressively (we use SWR with a short TTL on most queries) but the rendering decision has to happen fresh each turn. This means the per-turn token cost is real; we route to Haiku 4.5 for cheap "which primitives" decisions and only escalate to Sonnet for the substantive coaching turns.

When NOT to use generative UI

Generative UI is a force multiplier when the agent's range is wide and the visitor's intent is variable. It is overkill when either condition does not hold.

Don't use generative UI for:

  • A fixed-flow app where every user does the same thing in the same order (most onboarding flows, most checkout flows). A pre-designed screen wins.
  • Any surface where regulatory or safety requirements demand pixel-exact, audit-stable UI (parts of healthcare, parts of finance). The variability is a liability, not a feature.
  • High-throughput consumer feeds where the per-turn cost matters more than expressive range (the home feed of a social app). Static UI with personalization data underneath is cheaper.

The rule of thumb: if you can list the screens, you don't need generative UI. If you can't, you do.

What it does to the designer's role

The designer designs the atoms. The agent composes them at runtime. Designers don't disappear; they move up the stack. Instead of drawing every screen, the designer specifies the primitive library, the rules for composition (margins, hierarchies, color tokens), and the fallback layouts. The skill shifts from "draw the screen" to "design a language the agent can speak."

This is uncomfortable for designers who came up on Figma-frozen workflows. It is liberating for designers who already think systemically.

Where else this pattern fits

Maestro applies the same pattern to a personal coach for any physical skill: same view-primitive shape, different primitives (camera-view, reference-clip, ghost-overlay, top-down). Fortune-Teller, the personal site, uses the same pattern for a portfolio that rebuilds per visitor.

Any product where the agent's work has variable shape benefits from this pattern. The work to set up the primitive library is real. The work it saves you, every time the agent's range grows, is permanent.

The thing I keep noticing: when the UI is frozen in Figma, the agent ends up capped at whatever the wireframes imagined. Designing the atoms instead of the screens is what unstuck that for us. It might not be the right call for what you're building, but it's the one I'd reach for again.