Fully Headless UI
Build a completely custom chat interface from scratch using useAgent and useCopilotKit
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, and streaming. This is built on top of the same primitives (useAgent and useCopilotKit) covered in Programmatic Control.
When should I use this?#
Use headless UI when the slot system isn't enough — for example, when you need a completely different layout, want to embed the chat into an existing UI, or are building a non-chat interface that still communicates with an agent.
Implementation#
Access the agent and CopilotKit#
Use useAgent to get the agent instance (messages, state, execution status) and useCopilotKit to run the agent.
export function CustomChat() {
const { agent } = useAgent();
const { copilotkit } = useCopilotKit();
return <div>{/* Your custom UI */}</div>;
}Display messages#
The agent's messages are available via agent.messages. Each message has an id, role ("user" or "assistant"), and content.
export function CustomChat() {
const { agent } = useAgent();
const { copilotkit } = useCopilotKit();
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{agent.messages.map((msg) => (
<div
key={msg.id}
className={
msg.role === "user"
? "ml-auto bg-blue-100 rounded-lg p-3 max-w-md"
: "bg-gray-100 rounded-lg p-3 max-w-md"
}
>
<p className="text-sm font-medium">{msg.role}</p>
<p>{msg.content}</p>
</div>
))}
{agent.isRunning && <div className="text-gray-400">Thinking...</div>}
</div>
</div>
);
}Send messages and run the agent#
Add a message to the agent's conversation, then call copilotkit.runAgent() to trigger execution. This is the same method CopilotKit's built-in <CopilotChat /> uses internally.
export function CustomChat() {
const { agent } = useAgent();
const { copilotkit } = useCopilotKit();
const [input, setInput] = useState("");
const sendMessage = useCallback(async () => {
if (!input.trim()) return;
agent.addMessage({
id: randomUUID(),
role: "user",
content: input,
});
setInput("");
await copilotkit.runAgent({ agent });
}, [input, agent, copilotkit]);
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{agent.messages.map((msg) => (
<div
key={msg.id}
className={
msg.role === "user"
? "ml-auto bg-blue-100 rounded-lg p-3 max-w-md"
: "bg-gray-100 rounded-lg p-3 max-w-md"
}
>
<p>{msg.content}</p>
</div>
))}
{agent.isRunning && <div className="text-gray-400">Thinking...</div>}
</div>
<form
className="border-t p-4 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
sendMessage();
}}
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 border rounded-lg px-3 py-2"
/>
<button type="submit" disabled={agent.isRunning}>
Send
</button>
</form>
</div>
);
}Stop the agent#
Use copilotkit.stopAgent() to cancel a running agent:
const stopAgent = useCallback(() => {
copilotkit.stopAgent({ agent });
}, [agent, copilotkit]);
// In your JSX:
{
agent.isRunning && (
<button onClick={stopAgent} className="text-red-500">
Stop
</button>
);
}Subscribe to agent events#
Use agent.subscribe() to listen for lifecycle events — useful for showing progress indicators, handling errors, or responding to custom events like LangGraph interrupts.
export function CustomChat() {
const { agent } = useAgent();
const { copilotkit } = useCopilotKit();
const [interrupt, setInterrupt] = useState<string | null>(null);
useEffect(() => {
const subscriber: AgentSubscriber = {
onCustomEvent: ({ event }) => {
if (event.name === "on_interrupt") {
setInterrupt(event.value);
}
},
};
const { unsubscribe } = agent.subscribe(subscriber);
return () => unsubscribe();
}, [agent]);
const resolveInterrupt = (response: string) => {
agent.runAgent({
forwardedProps: { command: { resume: response } },
});
setInterrupt(null);
};
return (
<div>
{/* Messages and input... */}
{interrupt && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white rounded-xl p-6 max-w-md">
<p className="font-medium mb-4">{interrupt}</p>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
resolveInterrupt(formData.get("response") as string);
}}
>
<input
name="response"
className="border rounded px-3 py-2 w-full mb-3"
/>
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Submit
</button>
</form>
</div>
</div>
)}
</div>
);
}Access shared state#
If your LangGraph agent shares state with the frontend, access it via agent.state:
export function AgentDashboard() {
const { agent } = useAgent();
const currentNode = agent.state.currentNode;
const progress = agent.state.progress;
const results = agent.state.results;
return (
<div>
{currentNode && (
<div className="text-sm text-gray-500">Current step: {currentNode}</div>
)}
{progress && (
<div className="w-full bg-gray-200 rounded">
<div
className="bg-blue-500 h-2 rounded"
style={{ width: `${progress}%` }}
/>
</div>
)}
{results && (
<pre className="bg-gray-50 p-4 rounded">
{JSON.stringify(results, null, 2)}
</pre>
)}
</div>
);
}See Also#
- Programmatic Control — Full
useAgentreference and advanced patterns - Component Slots — Customize the built-in UI without going fully headless
- useAgent API Reference — Complete API documentation
