Build a chat frontend with CopilotKit and 10xGraph
In shortConnect a CopilotKit chat frontend to your 10xGraph agent over the AG-UI protocol, with streaming responses, tool calls, browser tools, and shared state.
- 8 min read
- 12 sections
- Updated
- v0.10.0
- Markdown
CopilotKit is a hosted chat UI and runtime that connects to agent backends. AG-UI is an open protocol between agent servers and chat frontends. 10xGraph’s API server speaks both: enable the /v1/ag-ui endpoint and wire CopilotKit to it, and you have a production chat interface with streaming responses, tool calls, frontend tools that run in the browser, and real-time state updates.
This guide builds a complete Next.js + CopilotKit frontend that calls your 10xGraph agent. For the server-side setup and production hardening, see Serve your agent over AG-UI. For the full endpoint and event specification, see the AG-UI endpoint reference.
System architecture
The three layers are:
- Frontend: CopilotKit React components (chat UI, tool handlers, interrupt listeners) in the browser.
- Backend: Next.js route handler (running on your server) that wraps CopilotKit’s
HttpAgentand proxies to 10xGraph, keeping your API token off the client. - Agent: Your 10xGraph API server with the
/v1/ag-uiendpoint enabled, serving your compiled graph.
Messages flow end to end: the user types in CopilotKit, the route handler forwards it to /v1/ag-ui as an AG-UI RunAgentInput, the graph responds with events (text, tool calls, state updates), CopilotKit renders them, and frontend tools are executed in the browser and sent back.
Prerequisites
You need:
- A 10xGraph API server running with the
ag-uiextra installed and/v1/ag-uienabled in10xgraph.json(follow Serve your agent over AG-UI first). - A Next.js project.
- Basic familiarity with React hooks and async code.
Set up the Next.js route handler
Install the required packages in your Next.js project:
npm install @copilotkit/react-core @copilotkit/runtime @ag-ui/client zodCreate a route handler that bridges CopilotKit and your 10xGraph server. The handler keeps your API token on the server, never exposing it to the browser:
// app/api/copilotkit/[[...slug]]/route.ts
import { HttpAgent } from "@ag-ui/client";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
const GRAPH_URL = process.env.NEXT_PUBLIC_GRAPH_API_URL || "http://localhost:8000";
const runtime = new CopilotRuntime({
agents: {
graph: new HttpAgent({
url: `${GRAPH_URL}/v1/ag-ui`,
// If your 10xGraph server requires authentication, pass the token here.
// It never reaches the browser because the route handler runs on your server.
headers: process.env.GRAPH_API_TOKEN
? { authorization: `Bearer ${process.env.GRAPH_API_TOKEN}` }
: {},
}),
},
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export const GET = handler;
export const POST = handler;
export const PATCH = handler;
export const DELETE = handler;The HttpAgent points at the /v1/ag-ui endpoint of your 10xGraph server. If your server is behind an API gateway or uses JWT, set the token in headers; the route handler is on your own server, so credentials are safe.
Build the chat page
The chat page wraps CopilotKit’s provider and chat component:
// app/page.tsx
"use client";
import { CopilotChat, CopilotKitProvider } from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
export default function ChatPage() {
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit"
agentId="graph"
>
<div style={{ height: "100vh" }}>
<CopilotChat
agentId="graph"
labels={{
title: "10xGraph Chat",
initial: "Hi, I'm here to help. What can I do?",
}}
/>
</div>
</CopilotKitProvider>
);
}The runtimeUrl points to the route handler you created (CopilotKit calls /api/copilotkit). The agentId="graph" matches the agent name in your HttpAgent configuration.
Frontend tools: run code in the browser
A frontend tool is a JavaScript function that runs on the client, not on the server. Register it with useFrontendTool, and CopilotKit will make it available to the graph alongside server-side tools:
// app/page.tsx or a separate hook file
import { useFrontendTool } from "@copilotkit/react-core/v2";
import { z } from "zod";
useFrontendTool({
name: "set_background_color",
description: "Change the page background color.",
parameters: z.object({
color: z.string().describe("A CSS color value like #FF5733 or red"),
}),
handler: async ({ color }) => {
document.body.style.backgroundColor = color;
return `Background changed to ${color}`;
},
});
useFrontendTool({
name: "get_current_time",
description: "Get the current time on the client.",
parameters: z.object({}),
handler: async () => {
return new Date().toISOString();
},
});When the model calls a frontend tool, the run ends with the tool call unanswered, CopilotKit executes the handler in the browser, and starts a new run on the same thread with the result. The graph continues after the tool node. Server tools in that node still run normally. Frontend tools never override server tools; if a tool with the same name exists on the server, the server version is used.
Human-in-the-loop: approvals with interrupt()
Call interrupt() in a node or tool to pause the graph and ask the user for approval or input:
# graph.py
from tenxgraph.utils import interrupt, tool
@tool
async def transfer_funds(amount: float, recipient: str) -> str:
"""Transfer money to a recipient, after the user approves."""
decision = interrupt(
value={"amount": amount, "recipient": recipient},
message=f"Transfer ${amount} to {recipient}?",
reason="transfer_approval",
response_schema={
"type": "object",
"properties": {
"approved": {"type": "boolean"},
"comment": {"type": "string"},
},
"required": ["approved"],
},
)
if not decision or not decision.get("approved"):
comment = (decision or {}).get("comment", "")
return f"Transfer declined. Comment: {comment}"
return f"Transferred ${amount} to {recipient}"On the frontend, render the interrupt with useInterrupt:
import { useInterrupt } from "@copilotkit/react-core/v2";
useInterrupt({
render: ({ interrupt, resolve, cancel }) => {
if (!interrupt) return null;
return (
<div style={{
position: "fixed",
bottom: 20,
right: 20,
background: "white",
border: "1px solid #ccc",
borderRadius: 8,
padding: 16,
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
maxWidth: 300,
}}>
<p>{interrupt.message}</p>
<input
type="text"
placeholder="Optional comment"
id="approve-comment"
style={{ width: "100%", marginBottom: 8 }}
/>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => {
const comment = (document.getElementById("approve-comment") as HTMLInputElement)?.value;
resolve({ approved: true, comment });
}}
style={{ flex: 1, background: "#4CAF50", color: "white", border: "none", padding: 8, borderRadius: 4 }}
>
Approve
</button>
<button
onClick={() => resolve({ approved: false, comment: "" })}
style={{ flex: 1, background: "#f44336", color: "white", border: "none", padding: 8, borderRadius: 4 }}
>
Reject
</button>
</div>
</div>
);
},
});When the user clicks Approve, interrupt() returns the object { approved: true, comment } and the graph continues. If they click Reject, interrupt() returns { approved: false, comment: "" }. If they call cancel(), it returns None.
Shared state and real-time updates
Fields in your graph’s state class are sent to the frontend whenever they change. Add custom fields to your AgentState:
# graph.py
from tenxgraph import AgentState
class AppState(AgentState):
"""Graph state with custom fields."""
current_task: str = "Idle"
progress: float = 0.0
selected_file: str = ""Read these fields on the frontend with the useAgent hook:
import { useAgent } from "@copilotkit/react-core/v2";
export function StatusBar() {
const { agent } = useAgent({ agentId: "graph" });
const state = agent.state as {
current_task?: string;
progress?: number;
selected_file?: string;
};
return (
<div style={{ padding: 16, background: "#f5f5f5" }}>
<p>Task: {state.current_task || "-"}</p>
<p>Progress: {Math.round((state.progress ?? 0) * 100)}%</p>
<p>File: {state.selected_file || "-"}</p>
</div>
);
}The state updates in real-time as the graph runs. Set initial state from the frontend by passing it in RunAgentInput.state; it becomes the run’s starting state. The context, context_summary and execution_meta keys are ignored.
Frontend context and configuration
Pass data from the frontend to your graph nodes and tools via RunAgentInput.context. The AG-UI request extras are available inside any node or tool as config["ag_ui"]:
# graph.py
async def main_node(state: AppState, config: dict):
ag_ui = config.get("ag_ui", {})
# A list of AG-UI context items, each with "description" and "value".
frontend_context = ag_ui.get("context", [])
client_tools = ag_ui.get("tools", [])
forwarded_props = ag_ui.get("forwarded_props")context, tools and forwarded_props are the AG-UI RunAgentInput fields of the same names. In CopilotKit, context registered with its context hooks arrives as context.
Limitations and known behavior
-
Threads and message history. The 10xGraph API stores the full conversation in the checkpointer (Postgres, SQLite, or in-memory). CopilotKit sends the entire conversation on every run, but only new messages are passed to the graph; the checkpoint is the source of truth. If you reload the browser, the client loses its local message list unless you persist it (CopilotKit has a storage adapter for this). Use a durable checkpointer (Postgres + Redis) if threads must survive server restarts.
-
No
MESSAGES_SNAPSHOTyet. The/v1/ag-uiendpoint does not send the full message history back in a snapshot event. Reload a thread in the browser and it starts empty unless your client stores messages locally. -
Message filtering. Messages the client marks as coming from the system, developer, or assistant are ignored on resume; the graph treats the checkpoint as the record of what was said and done.
-
Browser vs. server tools. The graph needs a
ToolNode(passed to yourAgentnode astool_node). Browser tools fromRunAgentInput.toolsare added to the tool list for that run only and do not persist. A browser tool never replaces a server tool; if a tool with that name exists on the server, the server tool is used. Client tools are limited to 64 per run, names of 1 to 64 letters, digits,_or-, and set"ag_ui": {"allow_client_tools": false}in10xgraph.jsonto refuse them (onlyremote_toolsare then offered).
What maps to what
| 10xGraph | AG-UI |
|---|---|
| Run start / end | RUN_STARTED / RUN_FINISHED |
| Node start / end | STEP_STARTED / STEP_FINISHED (node name) |
| Assistant text (streamed deltas or a whole message) | TEXT_MESSAGE_START / CONTENT / END |
| Reasoning blocks | REASONING_START … REASONING_END |
| Tool calls from the model | TOOL_CALL_START / ARGS / END |
| Server tool results | TOOL_CALL_RESULT |
| A failed tool | TOOL_CALL_RESULT with the error, and the run continues |
| A browser tool call | TOOL_CALL_*, then RUN_FINISHED with the call unanswered |
interrupt() |
RUN_FINISHED with outcome: {type: "interrupt", interrupts: [...]} |
| Application state fields | STATE_SNAPSHOT (only fields you add to AgentState, not messages) |
| Graph error | RUN_ERROR |
Interrupt mapping
How interrupt() maps to the AG-UI Interrupt:
10xGraph interrupt() |
AG-UI Interrupt |
|---|---|
| generated id | id (echoed back as resume[].interruptId) |
reason |
reason |
message |
message |
response_schema |
responseSchema |
| tool call it ran in | toolCallId |
value and node name |
metadata.value, metadata.node |
resolve(payload) resumes the graph and interrupt() returns payload; cancel() resumes it
with None. While a thread is paused, a run that does not answer the interrupt (for example a
new chat message) reports the same interrupt again without running the graph. A resume for a
different interrupt id ends the run with RUN_ERROR.
Frequently asked questions
- Why CopilotKit instead of the TypeScript client?
- CopilotKit is a complete chat UI and runtime. Use it if you want a hosted chat widget. The 10xGraph TypeScript client gives you more control over the UI and how to render the stream.
- Can I use CopilotKit without AG-UI?
- CopilotKit speaks AG-UI natively. 10xGraph exposes the /v1/ag-ui endpoint via a server configuration key; there is no CopilotKit-specific code in 10xGraph.