4d4bd19
CopilotKitDocs
  • Docs
  • Integrations
  • Reference
  • Free Developer Access
Get Started
QuickstartCoding Agents
Concepts
ArchitectureGenerative UI OverviewOSS vs Enterprise
Agentic Protocols
OverviewAG-UIAG-UI MiddlewareMCPA2A
Build Chat UIs
Prebuilt Components
CopilotChatCopilotSidebarCopilotPopup
Custom Look and Feel
CSS CustomizationSlots (Subcomponents)Fully Headless UIReasoning Messages
Multimodal AttachmentsVoice
Build Generative UI
Controlled
Tool-based Generative UITool RenderingState RenderingReasoning
Your Components
Display ComponentsInteractive Components
Declarative
A2UIDynamic Schema A2UIFixed Schema A2UI
Open-Ended
MCP Apps
Adding Agent Powers
Frontend ToolsShared State
Human-in-the-Loop
HITL OverviewPausing the Agent for InputHeadless Interrupts
Sub-AgentsAgent ConfigProgrammatic Control
Agents & Backends
Built-in Agent
Backend
Copilot RuntimeFactory ModeAG-UI
Runtime Server AdapterAuthentication
Built-in Agent (TanStack AI)
Advanced ConfigurationMCP ServersModel SelectionServer Tools
Observe & Operate
InspectorVS Code Extension
Troubleshooting
Common Copilot IssuesError Debugging & ObservabilityDebug ModeAG-UI Event InspectorHook ExplorerError Observability Connectors
Enterprise
CopilotKit PremiumHow the Enterprise Intelligence Platform WorksHow Threads & Persistence WorkObservabilitySelf-Hosting IntelligenceThreads
Deploy
AWS AgentCore
What's New
Full MCP Apps SupportLangGraph Deep Agents in CopilotKitA2UI Launches with full AG-UI SupportCopilotKit v1.50Generative UI Spec SupportA2A and MCP Handshake
Migrate
Migrate to V2Migrate to 1.8.2
Other
Contributing
Code ContributionsDocumentation Contributions
Anonymous Telemetry
Built-in Agent (TanStack AI)Headless UI

Headless UI

Build fully custom chat interfaces with complete rendering control via hooks.

Full rendering control via hooks#

CopilotKit's headless hooks give you complete control over the chat experience: you compose messages, streaming, and tool-call surfaces yourself with zero UI opinions. Bring your own design system and render everything your way.

There are two live cells on this page. Start with Minimal for the smallest possible custom chat on useAgent + useCopilotKit, then jump to Complete to see the full generative-UI composition (tool calls, reasoning, activity messages, custom before/after slots) rebuilt by hand from the low-level hooks.

When should I use this?#

Use headless UI when you want to:

  • Build a completely custom chat interface with your own design system
  • Integrate agent chat into existing UI patterns
  • Have full control over message rendering and interaction
  • Drop generative UI primitives (useRenderToolCall, useRenderActivityMessage, useRenderCustomMessages) into a layout that isn't a chat at all

Minimal (headless-simple)#

The bare minimum: three hooks do the heavy lifting.

  • useAgent({ agentId }) exposes the current conversation (messages, isRunning) and the run-state object.
  • useCopilotKit() returns the runtime handle you call runAgent({ agent }) on (the same entry point <CopilotChat /> uses internally).
  • useComponent(...) (sugar over useFrontendTool) lets you register a React component the agent can render by invoking a named tool call. useRenderToolCall() then returns a function that paints any tool call inline.
frontend/src/app/page.tsx — useAgent + useCopilotKit + useComponent
L3–51
import React, { useState } from "react";
import {
  CopilotKitProvider,
  useAgent,
  useComponent,
  useCopilotKit,
  useRenderToolCall,
} from "@copilotkit/react-core/v2";
import { z } from "zod";

export default function HeadlessSimpleDemo() {
  return (
    <CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
      <div className="flex justify-center items-start min-h-screen w-full p-6 bg-gray-50">
        <div className="w-full max-w-4xl">
          <HeadlessChat />
        </div>
      </div>
    </CopilotKitProvider>
  );
}

function ShowCard({ title, body }: { title: string; body: string }) {
  return (
    <div className="my-2 rounded-lg border border-gray-300 bg-white p-4 shadow-sm">
      <div className="font-semibold text-gray-900">{title}</div>
      <div className="mt-1 text-sm text-gray-700 whitespace-pre-wrap">
        {body}
      </div>
    </div>
  );
}

function HeadlessChat() {
  const { agent } = useAgent({ agentId: "default" });
  const { copilotkit } = useCopilotKit();
  const [input, setInput] = useState("");

  useComponent({
    name: "show_card",
    description: "Display a titled card with a short body of text.",
    parameters: z.object({
      title: z.string().describe("Short heading for the card."),
      body: z.string().describe("Body text for the card."),
    }),
    render: ShowCard,
  });

  const renderToolCall = useRenderToolCall();

The message list is a plain .map() over agent.messages: user messages render as right-aligned bubbles, assistant messages render any streamed text plus inline tool calls via renderToolCall({ toolCall }):

frontend/src/app/page.tsx — message list
L83–122
        {agent.messages.length === 0 && (
          <div className="text-sm text-gray-400">No messages yet. Say hi!</div>
        )}
        {agent.messages.map((m) => {
          if (m.role === "user") {
            return (
              <div
                key={m.id}
                data-message-role="user"
                className="self-end rounded-lg bg-blue-600 px-3 py-2 text-white max-w-[80%]"
              >
                {typeof m.content === "string" ? m.content : ""}
              </div>
            );
          }
          if (m.role === "assistant") {
            const toolCalls =
              "toolCalls" in m && Array.isArray(m.toolCalls) ? m.toolCalls : [];
            return (
              <div
                key={m.id}
                data-message-role="assistant"
                className="self-start max-w-[90%]"
              >
                {m.content && (
                  <div className="rounded-lg bg-gray-100 px-3 py-2 text-gray-900">
                    {m.content}
                  </div>
                )}
                {toolCalls.map((tc) => (
                  <div key={tc.id}>{renderToolCall({ toolCall: tc })}</div>
                ))}
              </div>
            );
          }
          return null;
        })}
        {agent.isRunning && (
          <div className="text-xs text-gray-400">Agent is thinking...</div>
        )}

That's it: no <CopilotChat />, no <CopilotChatMessageView>, no slots. The downside: you only get text + tool calls. Reasoning messages, activity messages (A2UI, MCP Apps), and custom before/after slots won't show up unless you wire them in yourself, which is exactly what the next section covers.

Complete (headless-complete)#

This is the heart of the page. The headless-complete cell rebuilds the full generative-UI weave (text, tool calls via useRenderTool / useDefaultRenderTool / useComponent / useFrontendTool, reasoning cards, A2UI + MCP Apps activity messages, and custom before/after message slots) from the low-level hooks directly, without importing <CopilotChatMessageView> or <CopilotChatAssistantMessage>.

The useRenderedMessages hook#

The cell's central piece is a hand-rolled useRenderedMessages(messages, isRunning) that returns the same flat list of messages, each augmented with a renderedContent: ReactNode field. This hook is a manual recreation of what <CopilotChatMessageView> does; compare it line-for-line against the renderMessageBlock helper inside the canonical primitive: packages/react-core/src/v2/components/chat/CopilotChatMessageView.tsx:542-612.

frontend/src/app/use-rendered-messages.tsx — composition hook
L3–57
import React, { useMemo } from "react";
import type {
  Message,
  AssistantMessage,
  UserMessage,
  ReasoningMessage,
  ActivityMessage,
  ToolMessage,
} from "@ag-ui/core";
import {
  CopilotChatReasoningMessage,
  useRenderToolCall,
  useRenderActivityMessage,
  useRenderCustomMessages,
} from "@copilotkit/react-core/v2";

/**
 * Manual per-message composition for the TRULY headless chat cell.
 *
 * Mirrors `renderMessageBlock` inside CopilotChatMessageView in the canonical
 * primitive. The point of this cell is to demonstrate that the FULL
 * generative-UI weave (assistant text + tool-call renders + reasoning +
 * activity + custom before / after slots) can be re-composed from the
 * low-level hooks directly.
 */
export type RenderedMessage = Message & { renderedContent: React.ReactNode };

export function useRenderedMessages(
  messages: Message[],
  isRunning: boolean,
): RenderedMessage[] {
  const renderToolCall = useRenderToolCall();
  const { renderActivityMessage } = useRenderActivityMessage();
  const renderCustomMessage = useRenderCustomMessages();

  return useMemo(() => {
    return messages.map((message): RenderedMessage => {
      const renderedContent = renderMessageContent({
        message,
        messages,
        isRunning,
        renderToolCall,
        renderActivityMessage,
        renderCustomMessage,
      });
      return { ...message, renderedContent } as RenderedMessage;
    });
  }, [
    messages,
    isRunning,
    renderToolCall,
    renderActivityMessage,
    renderCustomMessage,
  ]);
}

Three low-level hooks feed it:

  • useRenderToolCall() — returns the renderer for any registered tool call (per-tool via useRenderTool / useComponent, plus the wildcard from useDefaultRenderTool).
  • useRenderActivityMessage() — renders A2UI + MCP Apps activity messages for the current agent scope.
  • useRenderCustomMessages() — invokes renderCustomMessage hooks registered against the active CopilotChatConfigurationProvider, emitting "before" and "after" slots around every message.

Per-role dispatch#

Inside renderMessageContent the role-switch mirrors CopilotChatMessageView's renderMessageBlock exactly: assistant bodies get text + tool calls, user bodies get their text content, reasoning messages go through the <CopilotChatReasoningMessage> leaf component, and activity messages route through renderActivityMessage:

frontend/src/app/use-rendered-messages.tsx — per-role dispatch
L91–109
  if (message.role === "assistant") {
    body = renderAssistantBody({
      message: message as AssistantMessage,
      messages,
      renderToolCall,
    });
  } else if (message.role === "user") {
    body = renderUserBody(message as UserMessage);
  } else if (message.role === "reasoning") {
    body = (
      <CopilotChatReasoningMessage
        message={message as ReasoningMessage}
        messages={messages}
        isRunning={isRunning}
      />
    );
  } else if (message.role === "activity") {
    body = renderActivityMessage(message as ActivityMessage);
  }

Tool-call composition#

For each toolCall on an assistant message, we look up the sibling tool-role message (keyed by toolCallId) and hand both to renderToolCall. This mirrors CopilotChatToolCallsView exactly:

frontend/src/app/use-rendered-messages.tsx — assistant + tool calls
L123–148
function renderAssistantBody(args: {
  message: AssistantMessage;
  messages: Message[];
  renderToolCall: ReturnType<typeof useRenderToolCall>;
}): React.ReactNode {
  const { message, messages, renderToolCall } = args;
  const text = message.content ?? "";
  const hasText = text.trim().length > 0;
  const toolCalls = message.toolCalls ?? [];

  return (
    <>
      {hasText && <div className="whitespace-pre-wrap break-words">{text}</div>}
      {toolCalls.map((toolCall) => {
        const toolMessage = messages.find(
          (m) => m.role === "tool" && m.toolCallId === toolCall.id,
        ) as ToolMessage | undefined;
        return (
          <React.Fragment key={toolCall.id}>
            {renderToolCall({ toolCall, toolMessage })}
          </React.Fragment>
        );
      })}
    </>
  );
}

Bubble chrome#

The UserBubble and AssistantBubble components are pure chrome: they receive the pre-rendered node from useRenderedMessages and drop it into a styled container. No chat primitives are imported here:

frontend/src/app/{user,assistant}-bubble.tsx — pure chrome
L8–20
export function AssistantBubble({ children }: { children: React.ReactNode }) {
  if (isEmpty(children)) return null;

  return (
    <div className="flex justify-start">
      <div className="max-w-[85%] flex flex-col gap-2">
        <div className="rounded-2xl rounded-bl-sm bg-[#F0F0F4] text-[#010507] px-4 py-2 text-sm">
          {children}
        </div>
      </div>
    </div>
  );
}

export function UserBubble({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex justify-end">
      <div className="max-w-[75%] rounded-2xl rounded-br-sm bg-[#010507] text-white px-4 py-2 text-sm whitespace-pre-wrap break-words">
        {children}
      </div>
    </div>
  );
}