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/useRenderActivityMessageon their own. - You want to render generative UI primitives outside of a chat entirely.
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 callrunAgent({ 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:
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 }):
{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:
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 viauseRenderTool/useComponent, plus the wildcard fromuseDefaultRenderTool).useRenderActivityMessage()— renders A2UI + MCP Apps activity messages for the current agent scope.useRenderCustomMessages()— invokesrenderCustomMessagehooks registered against the activeCopilotChatConfigurationProvider, 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:
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:
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:
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.
