Headless Copilot
Build fully custom chat UIs using raw SDK primitives — no built-in components required
The Copilot SDK ships two layers:
| Layer | What it is | When to use |
|---|---|---|
| UI layer | <CopilotChat>, <CopilotProvider>, built-in components | Get up and running fast |
| Headless layer | Raw hooks, stream events, per-message state | Build your own UI from scratch |
The headless layer gives you full control — your own message bubbles, your own tool indicators, your own thinking step visualiser, your own artifact previews — without forking or overriding SDK internals.
Philosophy
The headless API follows a primitives, not patterns approach. Rather than shipping opinionated hooks like useThinkingSteps() that bake in a specific data shape, the SDK exposes two low-level primitives that let you compose anything:
useCopilotEvent— subscribe to every raw stream chunk as it arrivesuseMessageMeta— a reactive per-message key-value store you shape yourself
With just these two, you can build thinking step trackers, artifact stores, tool progress badges, plan approval flows, clarifying question UIs — entirely in your own code, with your own types.
Architecture
CopilotProvider
├── sends messages → runtime API
├── streams chunks → fires onStreamChunk for each
│ message:delta, thinking:delta, tool:status,
│ action:start/end, loop:iteration, loop:complete …
│
├── useCopilotEvent('thinking:delta', handler)
│ └── your handler runs for each thinking chunk
│
└── useMessageMeta(messageId)
└── reactive store — write anything, read anywhereGetting started
Install the SDK if you haven't already:
npm install @yourgpt/copilot-sdkWrap your app with CopilotProvider as normal — the headless hooks work inside any component under the provider:
import { CopilotProvider } from '@yourgpt/copilot-sdk/react'
export default function App() {
return (
<CopilotProvider runtimeUrl="/api/copilot">
<YourCustomChatUI />
</CopilotProvider>
)
}Then use useCopilotEvent and useMessageMeta anywhere inside to build whatever you need.
Full example — custom streaming chat
A complete headless chat UI using only SDK primitives:
import {
useCopilot,
useCopilotEvent,
useMessageMeta,
} from '@yourgpt/copilot-sdk/react'
// ── Message component ─────────────────────────────────────────────
interface MyMeta {
thinkingText?: string
toolsRunning?: string[]
}
function Message({ message }) {
// Read custom metadata we wrote during streaming
const { meta } = useMessageMeta<MyMeta>(message.id)
return (
<div className={`message ${message.role}`}>
{/* Thinking indicator */}
{meta.thinkingText && (
<div className="thinking">{meta.thinkingText}</div>
)}
{/* Active tool badges */}
{meta.toolsRunning?.map(name => (
<span key={name} className="tool-badge">⚙ {name}</span>
))}
{/* Message content */}
<p>{message.content}</p>
</div>
)
}
// ── Chat component ────────────────────────────────────────────────
function MyChat() {
const { messages, sendMessage, status } = useCopilot()
const [input, setInput] = useState('')
// Track which message is currently streaming
const activeMessageId = useRef<string | null>(null)
// Capture message start
useCopilotEvent('message:start', (e) => {
activeMessageId.current = e.id
})
// Build thinking text per message
const { updateMeta: updateActiveMeta } = useMessageMeta(activeMessageId.current ?? undefined)
useCopilotEvent('thinking:delta', (e) => {
useMessageMeta — see pattern below for per-message writes
})
// Track tool execution
useCopilotEvent('action:start', (e) => {
if (!e.messageId) return
// write to the message's meta store via a child component or ref pattern
})
return (
<div>
{messages.map(m => <Message key={m.id} message={m} />)}
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={() => sendMessage(input)}>Send</button>
</div>
)
}For writing metadata from event handlers that fire before a component mounts,
use the messageMeta store directly from useCopilot():
const { messageMeta } = useCopilot()
useCopilotEvent('thinking:delta', (e) => {
messageMeta.updateMeta(e.messageId!, prev => ({
...prev,
thinkingText: (prev.thinkingText ?? '') + e.content
}))
})Available stream events
| Event | When it fires | Key fields |
|---|---|---|
message:start | New assistant message begins | id |
message:delta | Text token arrives | content, messageId |
message:end | Message turn complete | messageId |
thinking:delta | Thinking/reasoning token | content, messageId |
action:start | Server tool begins | id, name, messageId |
action:args | Tool args streamed | id, args, messageId |
action:end | Server tool completes | id, name, result, messageId |
tool:status | Client tool status change | id, name, status, messageId |
tool:result | Client tool result | id, name, result, messageId |
source:add | Knowledge base source cited | source, messageId |
loop:iteration | Agent loop step | iteration, maxIterations, messageId |
loop:complete | Agent loop finished | iterations, maxIterationsReached, messageId |
* | Every event | (all fields) |
Next steps
useCopilotEvent— full API reference and recipesuseMessageMeta— full API reference and recipes- Custom Message View — intercept rendering inside
<CopilotChat> - Chat Primitives — lower-level layout components