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 do the heavy lifting — they're the same primitives <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 small — a hand-rolled message list and composer built from useAgent + useCopilotKit:
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 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>
)}No <CopilotChat />, no slots. The trade-off: you only get text + 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 — text, tool calls, reasoning cards, A2UI + MCP Apps activity messages, custom before/after message slots — from the low-level hooks directly, without importing <CopilotChatMessageView>.
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:
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
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, 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) => {
// 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>
);
}Next steps
- Slots — less work than going fully headless, often enough.
- CSS customization — when you just need to re-skin the defaults.