Skip to main content

Overview

The AG-UI Protocol is a sophisticated multi-agent orchestration pattern designed for complex workflows that require coordinated agent collaboration, shared state management, and human-in-the-loop approvals. Built on the @ag-ui/client library, it provides bidirectional state synchronization between frontend and backend agent systems.
When to Use AG-UI Protocol
  • Complex agent workflows requiring multiple steps
  • Multi-agent coordination and collaboration
  • Shared state across components and sessions
  • Tool execution with human approval gates
  • Draft systems requiring review before execution
Not Recommended For
  • Simple single-message requests (use CopilotKit instead)
  • Stateless operations (use SSE Streaming)
  • Single-turn Q&A interactions

Architecture

The AG-UI Protocol implements a sophisticated state management architecture using Zustand stores with bidirectional synchronization between frontend and backend.

State Flow

  1. User Action: User interacts with React component
  2. Local Update: Zustand store updates immediately (optimistic)
  3. Backend Sync: HttpAgent sends state to LangGraph backend
  4. Agent Processing: Backend agent processes with full state context
  5. State Broadcast: Backend pushes state updates via Observable stream
  6. Store Reconciliation: Frontend stores merge backend state changes

State Management

The AG-UI Protocol manages 7 core state components that provide comprehensive context for agent operations.

1. Conversation State

Tracks the complete message history and conversation flow.
interface ConversationState {
  messages: Array<{
    id: string;
    role: 'user' | 'assistant' | 'system';
    content: string;
    timestamp: string;
    tool_calls?: ToolCall[];
  }>;
  activeMessageId: string | null;
  isStreaming: boolean;
}

2. Workspace Context

Provides awareness of the user’s current workspace and focus.
interface WorkspaceContext {
  activeSheet?: string | null;
  activeDashboard?: string | null;
  activePipeline?: string | null;
  focusMode?: 'sheet' | 'dashboard' | 'pipeline' | null;
  selectedCells?: string[];
  currentPage?: string;
}

3. Draft System

Manages proposed actions requiring human approval before execution.
interface AgentDraft {
  id: string;
  type: 'formula' | 'validation' | 'workflow' | 'data_import';
  title: string;
  description: string;
  status: 'pending' | 'approved' | 'rejected' | 'applied';
  confidence: number;
  preview?: {
    before?: any;
    after?: any;
    affectedCells?: string[];
  };
  actions: DraftAction[];
  createdAt: string;
  expiresAt?: string;
}

4. Tool Execution State

Tracks tool calls and their execution status.
interface ToolExecutionState {
  pendingTools: string[];
  toolResults: Record<string, {
    success: boolean;
    data?: any;
    error?: string;
    timestamp: string;
  }>;
  activeToolCalls: Map<string, ToolCall>;
}

5. Session Metadata

Maintains session-level information and configuration.
interface SessionMetadata {
  sessionId: string;
  userId: string;
  activeAgent: AgentId;
  startTime: string;
  lastActivity: string;
  errorCount: number;
  lastError?: string | null;
}

6. Data Collections

Stores summaries of available resources and datasets.
interface DataCollections {
  dashboards: DashboardSummary[];
  workflows: WorkflowSummary[];
  pipelines: PipelineSummary[];
  datasets: DatasetSummary[];
}

7. Insights & Analytics

Captures agent-generated insights and recommendations.
interface AgentInsight {
  id: string;
  type: 'suggestion' | 'warning' | 'error' | 'info';
  category: string;
  title: string;
  description: string;
  actionable: boolean;
  suggestedAction?: string;
  confidence: number;
  source: string;
  createdAt: string;
}

Backend Setup

1. Install Dependencies

npm install @ag-ui/client @ag-ui/core
Already included in OpsHub’s package.json:
{
  "dependencies": {
    "@ag-ui/client": "^0.0.40"
  }
}

2. Create Multi-Agent Client

The MultiAgentClient class wraps the AG-UI HttpAgent and provides multi-agent orchestration:
// lib/agent/multi-agent-client.ts
import { HttpAgent, type RunAgentParameters } from '@ag-ui/client';
import type { Message, RunAgentInput, BaseEvent } from '@ag-ui/core';

export class MultiAgentClient {
  private baseUrl: string;
  private activeAgentId: AgentId;
  private httpAgent: HttpAgent | null = null;
  private threadId: string;
  private userId: string;

  constructor(config: MultiAgentConfig = {}) {
    this.baseUrl = config.baseUrl || 'http://localhost:8000';
    this.threadId = config.threadId || 'default-session';
    this.userId = config.userId || 'default-user';
    this.activeAgentId = config.defaultAgent || 'app';
  }

  getHttpAgent(): HttpAgent {
    if (!this.httpAgent) {
      this.httpAgent = new HttpAgent({
        url: `${this.baseUrl}/agent`,
        threadId: this.threadId,
        debug: process.env.NODE_ENV === 'development',
      });
    }
    return this.httpAgent;
  }

  async sendMessage(content: string, metadata?: Record<string, unknown>) {
    const agent = this.getHttpAgent();

    const userMessage: Message = {
      id: `user-${Date.now()}`,
      role: 'user',
      content,
    };

    agent.addMessage(userMessage);

    // Convert metadata to AG-UI context format
    const contextArray: Array<{ value: string; description: string }> = [
      { value: this.activeAgentId, description: 'activeAgentId' },
      { value: this.userId, description: 'userId' },
    ];

    if (metadata) {
      for (const [key, value] of Object.entries(metadata)) {
        contextArray.push({
          value: String(value),
          description: key,
        });
      }
    }

    const runParams: RunAgentParameters = { context: contextArray };
    return agent.runAgent(runParams);
  }
}

3. Backend Agent Configuration

Configure your FastAPI backend to work with AG-UI Protocol:
# Python backend (FastAPI + LangGraph)
from fastapi import FastAPI
from langgraph.graph import StateGraph
from typing import TypedDict, Annotated

class AgentState(TypedDict):
    messages: list[dict]
    workspace: dict
    drafts: list[dict]
    insights: list[dict]
    pendingTools: list[str]
    toolResults: dict
    sessionId: str
    userId: str
    activeAgent: str

app = FastAPI()

# LangGraph workflow
workflow = StateGraph(AgentState)

@app.post("/agent")
async def agent_endpoint(request: dict):
    # AG-UI client sends requests here
    thread_id = request.get("threadId")
    messages = request.get("messages", [])
    context = request.get("context", [])

    # Process with LangGraph
    result = await workflow.ainvoke({
        "messages": messages,
        "sessionId": thread_id,
        # ... other state
    })

    return result

Frontend Implementation

1. Zustand Store Setup

Create a comprehensive Zustand store with state management:
// lib/stores/agentStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface AgentState {
  // Core agent state
  activeAgent: AgentId;
  sessionId: string | null;
  error: string | null;

  // Workspace context
  workspace: AgentWorkspaceContext;

  // Data collections
  dashboards: DashboardSummary[];
  workflows: WorkflowSummary[];
  pipelines: PipelineSummary[];
  datasets: DatasetSummary[];
  drafts: AgentDraft[];
  insights: AgentInsight[];

  // Actions
  setActiveAgent: (agentId: AgentId) => void;
  updateWorkspace: (workspace: Partial<AgentWorkspaceContext>) => void;
  upsertDraft: (draft: AgentDraft) => void;
  removeDraft: (draftId: string) => void;
  appendInsight: (insight: AgentInsight) => void;
  resetSession: () => void;
}

export const useAgentStore = create<AgentState>()(
  subscribeWithSelector(
    immer((set) => ({
      activeAgent: 'dashboard',
      sessionId: null,
      error: null,
      workspace: {},
      dashboards: [],
      workflows: [],
      pipelines: [],
      datasets: [],
      drafts: [],
      insights: [],

      setActiveAgent: (agentId) => set((state) => {
        state.activeAgent = agentId;
      }),

      updateWorkspace: (workspace) => set((state) => {
        state.workspace = { ...state.workspace, ...workspace };
      }),

      upsertDraft: (draft) => set((state) => {
        const index = state.drafts.findIndex((d) => d.id === draft.id);
        if (index >= 0) {
          state.drafts[index] = draft;
        } else {
          state.drafts.push(draft);
        }
      }),

      removeDraft: (draftId) => set((state) => {
        state.drafts = state.drafts.filter((d) => d.id !== draftId);
      }),

      appendInsight: (insight) => set((state) => {
        const index = state.insights.findIndex((i) => i.id === insight.id);
        if (index === -1) {
          state.insights.push(insight);
        }
      }),

      resetSession: () => set((state) => {
        state.sessionId = null;
        state.error = null;
        state.workspace = {};
        state.drafts = [];
        state.insights = [];
      }),
    }))
  )
);

2. React Hook for Multi-Agent

Use the useMultiAgent hook for easy component integration:
// lib/agent/use-multi-agent.ts
import { useMultiAgent } from '@/lib/agent/use-multi-agent';

export function MyComponent() {
  const {
    agents,
    activeAgent,
    activeAgentId,
    databaseContext,
    setActiveAgent,
    sendMessage,
    isLoading,
    error,
  } = useMultiAgent({
    defaultAgent: 'dashboard',
    userId: user.id,
    threadId: sessionId,
  });

  return (
    <div>
      {/* Your component UI */}
    </div>
  );
}

3. Component Integration

Here’s a complete example of integrating AG-UI Protocol in a React component:
// components/agent/AgentConsole.tsx
'use client';

import { useMultiAgent } from '@/lib/agent/use-multi-agent';
import { useAgentStore } from '@/lib/stores/agentStore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { useState } from 'react';

export function AgentConsole() {
  const [input, setInput] = useState('');
  const {
    agents,
    activeAgent,
    sendMessage,
    isLoading,
  } = useMultiAgent({
    userId: 'user-123',
    threadId: 'session-456',
  });

  const { drafts, insights, workspace } = useAgentStore();

  const handleSend = async () => {
    if (!input.trim()) return;

    await sendMessage(input, {
      workspaceId: workspace.activeSheet,
      page: '/dashboard',
    });

    setInput('');
  };

  return (
    <div className="flex flex-col h-full">
      {/* Agent Selector */}
      <div className="flex items-center gap-2 p-4 border-b">
        <span className="text-sm font-medium">Active Agent:</span>
        {agents.map((agent) => (
          <Badge
            key={agent.id}
            variant={activeAgent?.id === agent.id ? 'default' : 'outline'}
          >
            {agent.name}
          </Badge>
        ))}
      </div>

      {/* Drafts Panel */}
      {drafts.length > 0 && (
        <div className="p-4 bg-amber-50 border-b">
          <h3 className="font-medium text-sm mb-2">
            Pending Approvals ({drafts.length})
          </h3>
          {drafts.map((draft) => (
            <DraftCard key={draft.id} draft={draft} />
          ))}
        </div>
      )}

      {/* Insights Panel */}
      {insights.length > 0 && (
        <div className="p-4 bg-blue-50 border-b">
          <h3 className="font-medium text-sm mb-2">
            Insights ({insights.length})
          </h3>
          {insights.map((insight) => (
            <InsightCard key={insight.id} insight={insight} />
          ))}
        </div>
      )}

      {/* Message Input */}
      <div className="flex items-center gap-2 p-4 border-t">
        <Input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask the agent..."
          onKeyDown={(e) => e.key === 'Enter' && handleSend()}
          disabled={isLoading}
        />
        <Button onClick={handleSend} disabled={isLoading}>
          Send
        </Button>
      </div>
    </div>
  );
}

Complete Example: Multi-Step Workflow

Here’s a complete example demonstrating a multi-step workflow with human-in-the-loop approvals:
  • React Component
  • Backend Workflow
'use client';

import { useMultiAgent } from '@/lib/agent/use-multi-agent';
import { useAgentStore } from '@/lib/stores/agentStore';
import { useState, useEffect } from 'react';

export function PricingWorkflow() {
  const { sendMessage, isLoading } = useMultiAgent({
    defaultAgent: 'pricing',
    userId: 'user-123',
  });

  const { drafts, upsertDraft, removeDraft, workspace } = useAgentStore();
  const [workflowStatus, setWorkflowStatus] = useState<'idle' | 'running' | 'completed'>('idle');

  // Start pricing workflow
  const startWorkflow = async () => {
    setWorkflowStatus('running');

    await sendMessage('Start daily pricing workflow', {
      portfolioId: workspace.activeSheet,
      date: new Date().toISOString(),
    });
  };

  // Handle draft approval
  const approveDraft = async (draftId: string) => {
    const draft = drafts.find((d) => d.id === draftId);
    if (!draft) return;

    // Update draft status
    upsertDraft({ ...draft, status: 'approved' });

    // Send approval to agent
    await sendMessage(`Approve draft: ${draftId}`, {
      draftId,
      action: 'approve',
    });
  };

  // Handle draft rejection
  const rejectDraft = async (draftId: string, reason: string) => {
    const draft = drafts.find((d) => d.id === draftId);
    if (!draft) return;

    upsertDraft({ ...draft, status: 'rejected' });

    await sendMessage(`Reject draft: ${draftId}`, {
      draftId,
      action: 'reject',
      reason,
    });
  };

  return (
    <div className="space-y-4">
      <Button
        onClick={startWorkflow}
        disabled={workflowStatus === 'running' || isLoading}
      >
        {workflowStatus === 'running' ? 'Running...' : 'Start Pricing Workflow'}
      </Button>

      {/* Draft Approvals */}
      <div className="space-y-2">
        {drafts.map((draft) => (
          <div key={draft.id} className="p-4 border rounded-lg">
            <h3 className="font-medium">{draft.title}</h3>
            <p className="text-sm text-muted-foreground">{draft.description}</p>

            {draft.preview && (
              <div className="mt-2 p-2 bg-muted rounded">
                <pre className="text-xs">
                  {JSON.stringify(draft.preview, null, 2)}
                </pre>
              </div>
            )}

            <div className="flex gap-2 mt-4">
              <Button
                size="sm"
                onClick={() => approveDraft(draft.id)}
              >
                Approve
              </Button>
              <Button
                size="sm"
                variant="outline"
                onClick={() => rejectDraft(draft.id, 'Not ready')}
              >
                Reject
              </Button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Draft System

The draft system is a key feature of AG-UI Protocol that enables human-in-the-loop approvals for agent actions.

Draft Lifecycle

Creating Drafts

Agents create drafts for actions requiring approval:
const draft: AgentDraft = {
  id: `draft-${Date.now()}`,
  type: 'validation',
  title: 'Apply ASIC RG94 Validation Rule',
  description: 'Add NAV variance check for Portfolio ABC',
  status: 'pending',
  confidence: 0.92,
  preview: {
    before: { rule: null },
    after: {
      rule: {
        name: 'NAV Variance Check',
        tolerance: 3, // basis points
        portfolio: 'ABC',
      },
    },
    affectedCells: ['E4:E50'],
  },
  actions: [
    {
      type: 'add_validation_rule',
      params: {
        ruleId: 'rule-123',
        portfolio: 'ABC',
      },
    },
  ],
  createdAt: new Date().toISOString(),
  expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
};

Handling Draft Approvals

Frontend code to handle draft approvals:
const { drafts, upsertDraft } = useAgentStore();

async function handleApproval(draftId: string, approved: boolean) {
  const draft = drafts.find((d) => d.id === draftId);
  if (!draft) return;

  // Update local state
  upsertDraft({
    ...draft,
    status: approved ? 'approved' : 'rejected',
  });

  // Notify backend
  await sendMessage(`Draft ${approved ? 'approved' : 'rejected'}`, {
    draftId,
    action: approved ? 'approve' : 'reject',
  });

  // If approved, apply changes
  if (approved) {
    for (const action of draft.actions) {
      await executeAction(action);
    }

    upsertDraft({ ...draft, status: 'applied' });
  }
}

Tool Execution

AG-UI Protocol provides robust tool execution with approval gates and result tracking.

Tool Definition

interface ToolCall {
  id: string;
  name: string;
  description: string;
  parameters: Record<string, any>;
  requiresApproval: boolean;
  estimatedDuration?: number;
}

Tool Execution Flow

// 1. Agent proposes tool call
const toolCall: ToolCall = {
  id: 'tool-123',
  name: 'update_validation_rule',
  description: 'Update NAV variance tolerance',
  parameters: {
    ruleId: 'rule-abc',
    newTolerance: 5,
  },
  requiresApproval: true,
};

// 2. Create draft for approval
const draft = createToolDraft(toolCall);
useAgentStore.getState().upsertDraft(draft);

// 3. User approves
await handleApproval(draft.id, true);

// 4. Execute tool
const result = await executeTool(toolCall);

// 5. Store result
useAgentStore.getState().setToolResult(toolCall.id, result);
// For tools that don't require approval
const toolCall: ToolCall = {
  id: 'tool-456',
  name: 'fetch_portfolio_data',
  description: 'Fetch portfolio holdings',
  parameters: {
    portfolioId: 'portfolio-123',
  },
  requiresApproval: false, // No approval needed
};

// Execute directly
const result = await executeTool(toolCall);
useAgentStore.getState().setToolResult(toolCall.id, result);

Use Cases

1. Complex Data Validation Workflows

Multi-step validation with approvals:
// Agent validates data across multiple checks
const workflow = [
  { step: 'data_quality', requiresApproval: false },
  { step: 'price_validation', requiresApproval: true },
  { step: 'reconciliation', requiresApproval: true },
  { step: 'asic_compliance', requiresApproval: false },
  { step: 'final_approval', requiresApproval: true },
];

2. Multi-Agent Collaboration

Multiple agents working together on a task:
const { setActiveAgent, sendMessage } = useMultiAgent();

// Pricing agent fetches prices
await setActiveAgent('pricing');
await sendMessage('Fetch EOD prices for all portfolios');

// Switch to reconciliation agent
await setActiveAgent('reconciliation');
await sendMessage('Reconcile positions against custodian');

// Switch to compliance agent
await setActiveAgent('compliance');
await sendMessage('Run ASIC RG94 validation checks');

3. Human-in-the-Loop Pricing

Daily pricing workflow with approval gates:
// Agent proposes pricing changes
const pricingDraft = await sendMessage('Calculate NAV for all portfolios');

// User reviews and approves
await handleApproval(pricingDraft.id, true);

// Agent applies prices and generates reports
await sendMessage('Apply approved prices and generate reports');

Best Practices

State Management

Optimistic Updates

Update Zustand stores immediately for responsive UI, then reconcile with backend state.

State Granularity

Keep state focused. Use multiple small stores instead of one large store.

Subscription Management

Use subscribeWithSelector middleware to prevent unnecessary re-renders.

Error Recovery

Implement rollback logic when backend state sync fails.

Performance Optimization

// 1. Selective store subscriptions
const drafts = useAgentStore((state) => state.drafts);
// Only re-renders when drafts change

// 2. Memoize expensive computations
const filteredDrafts = useMemo(
  () => drafts.filter((d) => d.status === 'pending'),
  [drafts]
);

// 3. Batch state updates
useAgentStore.setState((state) => ({
  ...state,
  drafts: [...state.drafts, newDraft],
  insights: [...state.insights, newInsight],
}));

Error Handling

const { sendMessage, error } = useMultiAgent();

try {
  await sendMessage('Complex query');
} catch (err) {
  // Handle error locally
  console.error('Message failed:', err);

  // Update store
  useAgentStore.getState().setError(err.message);

  // Show user notification
  toast.error('Failed to send message');
}

Troubleshooting

Common Issues

Cause: HttpAgent not properly configured or backend not returning state updates.Solution:
// Verify HttpAgent configuration
const agent = new HttpAgent({
  url: process.env.NEXT_PUBLIC_AGENT_API_URL,
  threadId: sessionId,
  debug: true, // Enable debug logging
});

// Check backend is returning state in response
// Backend should include full state in response
Cause: Store not being updated or component not subscribed to drafts.Solution:
// Ensure component subscribes to drafts
const drafts = useAgentStore((state) => state.drafts);

// Verify upsertDraft is being called
console.log('Upserting draft:', draft);
useAgentStore.getState().upsertDraft(draft);

// Check draft is valid
const isValid = draft.id && draft.type && draft.title;
Cause: Workspace context not being passed in sendMessage metadata.Solution:
const { workspace } = useAgentStore();

await sendMessage('Your message', {
  activeSheet: workspace.activeSheet,
  activeDashboard: workspace.activeDashboard,
  currentPage: window.location.pathname,
});
Cause: Backend not properly implementing event streaming.Solution:
# Backend should yield events during processing
async def stream_response():
    yield {"type": "message_start"}
    yield {"type": "tool_call", "data": {...}}
    yield {"type": "message_delta", "content": "..."}
    yield {"type": "message_end"}

Debug Mode

Enable debug logging to troubleshoot issues:
// Enable AG-UI debug mode
const agent = new HttpAgent({
  url: process.env.NEXT_PUBLIC_AGENT_API_URL,
  threadId: sessionId,
  debug: true, // Logs all agent communication
});

// Add custom logging
useAgentStore.subscribe(
  (state) => state.drafts,
  (drafts) => {
    console.log('[AgentStore] Drafts updated:', drafts);
  }
);

API Reference

MultiAgentClient

class MultiAgentClient {
  constructor(config: MultiAgentConfig);

  // Agent management
  initialize(): Promise<void>;
  getAgents(): AgentInfo[];
  getActiveAgent(): AgentInfo | undefined;
  setActiveAgent(agentId: AgentId): Promise<DatabaseContext>;

  // Messaging
  sendMessage(content: string, metadata?: Record<string, unknown>): Promise<RunAgentResult>;
  streamMessage(content: string, metadata?: Record<string, unknown>): AsyncGenerator<BaseEvent>;

  // Configuration
  getHttpAgent(): HttpAgent;
  getDatabaseContext(agentId: AgentId): Promise<DatabaseContext>;
}

useMultiAgent Hook

function useMultiAgent(options?: UseMultiAgentOptions): UseMultiAgentReturn;

interface UseMultiAgentReturn {
  agents: AgentInfo[];
  activeAgent: AgentInfo | undefined;
  activeAgentId: AgentId;
  databaseContext: DatabaseContext | null;
  setActiveAgent: (agentId: AgentId) => Promise<void>;
  getDatabaseContext: (agentId: AgentId) => Promise<DatabaseContext>;
  sendMessage: (content: string, metadata?: Record<string, any>) => Promise<any>;
  isLoading: boolean;
  error: Error | null;
}

useAgentStore

interface AgentState {
  // State
  activeAgent: AgentId;
  sessionId: string | null;
  workspace: AgentWorkspaceContext;
  drafts: AgentDraft[];
  insights: AgentInsight[];

  // Actions
  setActiveAgent: (agentId: AgentId) => void;
  updateWorkspace: (workspace: Partial<AgentWorkspaceContext>) => void;
  upsertDraft: (draft: AgentDraft) => void;
  removeDraft: (draftId: string) => void;
  appendInsight: (insight: AgentInsight) => void;
  resetSession: () => void;
}

Next Steps: Explore multi-agent orchestration patterns or dive into advanced state management techniques.