Headless Copilot

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

The SDK ships two layers. The headless layer lets you build your own UI from scratch using two low-level primitives — no built-in components involved.

LayerWhat it isWhen to use
UI layer<CopilotChat>, built-in componentsGet running fast
Headless layerRaw hooks + stream eventsBuild your own UI

Primitives

useCopilotEvent

Subscribe to raw stream chunks as they flow through the pipeline.

import { useCopilotEvent } from '@yourgpt/copilot-sdk/react'

function useCopilotEvent<T extends StreamChunkType | '*'>(
  eventType: T,
  handler: (chunk: ChunkOfType<T>) => void
): void

The handler is always called with its latest version via a ref — no useCallback needed. Resubscribes only when eventType changes.

Event types

EventWhenKey fields
message:startNew assistant message beginsid
message:deltaText token arrivescontent, messageId
message:endMessage turn completemessageId
thinking:deltaReasoning 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 source citedsource, messageId
loop:iterationAgent loop stepiteration, maxIterations, messageId
loop:completeAgent loop finishediterations, maxIterationsReached, messageId
*Every eventall fields

useMessageMeta

A reactive per-message key-value store. Attach any shape of data to a message ID — every component reading that ID re-renders on write.

import { useMessageMeta } from '@yourgpt/copilot-sdk/react'

function useMessageMeta<T extends Record<string, unknown>>(
  messageId: string | undefined
): {
  meta: T
  setMeta: (meta: T) => void
  updateMeta: (updater: (prev: T) => T) => void
}

Passing undefined as messageId returns a no-op instance — safe for conditional use. meta is always an object, never null.


Usage pattern

Write metadata from event handlers, read it in message components:

// 1. Write during streaming — use messageMeta store directly from useCopilot()
const { messageMeta } = useCopilot()

useCopilotEvent('thinking:delta', (e) => {
  if (!e.messageId) return
  messageMeta.updateMeta(e.messageId, prev => ({
    ...prev,
    thinking: ((prev.thinking as string) ?? '') + e.content,
  }))
})

useCopilotEvent('action:start', (e) => {
  if (!e.messageId) return
  messageMeta.updateMeta(e.messageId, prev => ({
    ...prev,
    activeTools: { ...(prev.activeTools as object ?? {}), [e.name]: 'running' }
  }))
})

// 2. Read in your message component
interface MyMeta {
  thinking?: string
  activeTools?: Record<string, 'running' | 'done'>
}

function Message({ message }) {
  const { meta } = useMessageMeta<MyMeta>(message.id)

  return (
    <div>
      {meta.thinking && <div className="thinking">{meta.thinking}</div>}
      {Object.entries(meta.activeTools ?? {})
        .filter(([, v]) => v === 'running')
        .map(([name]) => <span key={name}>⚙ {name}</span>)
      }
      <p>{message.content}</p>
    </div>
  )
}

Use messageMeta from useCopilot() inside event handlers (where React hooks can't be called). Use useMessageMeta(id) inside components for reactive reads.


Direct store access

For non-component code, access the store imperatively:

const { messageMeta } = useCopilot()

messageMeta.getMeta(messageId)
messageMeta.setMeta(messageId, { key: 'value' })
messageMeta.updateMeta(messageId, prev => ({ ...prev, count: (prev.count as number ?? 0) + 1 }))

Notes

  • Metadata is in-memory only — resets when the provider unmounts. Sync to your own storage in event handlers if you need persistence.
  • Multiple components can read the same messageId — all re-render on any write.
  • Handlers in useCopilotEvent run synchronously during streaming — keep them fast.

Attachment Primitives

Build custom file upload UIs with the useAttachments hook and companion components.

useAttachments

Core hook for file upload management — handles validation, upload progress, cancellation, retry, and drag-drop.

import { useAttachments, AttachmentStrip, DropZoneOverlay } from "@yourgpt/copilot-sdk/ui";

function MyCustomInput() {
  const { send } = useCopilotChatContext();

  const {
    attachments,           // PendingAttachment[] — current files
    addFiles,              // (files: FileList | File[]) => void
    removeAttachment,      // (id: string) => void
    cancelUpload,          // (id: string) => void
    retryUpload,           // (id: string) => void
    clearAll,              // () => void
    getReadyAttachments,   // () => MessageAttachment[]
    hasAttachments,        // boolean
    isUploading,           // boolean
    canSend,               // boolean (has ready files, none uploading)
    dragHandlers,          // spread on container for drag-drop
    isDragging,            // boolean
    openFilePicker,        // () => void
    fileInputRef,          // ref for hidden <input type="file">
    onFileInputChange,     // onChange handler for file input
  } = useAttachments({
    // Server upload URL (string), URL + options (object), or custom handler (function)
    upload: "/api/copilot/upload",
    maxFiles: 5,                                    // default: 5
    maxFileSize: 10 * 1024 * 1024,                  // default: 10MB
    allowedFileTypes: ["image/*", "application/pdf"], // default
  });

  const handleSend = (text: string) => {
    const files = getReadyAttachments();
    send(text, files.length ? files : undefined);
    clearAll();
  };

  return (
    <div {...dragHandlers} className="relative">
      <DropZoneOverlay isDragging={isDragging} />
      <AttachmentStrip
        attachments={attachments}
        onRemove={removeAttachment}
        onRetry={retryUpload}
      />
      <textarea ... />
      <button onClick={openFilePicker}>Attach</button>
      <input ref={fileInputRef} type="file" hidden multiple onChange={onFileInputChange} />
      <button onClick={() => handleSend(text)}>Send</button>
    </div>
  );
}

Config

OptionTypeDefaultDescription
uploadstring | object | functionUpload handler (see Attachments)
maxFilesnumber5Maximum concurrent files
maxFileSizenumber10MBMax file size in bytes
allowedFileTypesstring[]["image/*", "application/pdf", ...]Allowed MIME types

PendingAttachment

Each item in the attachments array:

{
  id: string;
  file: File;
  preview?: string;       // object URL for image thumbnails
  status: "uploading" | "ready" | "error";
  progress: number;       // 0-100
  error?: string;
  attachment?: MessageAttachment; // final result when ready
}

AttachmentStrip

Compact horizontal strip showing pending attachments. Each file shows a thumbnail (images) or type icon, filename, upload progress bar, and remove/retry button.

<AttachmentStrip
  attachments={attachments}
  onRemove={removeAttachment}
  onRetry={retryUpload}
  className="mb-2"
/>

DropZoneOverlay

Overlay that appears when files are dragged over the container. Spread dragHandlers from useAttachments on the parent element.

<div {...dragHandlers} className="relative">
  <DropZoneOverlay isDragging={isDragging} />
  {/* your input */}
</div>

On this page