All writing

Strivelabs engineering · Generative UI

From answers to UI: how we built Strive’s generative UI engine

A year of building a chat where every non-text element is an LLM tool call. Why we started by asking the model for one big JSON document, why that ceiling turned out to be structural rather than fixable, and what changed when we pivoted to one tool call per component.

Published
Originally
Strivelabs engineering blog
Stack
React 19 · TypeScript · SSE · Zod
Read
~15 minutes

Strive AI is our generative UI chat engine, the gateway to the agentic marketing OS we’ve built at Strivelabs. I’ve had the privilege of building its frontend with the team from day zero.

A chat that answers in UI, not just text.

It’s been a year now. This post is a reflection on the full ride: how it started, why we built it from the ground up, the challenges we faced, the lessons we learned, where we stand today, and what’s next.

The one-liner

The user brings the intent. The agent turns it into an interface.

Every non-text element in a response is an individual LLM tool call. The tool’s Zod schema is the React component’s prop type. Arguments stream over SSE and render progressively.

One turn, one stream. Text is written as text, every widget is its own tool call, and the client mounts each one the moment its arguments close.

The fundamental pieces

Under the hood, these are the pieces that make the engine tick. Everything else in chat sits on top of them.

Blocks: the unit of a reply

A block is one renderable unit in an assistant response. It has a type, a version, a content payload, and optionally a list of behaviours.

Type picks the renderer. Version keeps old messages rendering when a widget evolves. Content is the component’s props, verbatim.

Renderers: dumb on purpose

Each block type has a React renderer that takes the block’s content as its props and draws it. Renderers know nothing about the model, streaming, or the conversation. Hand one a block and it renders. That strictness is what makes replaying history trivial: the same renderer draws a live block and a two-week-old one.

One switch on block type. Chart types share a renderer, an unknown type draws nothing, and every renderer sits inside its own error boundary.

The streaming parser: bytes are not blocks

The model’s output arrives as a byte stream. Something has to sit in between and decide, continuously, whether a complete block has arrived yet. The parser watches the buffer, detects block boundaries before the surrounding JSON is valid, emits text deltas character by character, and signals when a block is complete enough to hand to a renderer.

Fragments in, two kinds of thing out. Text deltas append as they land. A non-text block is held back until its closing brace arrives.

The event vocabulary: one stream, one handler

Everything travels over a single SSE stream with a small set of event types: streaming content, streaming reasoning, tool calls, tool-argument streaming (start and content), conversation metadata, and citations. One handler in the client switches on these.

The entire chat is that handler plus state.

Behaviours and slots: messages that stay alive

A block can carry a behaviour: an attached action, triggered either by a click or automatically on render. A click behaviour usually starts a new chat turn. An automatic one mounts a live agent view inside the message, a component that fetches its own data, polls, holds editable state, and writes back. We call those slots. They are the reason a chat message can be a task queue that is still updating while you scroll past it.

The model only names an experience and a trigger. The data, the polling and the edit state all belong to the slot.

The session context: a stream that outlives the view

Underneath all of it sits a session that outlives the view: messages, parser, streaming indices, pending interactions and abort controller, keyed per conversation. Navigate away mid-response and the stream keeps running. Come back and it’s complete.

A little bit of backstory

We built this system from the ground up. That was a conscious, tough call, and one we kept revisiting along the way. Starting small with our own layer, on top of what we already had, was the cheapest option that kept every door open.

It all started in September 2025. Generative UI was still new, with few proven implementations at scale, but chat was quickly becoming the interface for agents.

Thanks to our co-founders for spotting the bet early, backing us to go all in, and never letting us stop poking at what’s next.

Before any of it existed, it looked like this.

The engine on a whiteboard, September 2025. Workflow versions, the chat endpoints, the tool-calling loop, and action items, which grew up into behaviours.
The layout model from the same session. Rows of blocks, groups inside them, and the same content reflowing into a narrow view. The rows became blocks and the groups became group ids.

Vercel AI SDK: the obvious first stop

Anything frontend, Vercel is at the forefront, undeniably. Their links topped every search we’ve made and the community was buzzing. The AI SDK was already mature for streaming text and tool calling. Its Generative UI story was still evolving, and a bit fragmented.

The rough timeline:

  1. 2023

    First release

    Streaming text. Very early.

  2. 2024

    Generative UI

    Arrives, but only through RSC.

  3. Early 2025

    Client-side support

    Lands, still leaning on RSC.

  4. Jul 2025

    AI SDK 5

    Typed chat, four frameworks.

  5. 6 Aug 2025

    AI Elements

    The component library.

  6. Sep 2025

    We begin

    One month later.

Vercel’s AI SDK, 2023 to 2025. Generative UI arrived through React Server Components and stayed there, which is exactly where our hesitation came from.

So by September, Vercel was already mainstream in the agentic interfaces space. But the pieces we wanted most, like streamUI, lived in the RSC package and were marked experimental. That is where the hesitation to adopt immediately came from.

SPA or Next.js?

Our platform is an SPA, and the AI SDK’s story leaned towards Next.js at that point. We did consider migrating. We never found convincing enough reasons :)

Glad we didn’t take the server components detour.

AI Elements: the piece we took

AI Elements, on the other hand, was already super mature and the standard for AI interfaces even at that point. Obviously we didn’t miss it. We have our own custom registry of AI Elements, built on top of the original primitives, tuned to a marketer’s taste and personalised for our arena.

We weren’t rejecting Vercel’s SDK. We adopted the stable-ish primitives we needed and deliberately avoided an architectural dependency on the foundation we were setting up.

We knew chat was going to be our core, so having utmost flexibility didn’t hurt.

The rest of the field

Beyond the mainstream players, we also experimented with Thesys, Google’s GenUI and A2UI, CopilotKit and a few others. They were either too early or not a natural fit for our use cases at that point.

A small fork from that evaluation is still up: Marigameo/thesys-exp.

Building from scratch as a startup has a real cost. This was not a one-day decision. We looked hard at what already existed first, and we still keep our ears open to what’s moving in this space.

The first design: ask the model for a document

The original contract was the obvious one. The model returns one JSON object per turn: a version, and an array of blocks.

One assistant turn
{  "version": "1.0",  "blocks": [    {      "type": "metrics_grid",  // picks the renderer      "version": "v1",         // so old messages keep rendering      "content": {},           // the component's props      "behaviours": []         // optional    },    { "type": "line_chart", "version": "v1", "content": {} },    { "type": "text",       "version": "v1", "content": {} }  ]}

One payload, one parse, one render. Clean.

To make that possible, the model needed the full context of our component registry on every request. Every supported component, its fields, and how each one could be used, all written into the system prompt.

It worked. It demoed beautifully. We shipped it in September 2025, and it carried the product for seven months through progressive rendering, chat history, replay, slash commands, skills, error boundaries, and the first behaviour slots.

The prompt grew to ~584+ lines, with the majority of it being schema written as prose.

That wasn’t necessarily a bad design choice. We knew it wouldn’t remain manageable forever, but at that point, we were balancing implementation cost against impact. We wanted to build something useful, get it into people’s hands, and let real usage tell us where it needed to evolve, without over-engineering the first version.

That’s been a consistent principle for us: build the foundations with scale in mind, but don’t assume the first shape of those foundations has to last forever. As the product grows, the way those pieces are assembled can change with it. What we built here wasn’t throwaway; we kept reshaping it as we learned what the system needed.

The architecture was fine. It had a ceiling, and we didn’t hit it until we had built enough on top of it.

Where it started to hurt

Five failures, in roughly this order. Each one looked like a bug rather than a design signal. That’s the trap.

Long JSON from a model is a coin flip

As responses got richer the payload got long, and a missing brace four thousand tokens in took the whole reply with it. Not degraded, deleted. The user got a spinner, then nothing.

A silent retry, re-prompting with the parse error, turned most of those into slow successes. It also meant paying twice for an answer we had already paid for once.

The escaping arms race

Models like emitting nested structure as JSON strings rather than real objects. It’s cheaper for them. So the prompt grew defensive clauses, and you can watch the scar tissue accumulate in the diff:

system prompt · the defensive clauses
content is a JSON object. Output it as a native JSONobject, NOT as a JSON-escaped string…CRITICAL: If a block has no behaviours, omit this fieldentirely. NEVER set behaviours to null…Never double-escape or produce deeply nested escapesequences like \". If a field is already inside astringified parent, write content as native JSON objects,not as another layer of stringified JSON.

Every one of those sentences is a production incident with a bandage on it. At some point I realised our system prompt had become a bug tracker written in the imperative mood.

The bandages didn’t hold either. We still needed a client-side normaliser to re-parse stringified fields, because an instruction works most of the time, and most of the time isn’t a contract.

Nothing could render early

We couldn’t draw the first chart until we had buffered enough of the blob to know what it was. The model had finished describing it forty seconds ago. We were still waiting on the closing brace.

So the parser got cleverer and cleverer at finding complete blocks inside invalid JSON. Genuinely nice engineering, in service of working around a bad contract.

Prose and props drifted

The prompt described a component’s fields in English. The React component declared them in TypeScript. Nothing connected the two, so changing a renderer’s props and forgetting the prose gave us a bug that is invisible in code review and shows up as an empty chart in production.

The prompt was unownable

A prompt with hundreds of lines of schema is not something two people can edit in parallel. Adding a component meant a prompt PR. Adjusting a field meant a prompt PR. Our fastest iteration surface had become the most contended file in the repo.

Read together, those aren’t five bugs. They’re one: we were asking the model to be a JSON document author, and it isn’t one.

The pivot: one tool call per component

On 6 April 2026 we changed the contract. It remains the largest single structural change in the project, and it was not a one-shot call. We read a lot first.

A pattern we kept seeing

Looking at how other teams were approaching generative UI, one thing kept repeating: nobody turns the whole response into structured UI.

The model still answers naturally, in streamed text and markdown. It switches to a structured representation only where the answer genuinely benefits from one. A chart, a table, a card, a form.

Vercel’s AI SDK makes the separation explicit in its message model, where text, tool calls and tool results are distinct message parts. CopilotKit does the same with tool-based generative UI, exposing React components as tools the agent can invoke. The Claude and OpenAI products combine conversational text with structured interactions too, though the implementations differ.

The shape every stack we looked at converged on. Text stays text. Only the parts that have to become an interface go through a schema.

That distinction mattered. Text is already a great format for text. There was little value in forcing every sentence through a JSON schema just because some parts of the answer needed to become UI.

Natural language when the model is communicating. Structured data when the model is composing an interface.

So we settled on the hybrid, which is the standard approach. Only the interface-shaped parts become tool calls.

Straight from the research notes

Two extracts from the internal analysis, here to show the pivot wasn’t a blind jump. The research itself, and how we landed on the hybrid approach, is a story of its own. We’ll write that one up separately.

Industry landscape. The dominant patterns:

Industry landscape
PatternUsed byCore idea
Tool-call renderingCopilotKit, AG-UI protocol, Google A2UIEach UI component is a tool. The model calls render_chart(...) instead of generating JSON
SDUI (server-driven UI)Airbnb, Netflix, DoorDash, Vercel json-renderComponent catalogue with schemas. Server sends component specs, client renders
Multi-part messagesVercel AI SDK 5/6, Google A2UIMessages are arrays of typed parts (text, tool-result and so on), not single strings
MDX-enhanced markdownEmergingThe model writes markdown with embedded <Chart /> directives
Code sandboxv0.dev, Claude ArtifactsThe model generates actual React code, executed in a sandbox

Recommended ecosystem. The note’s shortlist at the time. Several of these did not earn the dependency, we took the necessary value additions from the set.

Recommended ecosystem
ToolRoleNotes
Vercel AI SDK 5/6Multi-part message protocol and the useChat hookAlready installed
AG-UI protocolSSE event format standardAlign our events with this emerging standard
Instructor.jsServer-side Zod validation and auto-retry for tool argsWraps Anthropic and OpenAI calls
json-render (Vercel)Component registry pattern referenceStudy the pattern for our registry layer

A small POC first

By then we knew what we wanted. This was just a little POC to gain some more confidence before acting on it: a simple Node server on the Claude SDK, with tests.

Three questions:

It did. Only then did we commit.

What we landed on

The model no longer authors a document. It calls a component.

The same turn, now
// prose streams as markdown, no longer a block typeui_metrics_grid({})  // renders the moment its arguments closeui_line_chart({})    // independentlyui_table({})         // independently

The envelope is gone, and the array with it. The prompt went back to the shape it is meant for: business and data context.

One schema, three jobs

Each tool has exactly one schema definition, and it does three things at once.

One definition, three consumers
const metricsGrid = z.object({  header: z.string(),  columns: z.number().int().min(1).max(4),  cells: z.array(cell),});// 1 · the JSON Schema the model writes againsttool({ name: "ui_metrics_grid", parameters: metricsGrid });// 2 · the runtime validator on the way inconst content = metricsGrid.parse(args);// 3 · the React renderer's prop typetype MetricsGridProps = z.infer<typeof metricsGrid>;

No wire format, no separate renderer contract, no adapter between them. The model emits, literally, what the renderer reads.

Why it had to be a pivot, not a patch

Every problem above shares a root, and none of them can be fixed downstream:

Symptom, root cause, and what replaced it
SymptomRoot cause under “one document”Under “one call per component”
Truncated or malformed repliesOne large payload; one weak token sinks itPer-tool arguments are small
One bad chart deletes the whole answerFailure is atomicA bad block is a bordered error inside a complete answer
Nothing renders until the endOne payload has one completion momentEvery call has its own
Prompt and renderer disagreeTwo artifacts, one of them in EnglishOne schema, enforced at compile time
Nobody can safely edit the promptComponents live in the promptComponents live in code

Each row is a consequence of “one document” versus “many calls.” You don’t patch your way from one to the other.

What we had to build to make it work

Three things that aren’t obvious until you try it.

Containers without a container tool

Grouped layouts, two metric cards side by side or a masonry of charts, are tempting to model as a container tool with nested children. Nesting puts you straight back in monolithic-JSON territory, so every tool carries an optional group id instead and the client does the assembly.

Grouping happens at assembly time. The model emits siblings that share an id, and the client puts them in a container at the position of the first one.

A rule about what streams

Only text streams character by character. Everything else appears complete.

A half-parsed chart config isn’t a preview of a chart, it’s garbage. Partial text is exactly as useful as complete text, one word earlier.

Don’t echo tool inputs

The natural tool handler returns its input as its result. Nothing ever reads it, because the block renders from the streamed arguments, yet it doubles the token cost of every rendered block for the rest of the conversation, because tool results replay into every subsequent request.

Our handlers return a two-word acknowledgement. Generative UI has rent, not just a purchase price.

What the pivot bought us

Immediately:

The compounding part took longer to show. The five months after the pivot delivered more than the seven before it, and that is not a coincidence.

The interaction loop. Once a component is a tool call, the user’s answer can be the tool result. The card renders, registers as a pending call, and the click posts that choice back as the output of that call. The model reads it as an ordinary tool result, indistinguishable from a database query returning a row.

That framing is what separates “the assistant drew a chart” from “the assistant is running a workflow with you.”

Live slots, then a canvas for them. Auto-triggered behaviours mount real agent views inside messages. The canvas is a React portal, rendered once in the message tree and portalled into the canvas mount, so hooks, polling timers, fetched data and edit state all survive the move because there is no move.

A deferrable tool catalogue. Thirty-odd tools is a lot of schema to ship on every request, so only eight load eagerly. The rest are declared deferred and discovered on demand through the provider’s tool search. Only possible because tools are the unit: under the old design, every component’s description was in the prompt, every turn, forever.

The whole thing is one method on the Tool interface. isDeferred() returns false by default. App-action tools, the long tail that acts in the product rather than drawing in it, override it to true. No registry of deferred names, no config file.

Each provider adapter translates that flag on the way out and appends the matching search tool:

Deferred tools, per provider
ProviderPer-tool markerSearch tool appended
Anthropicdefer_loading: true{ type: 'tool_search_tool_bm25_20251119', name: 'tool_search_tool_bm25' }
OpenAI and Azuredefer_loading: true{ type: 'tool_search' }

Not everything can defer. The search tool has to find something, so a request with no eager tools is rejected.

File attachments, citations, agent traces, a chat mode picker, a conversation minimap. All of it landed faster on a contract that didn’t fight back.

Seven lessons worth keeping

Five of them are this post compressed into a line each.

  1. Don’t ask a model for one large structured artifact. You bet the whole response on its weakest token.
  2. A schema in a prompt is a schema you don’t own. Nothing verifies it, nothing versions it, and nobody can edit it in parallel.
  3. Delete the translation layer. Two schemas will disagree, and the adapter between them is where the disagreement hides.
  4. Defensive clauses in a prompt are telling you about the architecture. The fix was never a better sentence.
  5. Progressive rendering is a product decision. Stream the one thing where partial output is genuinely useful, not everything you can.

The first one travels well past chat. It applies anywhere you are tempted to have a model emit a big config, a whole document, or a full migration.

Two the post hasn’t covered.

Ship structural rewrites opt-in. The canvas went out behind a per-surface flag that defaulted to the old behaviour, migrated one experience at a time. The tool catalogue is opt-in per tool. We restructured a chat people use daily without a single cutover. Caution had nothing to do with it. It’s the only reason we could move fast enough to attempt it.

The UI should not be authoritative. The one I most wish we’d unlearned sooner. Our first prompt literally said the user isn’t expected to reply in free text. The current design inverts it: every card can be answered by typing instead. A confident typed answer applies directly. An ambiguous one re-presents the same card pre-filled with the agent’s interpretation, so the user corrects rather than re-explains.

Every card can be answered by typing. The card proposes; the conversation decides.
Generative UI is an accelerant on a conversation, not a replacement for one. Every time we’ve forgotten that, usage data has reminded us within a week.

Twelve months in one timeline

Contract 1 · one JSON document per turn

  1. Sep to Oct 2025

    First chat

    Basic chat, tool calls, tool-ack, first UI blocks, stacked suggestions, dynamic thinking steps

  2. Oct to Nov 2025

    Progressive rendering

    Block-by-block rendering, reasoning shown as chain of thought, suggestions as cards

  3. Dec 2025 to Jan 2026

    History and replay

    Chat history, read-only replay, stop handler, streaming parser utility, loading states for non-text blocks, first behaviour slots

  4. Feb to Mar 2026

    Slots and skills

    Report preview and content editor slots, slash commands and skills, history pagination, error boundaries, stream only the active message

Contract 2 · one tool call per component

  1. 6 Apr 2026

    The pivot

    Tool-call-based streaming. Text tool removed; markdown streams natively

  2. May to Jun 2026

    Tools become the unit

    execute_tools endpoint replaces tool-ack, agent trace component, report studio wired into global chat, file support, canvas, selectable table

  3. Jul 2026

    Composer and flows

    Composer redesign, chat mode picker, interactive slash-skills menu, citations, connect an app mid-chat, card-driven routine creation, routine preview panel

  4. Aug to Sep 2026

    Scaling out

    Multi-connection chat, conversation minimap, analytics instrumentation, auth-retry hardening, task and briefing cards

Twelve months, two contracts. Everything above the coral line was built on a single JSON document per turn; everything on it was built on one tool call per component.

Where it stands today

The numbers are the least interesting part. What matters is the use cases, and the journey that pushed us to scale to them.

What’s next

Artifacts.

We got here early too. Artifacts are already in production, powering parts of our reporting and making those experiences shareable. There’s a lot more to that story, but it deserves its own post — so we’ll save the details for another day.

And there’s plenty more coming.

Chat writes code. The JSON contract disappears. So does the rulebook we’ve been stuffing into prompts — along with all the hallucinated field names that come with it. Instead, we give the model the design context, and let the system wire up the UI around whatever the answer needs.

And why stop at interfaces? What if chat could spin up an actual functional app inside a slot — something nobody had to pre-build or explicitly design for? Unlike the slots we ship today, the interface could be created on demand, for the task at hand.

Whatever comes next, one thing stays true: the contract between the model and our components is ours to design. Get that layer right, and we spend the next year building richer interfaces for marketers — not writing increasingly creative sentences to keep a model inside the lines.

There’s a lot we’re excited to build. And honestly, it feels like we’re just getting started.

Back to the shelf