CopilotKit

Advanced

Custom progress rendering, frontend action handlers, and advanced A2UI configuration.


Custom A2UI Progress Renderer#

When using Dynamic Schema A2UI, a secondary LLM generates the UI schema and data. This takes a few seconds — during which CopilotKit shows a built-in progress indicator.

You can replace the built-in indicator with your own component using useRenderTool.

How it works#

The dynamic schema flow calls a tool named render_a2ui under the hood. While the tool call is in progress (status === "inProgress"), your custom renderer is shown. Once the A2UI surface starts rendering (status === "complete"), your component is hidden and the actual surface takes over.

Implementation#

Create a progress component#

src/components/a2ui-progress.tsx
"use client";

import { memo } from "react";

interface A2UIProgressProps {
  parameters: Record<string, unknown>;
}

export const A2UIProgress = memo(function A2UIProgress({
  parameters,
}: A2UIProgressProps) {
  // You can inspect `parameters` to show partial progress.
  // As the LLM streams, `parameters.components` and `parameters.items`
  // will progressively populate.
  const componentCount = Array.isArray(parameters?.components)
    ? parameters.components.length
    : 0;
  const itemCount = Array.isArray(parameters?.items)
    ? parameters.items.length
    : 0;

  return (
    <div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
      <div className="flex items-center gap-2 text-sm text-gray-600">
        <div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" />
        <span>Building interface...</span>
      </div>
      {componentCount > 0 && (
        <p className="mt-2 text-xs text-gray-500">
          {componentCount} components, {itemCount} items
        </p>
      )}
    </div>
  );
});

Register the renderer#

Use useRenderTool to intercept the render_a2ui tool call and show your component while it's in progress:

src/hooks/use-a2ui-progress.tsx
"use client";

import { useRenderTool } from "@copilotkit/react-core/v2";
import { z } from "zod";
import { A2UIProgress } from "@/components/a2ui-progress";

export function useA2UIProgress() {
  useRenderTool(
    {
      name: "render_a2ui",
      parameters: z.any(),
      render: ({ status, parameters }) => {
        // Hide when complete — the A2UI surface renderer takes over
        if (status === "complete") return <></>;
        return <A2UIProgress parameters={parameters ?? {}} />;
      },
    },
    [],
  );
}

Call the hook in your page#

src/app/page.tsx
"use client";

import { useA2UIProgress } from "@/hooks/use-a2ui-progress";

function Chat() {
  useA2UIProgress();

  return <CopilotChat className="flex-1" />;
}

The parameters object updates progressively as the LLM streams the render_a2ui tool call. You can use this to show a skeleton that fills in as components and data arrive.

What's in parameters?#

As the secondary LLM generates the A2UI surface, the parameters object accumulates:

FieldTypeDescription
surfaceIdstringUnique ID for this surface
componentsarrayThe component tree (schema) — arrives first
rootstringRoot component ID
itemsarrayData items — arrive after the schema
actionHandlersobjectOptional button action handlers

A common pattern is to show a skeleton layout once components has data, then show item count as items streams in.

Action Handlers#

Each A2UI approach has its own way to declare action handlers on the agent side — see the individual guides for Fixed Schema, Streaming, and Dynamic Schema.

This section covers the frontend-side APIs for custom handling, the resolution chain, and the schema button format.

Schema button with action context#

The A2UI schema defines buttons with action names and data-bound context fields. When the button is clicked, the context values are resolved from the data model for that specific item:

{
  "Button": {
    "label": "Book",
    "action": {
      "name": "book_flight",
      "context": [
        { "key": "flightNumber", "value": { "path": "/flightNumber" } },
        { "key": "price", "value": { "path": "/price" } }
      ]
    }
  }
}

The resulting A2UIUserAction will include the resolved context:

{
  name: "book_flight",
  surfaceId: "flight-search-results",
  sourceComponentId: "button-123",
  context: { flightNumber: "AA100", price: "$350" },
  dataContextPath: "/flights/0",
}

useA2UIActionHandler hook (frontend)#

For custom frontend logic, register a handler with useA2UIActionHandler. Your handler receives every action and the pre-declared ops (if any), and decides whether to handle it:

import { useA2UIActionHandler } from "@copilotkit/react-core/v2";

// Handle a specific action with custom ops
useA2UIActionHandler((action, declaredOps) => {
  if (action.name === "book_flight") {
    // Return custom operations
    return [
      { createSurface: { surfaceId: action.surfaceId, catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json" } },
      { updateComponents: { surfaceId: action.surfaceId, components: mySchema } },
    ];
  }
  return null; // skip — let other handlers or fallback try
});

// Delegate to pre-declared ops from the agent
useA2UIActionHandler((action, declaredOps) => {
  if (action.name === "book_flight") return declaredOps;
  return null;
});

// Handle all actions on a specific surface
useA2UIActionHandler((action, declaredOps) => {
  if (action.surfaceId === "my-surface") return declaredOps;
  return null;
});

Resolution order#

The default orchestrator resolves actions in this order:

  1. Hook handlers — registered via useA2UIActionHandler. Each receives (action, declaredOps). The first handler that returns a non-empty operations array wins.
  2. Pre-declared ops — if no hook handles the action, falls back to the agent's action_handlers (exact name match first, then "*" catch-all).

This means pre-declared ops work out of the box, and hooks can override or extend them when needed.

Custom orchestrator (full control)#

To completely replace the resolution logic, pass a custom onAction to createA2UIMessageRenderer:

import {
  createA2UIMessageRenderer,
  resolveDeclaredOps,
} from "@copilotkit/react-core/v2";

const activityRenderers = [
  createA2UIMessageRenderer({
    theme,
    onAction: (action, handlers, declaredHandlers) => {
      // Custom dispatch logic — you control everything
      const declaredOps = resolveDeclaredOps(action, declaredHandlers);

      // Example: always use declared ops, ignore hooks
      return declaredOps;
    },
  }),
];

Types reference#

TypeDescription
A2UIUserActionDispatched action: { name, surfaceId, sourceComponentId, context?, dataContextPath? }
A2UIOpsArray<Record<string, unknown>> — array of A2UI operations
A2UIDeclaredOpsA2UIOps | null — resolved pre-declared ops, or null if no match
A2UIActionHandler(action: A2UIUserAction, declaredOps: A2UIDeclaredOps) => A2UIOps | null
A2UIActionOrchestrator(action, handlers, declaredHandlers) => A2UIOps | null — full control
resolveDeclaredOpsHelper: resolves exact match or "*" catch-all from declared handlers map
defaultActionOrchestratorThe built-in orchestrator — hooks first, then declared fallback