Headless

Headless Copilot

Build fully custom chat UIs using raw SDK primitives — no built-in components required

The Copilot SDK ships two layers:

LayerWhat it isWhen to use
UI layer<CopilotChat>, <CopilotProvider>, built-in componentsGet up and running fast
Headless layerRaw hooks, stream events, per-message stateBuild 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 arrives
  • useMessageMeta — 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 anywhere

Getting started

Install the SDK if you haven't already:

npm install @yourgpt/copilot-sdk

Wrap 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

EventWhen it firesKey fields
message:startNew assistant message beginsid
message:deltaText token arrivescontent, messageId
message:endMessage turn completemessageId
thinking:deltaThinking/reasoning tokencontent, messageId
action:startServer tool beginsid, name, messageId
action:argsTool args streamedid, args, messageId
action:endServer tool completesid, name, result, messageId
tool:statusClient tool status changeid, name, status, messageId
tool:resultClient tool resultid, name, result, messageId
source:addKnowledge base source citedsource, messageId
loop:iterationAgent loop stepiteration, maxIterations, messageId
loop:completeAgent loop finishediterations, maxIterationsReached, messageId
*Every event(all fields)

Next steps

On this page