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.
Model response
Spend is up 12% this week, driven by Search…
ui_metrics_grid({ … })
ui_line_chart({ … })
Text is written as text.
Every widget is its own tool call.
SSE stream
Text tokens and tool arguments interleave as the model writes them.
Client
Spend is up 12% this week, driven by Search…
Text renders as it streams; each widget mounts the moment its arguments close.
One Zod schema per widget: the model’s tool input and the component’s props
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.
A block
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.
switch (block.type)
Block type
Renderer
text <TextBlockRenderer /> table <TableBlockRenderer /> metrics_grid <MetricsGridBlockRenderer /> line_chart <ChartBlockRenderer /> bar_chart pie_chart radio <RadioBlockRenderer /> artifact_card <ArtifactCardBlockRenderer />Unknown type → renders nothing, never crashes. Each renderer sits in its own error boundary: a bad block can’t break the message.
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.
Bytes in
Raw JSON fragments, split wherever the network happens to split them.
Text deltas
Appended to the text block as they land.
Complete blocks
Held while incomplete, emitted whole when the closing brace lands.
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.
Assistant message
Queued 12 posts for a content refresh. Here’s the live queue:
behaviour · auto · blog_refresh_queue
Slot live · every 15s
| Post | Status | Updated |
|---|---|---|
| 10 SEO tips for 2026 | Done | 2m ago |
| How to audit backlinks | In progress | just now |
| Content refresh checklist | Queued | — |
| Keyword clustering guide | Queued | — |
| + 8 more | ||
- The block
- Rendered like any other block.
- Its behaviour
- The model only names an experience and a trigger. No data, no markup.
automounts with the block;clickmounts when the user taps. - The slot
- A live agent view mounted inside the message. Fetches its own data, polls, can go fullscreen.
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.
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:
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.
{ "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:
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.
LLM response
streamed as text
tool call, typed payload
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:
| Pattern | Used by | Core idea |
|---|---|---|
| Tool-call rendering | CopilotKit, AG-UI protocol, Google A2UI | Each UI component is a tool. The model calls render_chart(...) instead of generating JSON |
| SDUI (server-driven UI) | Airbnb, Netflix, DoorDash, Vercel json-render | Component catalogue with schemas. Server sends component specs, client renders |
| Multi-part messages | Vercel AI SDK 5/6, Google A2UI | Messages are arrays of typed parts (text, tool-result and so on), not single strings |
| MDX-enhanced markdown | Emerging | The model writes markdown with embedded <Chart /> directives |
| Code sandbox | v0.dev, Claude Artifacts | The 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.
| Tool | Role | Notes |
|---|---|---|
| Vercel AI SDK 5/6 | Multi-part message protocol and the useChat hook | Already installed |
| AG-UI protocol | SSE event format standard | Align our events with this emerging standard |
| Instructor.js | Server-side Zod validation and auto-retry for tool args | Wraps Anthropic and OpenAI calls |
json-render (Vercel) | Component registry pattern reference | Study 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:
- Does the model pick the right tool? A tool + tool-search setup, to see what happens when the catalogue gets big.
- Does
defer_loadinghold up? Lazy-loaded tools, discovered on demand rather than shipped every turn. - Does it survive our hard cases? Not a toy chart — the complex UI elements we actually support.
It did. Only then did we commit.
What we landed on
The model no longer authors a document. It calls a component.
// 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.
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 under “one document” | Under “one call per component” |
|---|---|---|
| Truncated or malformed replies | One large payload; one weak token sinks it | Per-tool arguments are small |
| One bad chart deletes the whole answer | Failure is atomic | A bad block is a bordered error inside a complete answer |
| Nothing renders until the end | One payload has one completion moment | Every call has its own |
| Prompt and renderer disagree | Two artifacts, one of them in English | One schema, enforced at compile time |
| Nobody can safely edit the prompt | Components live in the prompt | Components 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.
Container tool
ui_group({
children: [
ui_metric({}),
ui_metric({}),
ui_chart({})
]
})
Nested, so it is a small monolith. Nothing draws until every child has arrived, and one bad child takes the group with it.
Group id
ui_metric({ group: "g1", layout })
ui_metric({ group: "g1" })
ui_chart({ group: "g1" })
assembled client-side
Flat in the model’s output. Each block still streams, renders and fails on its own.
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:
- Failures became partial. One block can fail while the rest of the answer still arrives.
- Responses render as they’re built. Each block appears the moment its own arguments close.
- The prompt shrank by 40% in one commit.
- An entire bug class is gone. Prose-versus-props drift can’t happen when there is one artifact.
- Cost per rendered block dropped, and stopped compounding across the conversation.
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:
| Provider | Per-tool marker | Search tool appended |
|---|---|---|
| Anthropic | defer_loading: true | { type: 'tool_search_tool_bm25_20251119', name: 'tool_search_tool_bm25' } |
| OpenAI and Azure | defer_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.
- Don’t ask a model for one large structured artifact. You bet the whole response on its weakest token.
- 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.
- Delete the translation layer. Two schemas will disagree, and the adapter between them is where the disagreement hides.
- Defensive clauses in a prompt are telling you about the architecture. The fix was never a better sentence.
- 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.
The agent asks with a card
Weekly budget for Search?
₹20k₹50kCustom
Confirmor just type your answer
Confident answer
35k, search only
Applied₹35k / week · Search campaigns
applies directly
Ambiguous answer
a bit lower than last month?
Weekly budget for Search?
₹20k₹40k₹50k
my read: last month ₹48k, about 15% lower
ConfirmChange
same card, pre-filled: correct it, don’t re-explain
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
Where it stands today
- 153+ Total tools native, platform and app actions
- 16+ Integrated apps
- 34+ UI tools the model can call 28 block types · 35 renderers
- 7 + 4 Live agent slots + side-canvas surfaces Live, interactable views that mount inside a message
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.