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 callrunAgent({ agent })on — the same entry point<CopilotChat />uses internally.useComponent(...)(sugar overuseFrontendTool) 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.
const { agent } = useAgent({ agentId: "headless-simple" });
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 }):
{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}
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} 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
(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.
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;
});
// `renderToolCall`, `renderActivityMessage`, and `renderCustomMessage` are
// callbacks produced by their respective hooks; their identity turns over
// whenever the underlying registries / agent / config change, which is
// exactly when we want to recompute.
}, [
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
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:
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:
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) => {
// Tool result lives on a sibling `tool`-role message keyed by toolCallId.
// Mirrors CopilotChatToolCallsView (react-core/v2/components/chat/CopilotChatToolCallsView.tsx).
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>
);
}