AG-UI Integration

Use json-render to support AG-UI (Agent User Interaction Protocol) from CopilotKit.

Concept: This page demonstrates how json-render can support AG-UI. The examples are illustrative and may require adaptation for production use.

What is AG-UI?

AG-UI is an open protocol for connecting AI agents to user interfaces. It provides a standardized way for agents to render UI components, handle user input, and manage state. The protocol uses events streamed over HTTP to update the UI in real-time.

AG-UI Event Types

AG-UI defines several event types for agent-UI communication:

  • TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_END — Streaming text messages
  • TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END — Tool/function calls
  • STATE_SNAPSHOT / STATE_DELTA — State updates
  • CUSTOM — Custom events for UI rendering

Example AG-UI Event Stream

{"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"}
{"type": "TEXT_MESSAGE_START", "messageId": "msg-1", "role": "assistant"}
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-1", "delta": "Here's a dashboard for you:"}
{"type": "TEXT_MESSAGE_END", "messageId": "msg-1"}
{"type": "TOOL_CALL_START", "toolCallId": "tc-1", "toolCallName": "render_ui"}
{"type": "TOOL_CALL_ARGS", "toolCallId": "tc-1", "delta": "{\"component\": \"Dashboard\", \"props\": {\"title\": \"Sales\"}}"}
{"type": "TOOL_CALL_END", "toolCallId": "tc-1"}
{"type": "RUN_FINISHED"}

Define the AG-UI Schema

Define schemas for AG-UI event types:

import { z } from 'zod';

// Base event schema
const BaseEvent = z.object({
  type: z.string(),
  timestamp: z.number().optional(),
});

// Text message events
const TextMessageStart = BaseEvent.extend({
  type: z.literal('TEXT_MESSAGE_START'),
  messageId: z.string(),
  role: z.enum(['user', 'assistant']),
});

const TextMessageContent = BaseEvent.extend({
  type: z.literal('TEXT_MESSAGE_CONTENT'),
  messageId: z.string(),
  delta: z.string(),
});

const TextMessageEnd = BaseEvent.extend({
  type: z.literal('TEXT_MESSAGE_END'),
  messageId: z.string(),
});

// Tool call events
const ToolCallStart = BaseEvent.extend({
  type: z.literal('TOOL_CALL_START'),
  toolCallId: z.string(),
  toolCallName: z.string(),
  parentMessageId: z.string().optional(),
});

const ToolCallArgs = BaseEvent.extend({
  type: z.literal('TOOL_CALL_ARGS'),
  toolCallId: z.string(),
  delta: z.string(),
});

const ToolCallEnd = BaseEvent.extend({
  type: z.literal('TOOL_CALL_END'),
  toolCallId: z.string(),
});

// State events
const StateSnapshot = BaseEvent.extend({
  type: z.literal('STATE_SNAPSHOT'),
  snapshot: z.record(z.unknown()),
});

const StateDelta = BaseEvent.extend({
  type: z.literal('STATE_DELTA'),
  delta: z.array(z.object({
    op: z.enum(['add', 'remove', 'replace']),
    path: z.string(),
    value: z.unknown().optional(),
  })),
});

// Custom event for UI components
const CustomEvent = BaseEvent.extend({
  type: z.literal('CUSTOM'),
  name: z.string(),
  value: z.unknown(),
});

// Run lifecycle events
const RunStarted = BaseEvent.extend({
  type: z.literal('RUN_STARTED'),
  threadId: z.string(),
  runId: z.string(),
});

const RunFinished = BaseEvent.extend({
  type: z.literal('RUN_FINISHED'),
});

const RunError = BaseEvent.extend({
  type: z.literal('RUN_ERROR'),
  message: z.string(),
  code: z.string().optional(),
});

// Union of all events
export const AGUIEvent = z.discriminatedUnion('type', [
  TextMessageStart,
  TextMessageContent,
  TextMessageEnd,
  ToolCallStart,
  ToolCallArgs,
  ToolCallEnd,
  StateSnapshot,
  StateDelta,
  CustomEvent,
  RunStarted,
  RunFinished,
  RunError,
]);

export type AGUIEvent = z.infer<typeof AGUIEvent>;

Define the AG-UI Catalog

Create a catalog for UI components that agents can render:

import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';

export const aguiCatalog = defineCatalog(schema, {
  components: {
    Container: {
      description: 'A container for grouping elements',
      props: z.object({
        direction: z.enum(['row', 'column']).optional(),
        gap: z.enum(['none', 'sm', 'md', 'lg']).optional(),
        padding: z.enum(['none', 'sm', 'md', 'lg']).optional(),
      }),
    },
    Card: {
      description: 'A card with optional title',
      props: z.object({
        title: z.string().optional(),
        description: z.string().optional(),
      }),
    },
    Text: {
      description: 'Text content',
      props: z.object({
        content: z.string(),
        variant: z.enum(['body', 'heading', 'caption', 'code']).optional(),
      }),
    },
    Metric: {
      description: 'Displays a metric value',
      props: z.object({
        label: z.string(),
        value: z.union([z.string(), z.number()]),
        change: z.number().optional(),
        format: z.enum(['number', 'currency', 'percent']).optional(),
      }),
    },
    Button: {
      description: 'Interactive button',
      props: z.object({
        label: z.string(),
        variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
        disabled: z.boolean().optional(),
      }),
    },
    Alert: {
      description: 'Alert message',
      props: z.object({
        message: z.string(),
        type: z.enum(['info', 'success', 'warning', 'error']).optional(),
      }),
    },
    // Add more components...
  },

  actions: {
    submit: {
      description: 'Submit form data',
      params: z.object({ formId: z.string() }),
    },
    navigate: {
      description: 'Navigate to a URL',
      params: z.object({ url: z.string() }),
    },
    callback: {
      description: 'Trigger a callback to the agent',
      params: z.object({
        name: z.string(),
        data: z.record(z.unknown()).optional(),
      }),
    },
  },
});

Build an AG-UI Event Processor

Process AG-UI events and render UI components:

'use client';

import React, { useState, useCallback } from 'react';
import { AGUIEvent } from './schema';

interface AGUIState {
  messages: Array<{
    id: string;
    role: 'user' | 'assistant';
    content: string;
  }>;
  toolCalls: Map<string, {
    name: string;
    args: string;
    result?: unknown;
  }>;
  state: Record<string, unknown>;
  isRunning: boolean;
}

export function useAGUI() {
  const [aguiState, setAGUIState] = useState<AGUIState>({
    messages: [],
    toolCalls: new Map(),
    state: {},
    isRunning: false,
  });

  const processEvent = useCallback((event: AGUIEvent) => {
    switch (event.type) {
      case 'RUN_STARTED':
        setAGUIState(prev => ({ ...prev, isRunning: true }));
        break;
      case 'RUN_FINISHED':
        setAGUIState(prev => ({ ...prev, isRunning: false }));
        break;
      case 'TEXT_MESSAGE_START':
        setAGUIState(prev => ({
          ...prev,
          messages: [...prev.messages, {
            id: event.messageId,
            role: event.role,
            content: '',
          }],
        }));
        break;
      case 'TEXT_MESSAGE_CONTENT':
        setAGUIState(prev => ({
          ...prev,
          messages: prev.messages.map(msg =>
            msg.id === event.messageId
              ? { ...msg, content: msg.content + event.delta }
              : msg
          ),
        }));
        break;
      case 'TOOL_CALL_START':
        setAGUIState(prev => {
          const toolCalls = new Map(prev.toolCalls);
          toolCalls.set(event.toolCallId, { name: event.toolCallName, args: '' });
          return { ...prev, toolCalls };
        });
        break;
      case 'TOOL_CALL_ARGS':
        setAGUIState(prev => {
          const toolCalls = new Map(prev.toolCalls);
          const tc = toolCalls.get(event.toolCallId);
          if (tc) {
            toolCalls.set(event.toolCallId, { ...tc, args: tc.args + event.delta });
          }
          return { ...prev, toolCalls };
        });
        break;
      case 'STATE_SNAPSHOT':
        setAGUIState(prev => ({ ...prev, state: event.snapshot }));
        break;
    }
  }, []);

  return { state: aguiState, processEvent };
}

Usage Example

'use client';

import { useAGUI } from './use-agui';
import { renderToolCallUI } from './renderer';

export function AGUIChat() {
  const { state, processEvent } = useAGUI();

  async function startRun(prompt: string) {
    const response = await fetch('/api/agent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prompt }),
    });

    const reader = response.body?.getReader();
    const decoder = new TextDecoder();

    while (reader) {
      const { done, value } = await reader.read();
      if (done) break;

      const lines = decoder.decode(value).split('\n').filter(Boolean);
      for (const line of lines) {
        const event = JSON.parse(line);
        processEvent(event);
      }
    }
  }

  return (
    <div className="space-y-4">
      {state.messages.map(msg => (
        <div key={msg.id} className={`p-3 rounded ${
          msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
        }`}>
          {msg.content}
        </div>
      ))}

      {Array.from(state.toolCalls.values()).map((tc, i) => (
        <div key={i}>{renderToolCallUI(tc)}</div>
      ))}

      <form onSubmit={(e) => {
        e.preventDefault();
        const input = e.currentTarget.querySelector('input');
        if (input?.value) {
          startRun(input.value);
          input.value = '';
        }
      }}>
        <input
          type="text"
          placeholder="Ask the agent..."
          className="w-full px-4 py-2 border rounded"
          disabled={state.isRunning}
        />
      </form>
    </div>
  );
}

Next

Learn about OpenAPI integration for rendering forms from API schemas.