Strive AI is our generative UI chat engine, the gateway to the agentic marketing OS we've built at Strivelabs.

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:

2023
Streaming text. Very early.
2024
Arrives, but only through RSC.
Early 2025
Lands, still leaning on RSC.
Jul 2025
Typed chat, four frameworks.
6 Aug 2025
The component library.
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 JSON object, NOT as a JSON-escaped string…
 
CRITICAL: If a block has no behaviours, omit this field entirely. NEVER set behaviours to null…
 
Never double-escape or produce deeply nested escape sequences like \". If a field is already inside a stringified 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
Natural language
streamed as text
Markdown renderer
Structured UI
tool call, typed payload
Schema + component registry
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:

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.

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:

  1. Does the model pick the right tool? A tool + tool-search setup, to see what happens when the catalogue gets big.
  2. Does defer_loading hold up? Lazy-loaded tools, discovered on demand rather than shipped every turn.
  3. Does it survive our hard cases? Not a toy chart, but 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.

The same turn, now
// prose streams as markdown, no longer a block type
ui_metrics_grid({})  // renders the moment its arguments close
ui_line_chart({})    // independently
ui_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 against
tool({ name: "ui_metrics_grid", parameters: metricsGrid });
 
// 2 · the runtime validator on the way in
const content = metricsGrid.parse(args);
 
// 3 · the React renderer's prop type
type 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:

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.

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
metric
metric
chart
Flat in the model's output. Each block still streams, renders and fails on its own.
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:

  • 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:

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.

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
Sep to Oct 2025
First chat
Basic chat, tool calls, tool-ack, first UI blocks, stacked suggestions, dynamic thinking steps
Oct to Nov 2025
Progressive rendering
Block-by-block rendering, reasoning shown as chain of thought, suggestions as cards
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
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
6 Apr 2026
The pivot
Tool-call-based streaming. Text tool removed; markdown streams natively
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
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
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 orange 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

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.