7acadae
CopilotKitDocs
  • Docs
  • Integrations
  • Reference
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)Custom Look and FeelFully Headless UI

Fully Headless UI

Build any UI — chat or not — on top of the CopilotKit primitives with zero UI opinions.

What is this?#

A headless UI gives you full control over the chat experience. You bring your own components, layout, and styling while CopilotKit handles agent communication, message management, tool-call rendering, and streaming. No <CopilotChat>, no slot overrides, just your components composed on top of the low-level hooks.

When should I use this?#

Use headless UI when:

  • The slot system isn't enough: you need a completely different layout.
  • You're embedding chat into an existing UI with its own patterns.
  • You're building a non-chat surface that still talks to an agent (a dashboard, a canvas, an inspector) and want useRenderToolCall / useRenderActivityMessage on their own.
  • You want to render generative UI primitives outside of a chat entirely.
Live Demo: Built-in Agent (TanStack AI) — headless-completeOpen full demo →

The core hooks#

Three hooks power it, and they're the same ones <CopilotChat> uses internally.

  • useAgent({ agentId }) — exposes the current conversation (messages, isRunning) and the run-state object.
  • useCopilotKit() — returns the runtime handle you call runAgent({ agent }) on.
  • useRenderToolCall() — returns a function that paints any registered tool call inline.

Minimal example#

Start with a hand-rolled message list and composer built from useAgent + useCopilotKit:

frontend/src/app/page.tsx — useAgent + useCopilotKit
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 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>
        )}

No <CopilotChat />, no slots. The trade-off: you only get text and tool calls. Reasoning messages, activity messages, and custom before/after slots won't show up unless you wire them in yourself, which is exactly what the complete example covers.

Complete example#

The headless-complete cell rebuilds the full generative-UI composition from the low-level hooks directly, without importing <CopilotChatMessageView>: text, tool calls, reasoning cards, A2UI + MCP Apps activity messages, and custom before/after message slots.

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:

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#

The role-switch mirrors CopilotChatMessageView's renderMessageBlock exactly: assistant bodies get text and tool calls, user bodies get their text content, reasoning messages go through the <CopilotChatReasoningMessage> leaf, 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:

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>
  );
}

Next steps#

  • Slots — less work than going fully headless, often enough.
  • CSS customization — when you just need to re-skin the defaults.