Generative UI Is the New Frontend. We Shipped It Months Ago.
Everyone is talking about Generative UI right now. CopilotKit published a developer guide. Google has a research blog on it.
We shipped it in January. It's been running in production inside Elba ever since.
This is not a hot take. It's a writeup of how we actually built it, what the architecture looks like, and what tripped us up along the way.
What Generative UI actually means
The short version: instead of hardcoding every screen state, the AI agent decides at runtime which UI component to show, and what data to fill it with.
The chat interface doesn't just return text anymore. It returns live, interactive UI, rendered inside the conversation itself.
CopilotKit breaks this into three patterns:
- Static Generative UI: the frontend owns the components. The agent picks which one to show and fills it with data. High control, predictable.
- Declarative Generative UI: the agent returns a UI spec (cards, forms, lists in JSON), and the frontend renders from that spec. More flexible, more unpredictable.
- Open-ended Generative UI: the agent returns a full UI surface, often an iframe or arbitrary HTML. Maximum flexibility, minimum consistency.
We chose the first pattern. We pre-built every component. The agent never touches the layout. It only decides when something appears and what goes inside it.
That was the right call for a support assistant in a production product. You want the agent making decisions, not designing interfaces.
How Frida works
Frida is the in-product AI assistant inside Elba. It runs inside a chat panel. Users can ask it about their account, recent calls, credit balance, billing, and anything else about the platform.
What makes it Generative UI: when you ask "what's my credit balance?", Frida doesn't answer with text. It renders a card.
When you ask about recent calls, you get an interactive call log. When you're on the Enterprise path, you get a clickable qualifier form, not a text prompt asking you to type your use case.
The interface adapts to what the agent needs the user to do.
The stack
- Frontend: React, CopilotKit v2, AG-UI protocol
- Backend: PydanticAI agent running on our Cognition Hub service
- Protocol: CopilotKit's
useFrontendTool hook over AG-UI
The component registry
Every generative component in Frida is registered in one place:
// toolComponentRegistry.ts
export const TOOL_COMPONENTS: Record<string, React.FC<ToolComponentProps>> = {};
export function registerToolComponent(
name: string,
component: React.FC<ToolComponentProps>
): void {
TOOL_COMPONENTS[name] = component;
}
At startup, each component registers itself:
registerToolComponent('showCreditBalance', CreditBalanceCard);
registerToolComponent('showRecentCalls', RecentCallsCard);
registerToolComponent('showCreditBurnRates', CreditBurnRateCard);
registerToolComponent('showSalesQualifier', SalesQualifierInChat);
registerToolComponent('showSalesInquiryPreview', SalesInquiryPreview);
When the agent fires a tool call, the chat renderer looks it up in this registry and mounts it inline.
The tool definitions
Each component has a corresponding useFrontendTool definition. This is where the agent learns when and how to use it:
useFrontendTool(
{
name: 'showCreditBalance',
description:
'ALWAYS use this tool to render a visual credit balance card when the user asks about their credits, balance, remaining minutes, or billing status.',
parameters: creditBalanceSchema,
handler: async (params) => JSON.stringify(params),
},
[],
);
Parameters are validated with Zod:
const creditBalanceSchema = z.object({
credits: z.number().describe('Current credit balance'),
plan: z.string().describe('Current plan name'),
status: z.enum(['healthy', 'low', 'critical']).describe(
'Balance status: healthy (>100), low (10-100), critical (<10)'
),
estimatedMinutes: z.number().describe('Estimated voice minutes remaining'),
});
The agent reads the schema. The schema descriptions train it on what values to pass. estimatedMinutes with its description (credits / 10) means the agent calculates the right value rather than guessing.
The backend agent
The other half of this lives in the Python backend. Frida is a PydanticAI agent with tools it can call to fetch real data:
@agent.tool
async def fetch_recent_calls(ctx: RunContext[FridaSupportDeps], limit: int = 5) -> dict:
"""Fetch recent calls for the user's organization."""
calls = await _get_recent_calls(client, ctx.deps.org_id, limit=limit)
formatted = [_format_call_record(c) for c in calls]
return tool_result({"calls": formatted, "total": len(formatted)})
The agent has two types of tools: backend tools that fetch data (calls, org info, knowledge base), and frontend tools that render UI. The backend tools feed data to the frontend tools. That's the full loop.
When a user asks "what are my recent calls?", the agent:
- Calls
fetch_recent_calls to get the data from API
- Calls
showRecentCalls with the formatted results
- The frontend receives the tool call, looks up
RecentCallsCard in the registry, and mounts it
No text. No parsing. Just a rendered component with real data.
Context injection
The agent knows more than just what the user typed. We inject runtime context via dynamic instruction generators:
def page_context(ctx: RunContext[FridaSupportDeps]) -> str:
"""Add current page context."""
page = ctx.deps.current_page
for prefix, hint in _PAGE_HINTS.items():
if page.startswith(prefix):
return f"Current page context: {hint}"
return f"The user is currently on page: {page}"
The agent knows which page the user is on, their billing status, credit balance, plan, agent count, and today's date. That context shapes every response. "Check my credit balance" from someone on the /billing page gets a different treatment than from someone on /agents.
The date injection is worth calling out specifically:
def date_context(ctx: RunContext[FridaSupportDeps]) -> str:
parts.append(
f"IMPORTANT — Today's date is {ctx.deps.current_date}. "
"Use this as the source of truth for 'today', 'yesterday', 'this week', etc. "
"Do NOT infer the current date from call timestamps or any other data."
)
Without this, the model will try to infer the current date from the most recent call timestamp, which is wrong. It will show a call from three days ago and say "Today." We caught this in testing. The explicit date injection with the "Do NOT infer" instruction fixed it.
The parts nobody writes about
The happy path is easy. Here's what actually took time.
Skeleton states during streaming
CopilotKit streams tool call arguments. The component mounts before all arguments have arrived. If you render immediately with partial data, you get jarring half-loaded states.
We added a 500ms delay before showing the real component, with a skeleton shimmer in the gap:
const DelayedToolComponent: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [ready, setReady] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setReady(true), 500);
return () => clearTimeout(timer);
}, []);
if (!ready) return <ToolComponentSkeleton />;
return <>{children}</>;
};
The skeleton matches the shape of the real component. Credit balance card gets a skeleton that looks like a credit balance card. This made the experience feel intentional rather than broken.
Error boundaries per component
When a component crashes, you don't want the whole chat to break. We wrapped every tool component render in an error boundary:
class ToolComponentErrorBoundary extends Component<...> {
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) {
return (
<div className="...amber border...">
<p>Unable to display this content — please try asking again.</p>
</div>
);
}
return this.props.children;
}
}
This gives the user a recoverable error instead of a broken chat session. The amber fallback is lightweight enough that it doesn't look alarming, but clear enough that the user knows something didn't render.
Interactive components that trigger the next agent turn
The sales qualifier card is interactive. The user clicks options, hits submit, and their selections need to become the next message in the conversation. We do this via a custom DOM event:
const SalesQualifierInChat: React.FC = () => {
const handleSubmit = (data: QualifierState) => {
const message = `Use case: ${data.useCase}. Volume: ${data.volume}. Timeline: ${data.timeline}.`;
window.dispatchEvent(
new CustomEvent('frida:qualifier-submit', { detail: message })
);
};
return <SalesQualifierCard onSubmit={handleSubmit} />;
};
The chat listens for this event and injects the formatted string as a user message. The agent then picks it up and continues the workflow, drafting the email and showing the preview card. Two tool components, one conversation flow.
Why Static Generative UI was the right call
We could have gone declarative. We could have let the agent return A2UI JSON and rendered from spec. That would have given Frida the ability to invent new layouts.
We didn't want that. Frida lives inside a production product with a design system. An agent that can invent arbitrary cards is also an agent that can produce off-brand or broken UI at 2am when a customer is blocked.
Static Generative UI gives you the responsiveness of an AI interface with the predictability of a handcrafted component library. The agent makes decisions. The frontend makes pixels. Neither one crosses into the other's job.
The constraint is also what made it shippable. Frida launched with six components. Each one is a clearly defined contract between the agent and the UI. When the agent calls showCreditBalance, we know exactly what renders. When it doesn't, nothing renders. No surprises.
Generative UI is not a new concept. The name is new. The idea that an AI assistant should render task-appropriate UI instead of narrating everything as text, that has been obvious for a while.
If you're building an in-product AI assistant and it only returns text, that's the next thing to look at. Pick one component, one tool call, one use case. The pattern is small enough to ship in a day. The difference in the experience is not small at all.
#Elba #BuildingVoiceAI