Day 1 of 30 — The complete introduction to AG-UI: what it is, how it works, and why it breaks traditional frontend thinking
This is day one of a 30-day series on Agentic UI — what it is, how to build it, and what the patterns look like in production. Every post is specific, technical, and grounded in real implementation. No fluff. No theory without code.
The problem with building AI agents in traditional frontends
Most teams building AI-powered products make the same architectural mistake early on.
They build the agent first. Then they try to fit it into a traditional SaaS frontend.
It does not work — not cleanly, not at scale, not for anything more complex than a simple question-and-answer flow. The reason is fundamental: traditional frontend architecture is designed for deterministic outputs. Agentic architecture produces non-deterministic outputs. These two models are structurally incompatible.
To understand why, you need to understand exactly what makes an agentic application different from every other kind of software.
The core distinction — deterministic vs non-deterministic outputs
In a traditional SaaS application, the developer knows at build time what every feature produces.
A search feature produces a list of results. A dashboard feature produces a set of charts. A settings feature produces a form. The developer builds a component for each output. The component renders that shape. Every time. Predictably.
Feature defined at design time
→ Output shape known at design time
→ Component built at design time
→ Runtime populates data into the component
In an agentic application, the agent decides what to produce at runtime based on the task it has been given, the data it finds, and what it discovers as it works.
A user gives the agent a task. The agent might produce a data table. It might produce a set of structured items for human review. It might produce a narrative summary. It might produce an alert that it is blocked. It might produce all of these, in sequence, as it works through the task.
The developer cannot pre-build a component for every possible output combination because the output is determined by the agent at runtime, not by the developer at design time.
Task defined at runtime by the user
→ Agent execution — unknown duration, unknown steps
→ Typed output — known shape, unknown content
→ Component selected at runtime by the renderer
This is the fundamental incompatibility. And it is why Agentic UI exists as a distinct architectural pattern.
What Agentic UI actually is — a precise definition
Agentic UI (AG-UI) is a frontend architecture pattern designed for applications where an AI agent drives the primary workflow execution. It is characterised by five specific properties:
1. The agent drives execution, not assists it.
The user sets intent. The agent plans the steps, executes them, and produces outputs. The UI renders those outputs. This is different from a copilot model where AI assists within a pre-built feature flow. In AG-UI the agent is the workflow. The UI is the rendering layer.
2. Outputs are typed and structured.
The agent does not produce free text that the UI pastes into a box. It produces typed JSON objects — a defined schema of output types. A renderer maps those types to UI components at runtime. There are no hardcoded feature components. There is one dynamic renderer.
3. The human is in the loop at defined points.
Agentic does not mean autonomous. The agent handles execution. The human handles judgment at high-stakes decision points. When the agent wants to take a consequential action, execution stops. The human explicitly approves or rejects. Only then does execution continue.
4. Parallel execution is first-class.
Multiple agents can run simultaneously across different tasks. The UI must represent all parallel executions in a coherent, navigable way. This is a first-class requirement, not an afterthought.
5. Audit trails are user-facing.
Every agent decision, every action taken, every human approval or rejection is logged in a user-readable, inspectable record. Not server logs. Not analytics events. A visible, accessible audit trail the end user can open and read.
The AG-UI stack — the three layers
Agentic UI is built on three interconnected layers. Understanding how they relate to each other is the foundation for everything else in this series.
Layer 1 — The agent output schema
The output schema is the contract between the agent and the frontend. It is the single most important architectural decision in an agentic application.
The schema defines every type of structured output the agent can produce. The agent always emits outputs conforming to this schema. The frontend always renders based on this schema. Nothing crosses the boundary between agent and UI as free text.
A general-purpose AG-UI output schema looks like this:
type AgentOutputType =
| 'approval_gate' // agent wants to take a high-stakes action
| 'table' // tabular data of any kind
| 'review_items' // structured items for human review before action
| 'checklist' // ordered task list with completion states
| 'narrative' // rich text — summaries, commentary, documents
| 'chart' // data visualisation
| 'file_created' // agent created an external file or resource
| 'progress' // real-time step update during execution
| 'attention_required' // agent is blocked — needs human input
| 'task_created' // a prompt has been converted to a tracked task
| 'query_result' // simple info query — no task created
interface AgentOutput<T = unknown> {
type: AgentOutputType
data: T
task_id: string | null
session_id: string
requires_approval: boolean
timestamp: string
reasoning?: string // why the agent produced this output
audit_entry: AuditEntry // written atomically — always present
}
The reasoning field deserves special attention. It is optional in the schema but should be treated as mandatory in practice. The agent's explanation of why it produced a given output is what builds user trust in agentic applications. Users who can see the agent's reasoning approve outputs faster, reject inappropriate outputs more accurately, and trust the system more over time.
The most critical output type is approval_gate:
interface ApprovalGateOutput extends AgentOutput {
type: 'approval_gate'
data: {
action: string // what the agent wants to do
payload: unknown // the data it wants to act on
reasoning: string // why it wants to take this action
reversible: boolean // can this be undone if approved in error
estimated_impact: string // plain English — what will change if approved
}
requires_approval: true // always true — cannot be false
}
The approval gate is what separates an agentic UI from an autonomous system. Every high-stakes action routes through it. The agent drafts. The human decides. This is not a limitation — it is the design.
Layer 2 — The LLM output configuration
Having a schema means nothing if the agent does not reliably produce it. The agent must be explicitly configured to always emit structured outputs matching the schema.
This is done via Anthropic's tool_use API. Each output type in the schema becomes a tool definition. The agent calls the appropriate tool to emit each output instead of producing free text.
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic()
const agentTools: Anthropic.Tool[] = [
{
name: 'emit_approval_gate',
description: `You MUST call this tool before taking any high-stakes action.
Define "high-stakes" as: any action that modifies external data,
sends communications, makes financial transactions, or cannot be
easily reversed.
You cannot take these actions directly.
You must always request approval first.
The human will approve or reject.
You only execute on explicit approval.
There are no exceptions to this rule.`,
input_schema: {
type: 'object' as const,
properties: {
action: {
type: 'string',
description: 'The action you want to take'
},
payload: {
type: 'object',
description: 'The data you want to act on'
},
reasoning: {
type: 'string',
description: 'Why you want to take this action'
},
estimated_impact: {
type: 'string',
description: 'Plain English — what will change if approved'
},
reversible: {
type: 'boolean',
description: 'Can this be undone if approved in error'
}
},
required: ['action', 'payload', 'reasoning', 'estimated_impact', 'reversible']
}
},
{
name: 'emit_table',
description: `Call this to render any tabular data to the user.
Define columns explicitly with types so the renderer
can format values correctly.`,
input_schema: {
type: 'object' as const,
properties: {
title: { type: 'string' },
columns: {
type: 'array',
items: {
type: 'object',
properties: {
key: { type: 'string' },
label: { type: 'string' },
type: {
type: 'string',
enum: ['text', 'number', 'currency', 'date', 'status', 'badge']
},
sortable: { type: 'boolean' }
}
}
},
rows: {
type: 'array',
items: { type: 'object' }
},
summary: { type: 'string' }
},
required: ['title', 'columns', 'rows']
}
},
{
name: 'emit_attention_required',
description: `Call this when you are blocked and cannot continue
without human input. Be specific about what you need.
Always provide concrete options for the human to resolve the block.
Do not guess or proceed past a genuine blocker.`,
input_schema: {
type: 'object' as const,
properties: {
blocked_at_step: { type: 'string' },
reason: { type: 'string' },
urgency: {
type: 'string',
enum: ['low', 'medium', 'high']
},
options: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string' },
description: { type: 'string' },
action: {
type: 'string',
enum: ['provide_input', 'approve', 'skip', 'cancel']
}
}
}
}
},
required: ['blocked_at_step', 'reason', 'urgency', 'options']
}
},
{
name: 'emit_review_items',
description: `Call this when you have drafted items that need
human review before any action is taken on them.
Always include confidence score and reasoning per item.`,
input_schema: {
type: 'object' as const,
properties: {
title: { type: 'string' },
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
summary: { type: 'string' },
detail: { type: 'object' },
confidence: {
type: 'string',
enum: ['high', 'medium', 'low']
},
reasoning: { type: 'string' }
}
}
},
allow_bulk_approval: { type: 'boolean' }
},
required: ['title', 'items']
}
}
]
// Execute a single agent step within a task session
async function runAgentStep(params: {
taskId: string
sessionHistory: Anthropic.MessageParam[]
systemPrompt: string
}) {
const response = await client.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 8096,
system: params.systemPrompt,
tools: agentTools,
messages: params.sessionHistory
})
const outputs: AgentOutput[] = []
for (const block of response.content) {
if (block.type === 'tool_use') {
const output = mapToolUseToOutput(block, params.taskId)
outputs.push(output)
}
}
return {
outputs,
stop_reason: response.stop_reason,
requires_continuation: response.stop_reason === 'tool_use'
}
}
One production observation worth noting: the description field in each tool definition does significantly more work than most developers expect. The constraint around approval gates — the explicit statement that there are no exceptions — needs to be stated emphatically and without ambiguity. Weak tool descriptions produce inconsistent agent behaviour. Strong, explicit tool descriptions produce reliable, predictable output schemas.
Layer 3 — The generative renderer
The renderer is a single component that receives an AgentOutput and renders the correct UI at runtime. This is built using Vercel AI SDK's streamUI with React Server Components.
import { streamUI } from 'ai/rsc'
import { anthropic } from '@ai-sdk/anthropic'
export async function AgentWorkspace({
session,
context
}: {
session: TaskSession
context: ApplicationContext
}) {
const result = await streamUI({
model: anthropic('claude-sonnet-4-5'),
system: buildSystemPrompt(context),
messages: session.history,
// Streaming text — progress updates while agent is working
text: ({ content, done }) => {
if (done) return <NarrativeBlock content={content} />
return <ProgressStream content={content} />
},
tools: {
emit_approval_gate: {
description: 'Render an approval gate for a high-stakes action',
parameters: approvalGateSchema,
generate: async function* (params) {
// Immediate skeleton while data loads
yield <ApprovalGateSkeleton />
const enriched = await enrichApprovalData(params, context)
return (
<ApprovalGate
data={enriched}
onApprove={async () => {
'use server'
await executeAction(enriched)
await logAuditEntry(enriched, 'approved', session)
}}
onReject={async (reason: string) => {
'use server'
await logAuditEntry(enriched, 'rejected', session, reason)
await notifyAgent(session, 'action_rejected', reason)
}}
/>
)
}
},
emit_table: {
description: 'Render a dynamic data table',
parameters: tableSchema,
generate: async function* (params) {
yield <TableSkeleton />
return (
<DynamicTable
title={params.title}
columns={params.columns}
rows={params.rows}
summary={params.summary}
/>
)
}
},
emit_attention_required: {
description: 'Agent is blocked — needs human input to continue',
parameters: attentionRequiredSchema,
generate: async function* (params) {
// Update task status to needs_attention
await updateTaskStatus(session.task_id, 'needs_attention')
await notifyAssignedUsers(session, params.urgency)
return (
<AttentionRequired
blockedAt={params.blocked_at_step}
reason={params.reason}
urgency={params.urgency}
options={params.options}
onResolve={async (optionId: string, input?: string) => {
'use server'
await resolveBlock(session, optionId, input)
await updateTaskStatus(session.task_id, 'in_progress')
await resumeAgentExecution(session)
}}
/>
)
}
},
emit_review_items: {
description: 'Render items for human review before action',
parameters: reviewItemsSchema,
generate: async function* (params) {
yield <ReviewItemsSkeleton count={params.items.length} />
return (
<ReviewItems
title={params.title}
items={params.items}
allowBulkApproval={params.allow_bulk_approval}
onApproveAll={async () => {
'use server'
await queueAction('bulk_approve', params.items)
}}
onApproveItem={async (itemId: string) => {
'use server'
const item = params.items.find(i => i.id === itemId)
await queueAction('approve_item', [item])
}}
onRejectAll={async (reason: string) => {
'use server'
await notifyAgent(session, 'items_rejected', reason)
}}
/>
)
}
}
}
})
return result.value
}
The generate: async function* pattern is what makes the streaming feel live. The yield renders an immediate skeleton. The agent processes. The return renders the final component with real data. Users see the interface assembling in real time as the agent works — not a loading spinner and then a sudden page change.
The task board — tracking parallel agent execution
The task board is the operational centre of any agentic application. It gives users a real-time view of everything the agent is doing, has done, and is waiting on.
The five-column Kanban model maps directly to the agentic execution lifecycle:
type TaskStatus =
| 'todo' // scheduled — agent not yet started
| 'in_progress' // agent executing — live updates streaming
| 'needs_attention' // agent paused — human input required
| 'in_review' // agent complete — awaiting human sign-off
| 'completed' // approved and done
interface Task {
id: string
name: string
status: TaskStatus
current_step?: string
steps_completed?: number
estimated_steps?: number
blocked_reason?: string
urgency?: 'low' | 'medium' | 'high'
assigned_to: string
due_date: string
created_at: string
completed_at?: string
metadata: {
actions_approved: number
actions_rejected: number
outputs_created: number
duration_minutes?: number
}
}
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
const COLUMNS: {
status: TaskStatus
label: string
description: string
highlight?: boolean
}[] = [
{
status: 'todo',
label: 'To Do',
description: 'Scheduled — not yet started'
},
{
status: 'in_progress',
label: 'In Progress',
description: 'Agent executing — live updates'
},
{
status: 'needs_attention',
label: 'Needs Attention',
description: 'Agent paused — your input required',
highlight: true
},
{
status: 'in_review',
label: 'In Review',
description: 'Awaiting final sign-off'
},
{
status: 'completed',
label: 'Completed',
description: 'Approved and done'
}
]
export function TaskBoard() {
const [tasks, setTasks] = useState<Task[]>([])
const supabase = createClient()
useEffect(() => {
fetchTasks().then(setTasks)
// Real-time subscription — board updates the instant
// any agent changes status anywhere in the system
const channel = supabase
.channel('task-board')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'tasks' },
(payload) => {
setTasks(prev => {
const updated = payload.new as Task
const idx = prev.findIndex(t => t.id === updated.id)
if (idx === -1) return [...prev, updated]
return prev.map(t => t.id === updated.id ? updated : t)
})
}
)
.subscribe()
return () => { supabase.removeChannel(channel) }
}, [])
const byStatus = groupBy(tasks, 'status')
const attentionCount = byStatus['needs_attention']?.length ?? 0
return (
<div className="h-full flex flex-col">
{attentionCount > 0 && (
<div className="bg-amber-50 border-b border-amber-200 px-6 py-3
flex items-center gap-3">
<span className="text-amber-700 font-medium text-sm">
{attentionCount} {attentionCount === 1
? 'task needs'
: 'tasks need'} your attention
</span>
<button
className="text-amber-600 text-sm underline"
onClick={() => scrollToColumn('needs_attention')}
>
Review now
</button>
</div>
)}
<div className="flex-1 flex gap-4 p-6 overflow-x-auto">
{COLUMNS.map(column => (
<div
key={column.status}
id={`column-${column.status}`}
className={`
flex-shrink-0 w-72 flex flex-col rounded-lg
${column.highlight
? 'bg-amber-50 ring-1 ring-amber-200'
: 'bg-slate-50'
}
`}
>
<div className="p-4 border-b border-slate-200">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-slate-900 text-sm">
{column.label}
</span>
<span className="text-xs font-medium text-slate-500
bg-white border border-slate-200
rounded-full px-2 py-0.5">
{byStatus[column.status]?.length ?? 0}
</span>
</div>
<p className="text-xs text-slate-400">{column.description}</p>
</div>
<div className="flex-1 p-3 flex flex-col gap-2 overflow-y-auto">
{(byStatus[column.status] ?? []).map(task => (
<TaskCard key={task.id} task={task} />
))}
{(byStatus[column.status] ?? []).length === 0 && (
<p className="text-xs text-slate-300 text-center py-8">
Nothing here
</p>
)}
</div>
</div>
))}
</div>
</div>
)
}
function TaskCard({ task }: { task: Task }) {
return (
<div
className={`
bg-white rounded-lg border p-3 cursor-pointer
hover:shadow-sm transition-shadow
${task.status === 'needs_attention'
? 'border-amber-300'
: 'border-slate-200'
}
`}
onClick={() => openTask(task.id)}
>
<div className="flex items-center justify-between mb-2">
<StatusBadge status={task.status} />
<span className="text-xs text-slate-400">
{task.assigned_to}
</span>
</div>
<p className="text-sm font-medium text-slate-900 mb-2">
{task.name}
</p>
{task.status === 'in_progress' && (
<div className="mb-2">
<div className="flex items-center gap-2 mb-1">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
<span className="text-xs text-slate-500">{task.current_step}</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1">
<div
className="bg-blue-500 h-1 rounded-full transition-all duration-500"
style={{
width: `${((task.steps_completed ?? 0) /
(task.estimated_steps ?? 1)) * 100}%`
}}
/>
</div>
</div>
)}
{task.status === 'needs_attention' && (
<div className="bg-amber-50 rounded p-2 mb-2">
<p className="text-xs text-amber-700">{task.blocked_reason}</p>
</div>
)}
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-slate-400">
Due {formatRelativeDate(task.due_date)}
</span>
{task.status === 'completed' && (
<span className="text-xs text-slate-400">
{task.metadata.duration_minutes}m
</span>
)}
</div>
</div>
)
}
Per-task sessions — context continuity across multi-step execution
Every task has its own isolated, persistent chat session. This enables full audit trails, resume capability after blocks, and context continuity across multi-day tasks.
interface TaskSession {
id: string
task_id: string
history: Anthropic.MessageParam[]
status: TaskStatus
last_approval_decision?: 'approved' | 'rejected'
last_approval_notes?: string
created_at: string
updated_at: string
metadata: {
approval_gates_shown: number
approval_gates_approved: number
approval_gates_rejected: number
steps_completed: number
estimated_steps: number
}
}
// Always atomic — row lock prevents concurrent writes
async function appendToSession(
sessionId: string,
role: 'user' | 'assistant',
content: Anthropic.MessageParam['content']
): Promise<TaskSession> {
return await db.transaction(async (tx) => {
const session = await tx
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.for('update')
.then(rows => rows[0])
const updatedHistory: Anthropic.MessageParam[] = [
...session.history,
{ role, content }
]
return await tx
.update(sessions)
.set({
history: updatedHistory,
updated_at: new Date().toISOString()
})
.where(eq(sessions.id, sessionId))
.returning()
.then(rows => rows[0])
})
}
// Resume from exactly where the agent paused
async function resumeSession(sessionId: string): Promise<void> {
const session = await getSession(sessionId)
const resumeMessage: Anthropic.MessageParam = {
role: 'user',
content: `The human has resolved the previous block.
Decision: ${session.last_approval_decision}
Notes: ${session.last_approval_notes ?? 'None provided'}
Please continue from where you stopped.
Do not repeat steps you have already completed.`
}
await appendToSession(sessionId, 'user', resumeMessage.content)
await runAgentStep({
taskId: session.task_id,
sessionHistory: [...session.history, resumeMessage],
systemPrompt: buildSystemPrompt(session.context)
})
}
The row lock on session writes is critical. Without it, concurrent requests to the same session — which happen frequently when parallel agents are running — can produce race conditions that corrupt the conversation history and break agent context.
The three things that will surprise you
1. The approval gate reduces friction, not increases it.
The instinct is to minimise approval gates to reduce user friction. This instinct is wrong in high-stakes domains. Users who can see exactly what the agent wants to do and explicitly approve it trust the system far more than users who watch the agent act autonomously. An explicit approval step converts an anxious user into a confident one. Trust compounds over time.
2. The reasoning field is more valuable than the output itself.
When you first define your output schema, reasoning feels like optional metadata. In production, it is the most important field in the schema. Users who can read why the agent produced a given output approve correct outputs faster and catch incorrect outputs more reliably. The reasoning is not documentation — it is the primary trust-building mechanism in the UI.
3. Parallel agents surface a prioritisation problem you will not anticipate.
When multiple agents run simultaneously, approval gates and attention alerts from different tasks arrive in the queue at the same time. Displaying them in chronological order is wrong. Users need to triage by urgency and impact. Build the prioritisation logic into your approval queue from day one. Retrofitting it is painful.
What is next
Tomorrow: the difference between a chat UI and a true agent UI.
They look nearly identical from the outside. The architecture underneath is completely different. Five specific decisions that look the same on the surface but have entirely different implications when you build them — and why chat-style thinking breaks down the moment you try to implement parallel execution, session persistence, and audit trails at the same time.
Resources
- Vercel AI SDK — streamUI
- Anthropic tool_use documentation
- Supabase real-time subscriptions
- Next.js React Server Components
Follow this series on Dev.to, LinkedIn, and X. New post every day for 30 days. Questions or implementation discussions — drop them in the comments.
Top comments (0)