---
title: "Fully Headless UI"
description: "Build any UI — chat or not — on top of the CopilotKit primitives with zero UI opinions."
icon: "lucide/Settings"
hideTOC: true
snippet_cell: headless-complete
---

## 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](/custom-look-and-feel/slots) 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.

<InlineDemo demo="headless-complete" />

## 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`:

<Snippet cell="headless-simple" region="use-agent-simple" title="frontend/src/app/page.tsx — useAgent + useCopilotKit" />

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 })`:

<Snippet cell="headless-simple" region="message-list-simple" title="frontend/src/app/page.tsx — message list" />

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**:

<Snippet region="use-rendered-messages-hook" title="frontend/src/app/use-rendered-messages.tsx — composition hook" />

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`:

<Snippet region="manual-activity-message-rendering" title="frontend/src/app/use-rendered-messages.tsx — per-role dispatch" />

### 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`:

<Snippet region="manual-tool-call-rendering" title="frontend/src/app/use-rendered-messages.tsx — assistant + tool calls" />

### 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:

<Snippet region="custom-bubbles" title="frontend/src/app/{user,assistant}-bubble.tsx — pure chrome" />

## Next steps

- [Slots](/custom-look-and-feel/slots) — less work than going fully headless, often enough.
- [CSS customization](/custom-look-and-feel/css) — when you just need to re-skin the defaults.
