You’ve probably interacted with AI agents recently, most likely through an endless stream of chat bubbles. That works fine for simple tasks, but it breaks down fast when you need richer, back-and-forth interactions like collecting structured user input through a UI. That’s where A2UI comes in.
A2UI (Agent to UI) is a UI protocol from Google that lets AI agents generate user interfaces on demand. It introduces declarative mini-apps where UI components and actions are defined in a schema, and the agent can operate them automatically. Think Telegram-style mini-apps: small, self-contained interfaces that work without custom integration code.
Instead of a long question-and-answer loop, agents can now send interactive, native interfaces directly to the client.
In this guide, we’ll build an A2UI mini-app end-to-end. We’ll cover the declarative UI schema, action definitions, the A2UI Bridge, agent integration, and finish with a complete working demo.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
To explore A2UI, you will need the following:
A2UI treats UI as structured data, not executable code. The AI doesn’t send HTML, CSS, or JavaScript. It sends JSON describing what it needs. The AI decides it needs a form with a date picker and a submit button. It builds a JSON object defining those components. Your app receives that JSON and renders it using your own trusted, native components.
A2UI is currently in public preview. We’ll pull down the official codebase to see how it works, then build our own version using a community-made React renderer.
Let’s digress for a second. If you’re wondering what UV is, here’s the quick answer.
UV is a Python package manager.
You can install it with the following command:
# For macOS and Linux curl -LsSf https://astral.sh/uv/install.sh | sh # For Windows powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
First, clone the official A2UI repository to get access to the sample code.
git clone https://github.com/google/a2ui.git cd a2ui
The Python agent uses Google’s Gemini model to generate the UI.
Head to Google AI Studio, create an API key, and export it:
# For macOS and Linux export GEMINI_API_KEY="your_api_key_here" # For Windows (in Command Prompt) set GEMINI_API_KEY="your_api_key_here" # For Windows (in PowerShell) $env:GEMINI_API_KEY="your_api_key_here"
Now let’s fire up the backend agent. It’ll listen for requests, call Gemini, and stream A2UI JSON back to the client.
cd samples && cd agent && cd adk && cd restaurant_finder
Then run this command:
uv run .
This installs the required dependencies and starts the server.
If everything starts up cleanly, you should see a screen like this:

This starts the agent at http://localhost:10002.
With the backend running, it’s time to set up the frontend.
Inside the A2UI folder, copy and run this command:
cd samples && cd client && cd lit
Now, open your browser to http://localhost:4200. As you interact with the agent, you’ll see the UI update dynamically based on the JSON it receives from the backend.

Let’s play around with it:

Take a look at the logs, and you’ll see Gemini return a description of the UI, which the A2UI protocol then renders on the client.

Now that we’ve seen the official implementation, let’s implement A2UI in our React app using A2UI Bridge, a community-created React renderer for the A2UI protocol.
First, you’ll need to set up a React project. In this case, the project is initialized using Vite.
After that, install the following dependencies:
npm install @a2ui-bridge/core @a2ui-bridge/react @a2ui-bridge/react-mantine
Then go ahead and install the Google SDK for our AI connection:
npm install @google/genai
Here’s what each package does:
@a2ui-bridge/core – The core A2UI protocol@a2ui-bridge/react – React bindings for A2UI@a2ui-bridge/react-mantine – Pre-built Mantine component adapters@mantine/core – Mantine’s component library@google/genai – Google’s latest Gemini SDKNext, set up our Gemini API key.
Create a new file called .env in your project root:
VITE_GEMINI_API_KEY=your-api-key-here
Replace your-api-key-here with your actual Google AI Studio API key.
Mantine needs a provider wrapper to work. Open src/main.tsx and update it to look like this:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { MantineProvider } from '@mantine/core';
import '@mantine/core/styles.css';
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<MantineProvider>
<App />
</MantineProvider>
</StrictMode>,
)
We imported MantineProvider and wrapped our App with it. Now every component can use Mantine’s theming and components.
Start by creating a new file called geminiService.ts, then add the following code. This file will be responsible for talking to the Gemini API and turning a natural-language prompt into A2UI messages.
import { GoogleGenAI } from '@google/genai';
import type { ServerToClientMessage } from '@a2ui-bridge/core';
We import the Gemini client from @google/genai and the ServerToClientMessage type from A2UI, so our function returns strongly typed UI messages.
Next, we create a Gemini client instance using an API key stored in an environment variable.
const ai = new GoogleGenAI({
apiKey: import.meta.env.VITE_GEMINI_API_KEY
});
Keeping the API key in import.meta.env makes this service easy to configure across environments without hardcoding secrets.
Next, create a single function: generateUIFromPrompt. It accepts a user’s description of a UI and returns an array of A2UI messages.
export async function generateUIFromPrompt(
prompt: string
): Promise<ServerToClientMessage[]> {
At a high level, this function sends the prompt to Gemini with strict instructions, cleans up the model’s response, and then parses and validates the result as A2UI messages.
Inside the generateUIFromPrompt function, call GoogleGenAI’s generateContent method and pass a carefully constructed prompt.
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: [
{
role: 'user',
parts: [{
text: `You are a UI generator. Create A2UI Bridge JSON messages based on the user's request.
IMPORTANT: Return ONLY a valid JSON array with no markdown formatting, no code blocks, no explanations.`
The key idea here is that we are not asking Gemini to generate HTML or React. We explicitly tell it to behave as a UI generator that outputs A2UI Bridge JSON. The prompt strongly constrains the model’s output, reduces ambiguity and makes the response easier to parse reliably.
Add the following to geminiService.ts:
Structure:
[
{
"beginRendering": {
"surfaceId": "@default",
"root": "root-component-id"
}
},
{
"surfaceUpdate": {
"surfaceId": "@default",
"components": [
{
"id": "unique-id",
"component": {
"ComponentType": { /* properties */ }
}
}
]
}
}
]
Add the following to geminiService.ts, just below the structure:
Available Components:
1. Button: { "Button": { "child": "text-id", "action": { "submitAction": { "actionType": "button-action" } } } }
2. Text: { "Text": { "text": { "literalString": "Display text here" } } }
3. TextField: { "TextField": { "label": { "literalString": "Field Label" }, "text": { "literalString": "" } } }
4. Column: { "Column": { "children": ["child-id-1", "child-id-2"] } }
5. Row: { "Row": { "children": ["child-id-1", "child-id-2"] } }
6. Checkbox: { "Checkbox": { "label": { "literalString": "Checkbox Label" }, "checked": false } }
7. Select: { "Select": { "label": { "literalString": "Select Label" }, "data": [{ "value": "option1", "label": "Option 1" }], "placeholder": { "literalString": "Choose an option" } } }
8. DatePicker: { "DatePicker": { "label": { "literalString": "Date Label" }, "placeholder": { "literalString": "Pick a date" } } }
9. Card: { "Card": { "children": ["child-id-1", "child-id-2"] } }
10. Tabs: { "Tabs": { "defaultValue": "tab1", "children": ["tab-1-id", "tab-2-id"] } }
11. TabsList: { "TabsList": { "children": ["tab-trigger-1", "tab-trigger-2"] } }
12. TabsTrigger: { "TabsTrigger": { "value": "tab1", "child": "tab-label-id" } }
13. TabsPanel: { "TabsPanel": { "value": "tab1", "children": ["content-id-1"] } }
14. Modal: { "Modal": { "opened": false, "title": { "literalString": "Modal Title" }, "children": ["modal-content-id"] } }
This prompt lists every component the model is allowed to use, such as Button, Text, TextField, Column, and Row, along with their expected shapes.
This acts as a contract. The model is free to compose UIs, but only using known components and schemas that your renderer already understands.
To further anchor the output, add a complete example of a valid response for a simple button UI to the prompt:
Example for a button:
[
{ "beginRendering": { "surfaceId": "@default", "root": "my-button" } },
{
"surfaceUpdate": {
"surfaceId": "@default",
"components": [
{
"id": "my-button",
"component": {
"Button": {
"child": "button-label",
"action": { "submitAction": { "actionType": "click-action" } }
}
}
},
{
"id": "button-label",
"component": {
"Text": { "text": { "literalString": "Click Me" } }
}
}
]
}
}
]
This example dramatically improves consistency by showing the model exactly what a correct A2UI message sequence looks like.
At the end of the prompt, insert the user’s actual request.
User request: ${prompt}
Return ONLY the JSON array, no markdown:
This allows Gemini to generate a UI tailored to the user’s intent while still staying within the defined constraints.
Next, add these validation guards against malformed responses to prevent downstream rendering errors:
if (!response.text) {
throw new Error('No response text received from Gemini API');
}
let content = response.text.trim();
content = content.replace(/^```json\s*/gm, '');
content = content.replace(/^```\s*/gm, '');
content = content.replace(/```$/gm, '');
content = content.trim();
const messages = JSON.parse(content) as ServerToClientMessage[];
if (!Array.isArray(messages) || messages.length === 0) {
throw new Error('Invalid response: Expected non-empty array');
}
return messages;
}
When the AI responds, it sometimes wraps the JSON in markdown code blocks, so we strip these out using regular expressions before parsing. We parse the cleaned string and assert that it matches the expected A2UI message shape.
Now let’s wire everything together in our main App component.
Open App.tsx and replace its contents with this:
import { useState } from 'react';
import { useA2uiProcessor, Surface } from '@a2ui-bridge/react';
import { mantineComponents } from '@a2ui-bridge/react-mantine';
import type { UserAction } from '@a2ui-bridge/core';
import { Textarea, Button } from '@mantine/core';
import { generateUIFromPrompt } from './geminiService';
import './App.css';
function App() {
const processor = useA2uiProcessor();
const [aiInput, setAiInput] = useState('');
const [loading, setLoading] = useState(false);
const examples = [
{ label: '👋 Hello Button', prompt: 'A button that says Hello World' },
{ label: '📝 Login Form', prompt: 'Create a login form with username and password fields and a submit button' },
{ label: '📋 Registration Form', prompt: 'A registration form with email, password, and confirm password fields arranged vertically' },
{ label: '👤 User Card', prompt: 'A card with a user profile containing a title and description' },
];
const handleExampleClick = (prompt: string) => {
setAiInput(prompt);
};
const handleAiSubmit = async () => {
if (!aiInput.trim()) return;
setLoading(true);
try {
const messages = await generateUIFromPrompt(aiInput);
processor.processMessages([{ deleteSurface: { surfaceId: '@default' } }]);
processor.processMessages(messages);
console.log('Generated UI:', messages);
} catch (error) {
console.error('Error generating UI:', error);
alert('Failed to generate UI. Check console for details.');
} finally {
setLoading(false);
}
};
const handleAction = (action: UserAction) => {
console.log('Action received:', action);
};
return (
<div className="app-container">
<div className="sidebar">
<div className="info-box">
<h3>How to Use</h3>
<p>Describe the UI you want to create in natural language. The AI will generate it for you!</p>
</div>
<div className="sidebar-content">
<Textarea
placeholder="E.g., Create a login form with username and password fields and a submit button"
value={aiInput}
onChange={(event) => setAiInput(event.currentTarget.value)}
minRows={6}
autosize
maxRows={12}
styles={{
input: {
fontSize: '0.95rem',
lineHeight: 1.6
}
}}
/>
<Button
onClick={handleAiSubmit}
loading={loading}
disabled={!aiInput.trim() || loading}
size="lg"
fullWidth
gradient={{ from: '#667eea', to: '#764ba2', deg: 135 }}
variant="gradient"
>
{loading ? 'Generating...' : 'Generate UI'}
</Button>
<div className="examples-section">
<h3>Quick Examples</h3>
<div className="examples-grid">
{examples.map((example, index) => (
<button
key={index}
className="example-button"
onClick={() => handleExampleClick(example.prompt)}
disabled={loading}
>
{example.label}
</button>
))}
</div>
</div>
</div>
</div>
<div className="main-content">
<div className="main-content-inner">
<Surface
processor={processor}
components={mantineComponents}
onAction={handleAction}
/>
</div>
</div>
</div>
);
}
export default App;
The useA2uiProcessor hook manages our dynamic UI state. It processes A2UI messages and tracks rendered components. We track two things: the user’s input and a loading flag. The example prompts give users quick start options. The Surface component renders everything. It takes messages from the processor and converts them into React components using the Mantine adapters.
When users interact with generated components, like clicking a button, those actions flow through handleAction.
Let’s finally add some CSS styles to this. Copy and paste this in your CSS file:
.app-container {
display: flex;
flex-direction: row;
min-height: 100vh;
}
.sidebar {
width: 30%;
padding: 32px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-right: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 2px 0 20px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
}
.sidebar h1 {
font-size: 1.75rem;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
letter-spacing: -0.5px;
}
.sidebar-content {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
}
.info-box {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border-radius: 12px;
padding: 16px;
border: 1px solid rgba(102, 126, 234, 0.2);
}
.info-box h3 {
margin: 0 0 8px 0;
font-size: 0.875rem;
font-weight: 600;
color: #667eea;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-box p {
margin: 0;
font-size: 0.875rem;
color: #666;
line-height: 1.6;
}
.examples-section {
margin-top: 8px;
}
.examples-section h3 {
margin: 0 0 12px 0;
font-size: 0.875rem;
font-weight: 600;
color: #667eea;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.examples-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.example-button {
padding: 10px 12px;
border: 1px solid rgba(102, 126, 234, 0.3);
background: rgba(102, 126, 234, 0.05);
border-radius: 8px;
font-size: 0.8rem;
font-weight: 500;
color: #667eea;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.example-button:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.15);
border-color: rgba(102, 126, 234, 0.5);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.example-button:active:not(:disabled) {
transform: translateY(0);
}
.example-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.main-content {
width: 70%;
padding: 32px;
overflow-y: auto;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
}
.main-content-inner {
background: #778ce8;
border-radius: 5px;
padding: 32px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
min-height:70%;
max-height:70%;
width: 50%;
}
Go ahead and test it out in the browser:

You can check out the full demo codebase in this GitHub repo.
A2UI changes how we think about interacting with AI agents. Instead of forcing everything through chat, it gives agents a way to present real interfaces when the task calls for it. That shift enables clearer workflows, better user input, and far more control over complex interactions.
In this walkthrough, we explored the A2UI protocol and built a working UI generator using a2ui-bridge, a React-based renderer. The model strikes a thoughtful balance between structure and flexibility: agents describe intent, the UI adapts, and the client remains in control.
A2UI is still evolving. As the ecosystem matures, we can expect official renderers for popular frameworks, richer component libraries, stronger tooling and debugging support, and deeper integrations with more AI providers. What A2UI ultimately signals is a move toward graphical, interface-driven AI interactions. In that sense, it feels a lot like the Telegram Mini App equivalent for AI agents: lightweight, interactive, and surprisingly powerful.

Learn how LLM routing works in production, when it’s worth the complexity, and how teams choose the right model for each request.

Compare key features of popular meta-frameworks Remix, Next.js, and SvelteKit, from project setup to styling.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the February 4th issue.

AI-first isn’t about tools; it’s about how teams think, build, and decide. Ken Pickering, CTO at Scripta Insights, shares how engineering leaders can adapt.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now