CopilotKit Frontend for a 10xGraph Agent over AG-UI
In shortBuild a task board with a Next.js CopilotKit UI and a Python 10xGraph agent served over AG-UI, with shared state, approvals and Docker.
- 22 min read
- 13 sections
- Updated
- v1.0.0rc4
- Markdown
This is a full-stack app: a Python 10xGraph agent served over the AG-UI protocol, and a Next.js frontend built with CopilotKit that shows a task board next to a chat. The main assistant runs on OpenAI gpt-5-mini. A second, separate planner graph runs on DeepSeek deepseek-chat. Both services ship with Dockerfiles and a Compose file, so you can run the whole app with one command.
What you will build
A task board with a chat assistant. You type a goal such as “plan a product launch”. The assistant adds tasks to a board that is shown next to the chat, moves them between columns, and asks you to approve before it deletes finished tasks.
The backend is a 10xGraph ReactAgent served by the 10xGraph API server on POST /v1/ag-ui. The frontend is Next.js with CopilotKit, which speaks AG-UI natively.
Architecture
Backend agents
| Agent | Model | Company | Role |
|---|---|---|---|
Task assistant (backend/graph/agent.py) |
gpt-5-mini |
OpenAI | Talks to the user, calls the board tools |
Task planner (backend/graph/planner.py) |
deepseek-chat |
DeepSeek | Splits a goal into 3 to 5 steps, called from the plan_goal tool |
Both are 10xGraph graphs. The planner uses the OpenAI-compatible client with base_url="https://api.deepseek.com", so no extra package is needed beyond the openai extra. Using a model from another company shows that agents in one app do not have to share a provider.
The AG-UI bridge
AG-UI is an event protocol between an agent backend and a UI. The 10xGraph API server implements it in routers/ag_ui/ (router, service, event mapper). When ag_ui.enabled is true in 10xgraph.json, the server mounts POST /v1/ag-ui. A request carries a RunAgentInput (messages, state, frontend tools). The response is a server-sent event stream. The server runs your graph and maps what it does to AG-UI events. It uses the same auth as /v1/graph/stream.
CopilotKit runtime and frontend
The browser never calls the 10xGraph server. It calls the CopilotKit runtime, which lives in a Next.js route handler (app/api/copilotkit/[[...slug]]/route.ts). That handler holds an HttpAgent from @ag-ui/client pointed at GRAPH_API_URL/v1/ag-ui. If you set GRAPH_API_TOKEN, the handler adds it as a bearer token, so the token stays on the server.
Docker services
docker-compose.yml defines two services. backend is built from ./backend, reads keys from .env and publishes port 8000. frontend is built from ./frontend, gets GRAPH_API_URL=http://backend:8000 and publishes port 3000. The frontend starts after the backend.
Event flow from a message to rendered UI
Browser (CopilotChat + Board + Approval)
| 1. user types a message
v
CopilotKit runtime (Next.js route handler, /api/copilotkit)
| 2. HttpAgent -> POST GRAPH_API_URL/v1/ag-ui
| body: RunAgentInput (messages, state, frontend tools such as focus_task)
v
10xGraph API server (/v1/ag-ui, ag_ui.enabled = true)
| 3. runs the graph: MAIN (gpt-5-mini) <-> TOOL
| plan_goal tool --> planner graph (deepseek-chat)
| 4. maps graph activity to AG-UI events over SSE
v
CopilotKit runtime --- events streamed back ---> Browser
TEXT_MESSAGE_START/CONTENT/END -> chat bubbles
TOOL_CALL_START/ARGS/END, RESULT -> tool calls in the chat
STATE_SNAPSHOT (tasks, ...) -> useAgent().agent.state -> Board
tool call focus_task (no result) -> run ends, browser handler runs,
CopilotKit starts a new run with the result
RUN_FINISHED outcome=interrupt -> useInterrupt -> Approval card
resolve(...) resumes the graphKey concepts
ag_ui.enabledin10xgraph.json: mountsPOST /v1/ag-uion the API server.AgentStatesubclass (BoardState): the custom fields are the shared state.- Tools that take
state: they change the board in place; the server sends the new fields as a snapshot. interrupt()inclear_done_tasks: pauses for approval.HttpAgentfrom@ag-ui/clientin the Next.js route: the CopilotKit runtime’s link to the server.useAgent,useFrontendTool,useInterrupt: CopilotKit hooks for the board, the browser tool and the approval.
AG-UI feature support
Checked against the API server source under routers/ag_ui/ and the integration guide.
| Feature | Supported | Used here | How |
|---|---|---|---|
| Streaming text | Yes | Yes | TEXT_MESSAGE_START/CONTENT/END |
| Tool calls and results | Yes | Yes | TOOL_CALL_START/ARGS/END, TOOL_CALL_RESULT |
| Reasoning | Yes | No | REASONING_START to REASONING_END |
| Shared state to the UI | Yes | Yes | STATE_SNAPSHOT with the fields you add to AgentState (not messages) |
| State from the UI to the graph | Yes | No | RunAgentInput.state becomes the run’s start state; context, context_summary, execution_meta are ignored |
| Frontend (browser) tools | Yes | Yes | Sent in RunAgentInput.tools, offered for that run only; the run finishes with the call unanswered and CopilotKit starts a new run with the result. Disable with ag_ui.allow_client_tools: false |
| Human-in-the-loop | Yes | Yes | interrupt() becomes RUN_FINISHED with outcome: {type: "interrupt"}; CopilotKit useInterrupt resumes it |
STATE_DELTA patches |
No | No | Only full snapshots are sent |
MESSAGES_SNAPSHOT |
No | No | A reloaded browser starts with an empty chat unless CopilotKit stores messages |
Project structure
- .env.exampleTemplate for API keys and the frontend’s server-side settings
- docker-compose.ymlRuns backend and frontend together
- backend/Python 10xGraph API server
- 10xgraph.jsonPoints at the graph and enables the AG-UI endpoint
- DockerfilePython 3.13 image, runs uvicorn on port 8000
- .dockerignoreKeeps .env and caches out of the image
- requirements.txt10xgraph with the openai extra, 10xgraph-api with the ag-ui extra, python-dotenv
- graph/
- agent.pyMain assistant, board tools, exports the compiled graph as
app - planner.pyDeepSeek planner graph used by the plan_goal tool
- state.pyBoardState, the shared state sent to the browser
- agent.pyMain assistant, board tools, exports the compiled graph as
- frontend/Next.js app with CopilotKit
- DockerfileMulti-stage build, runs the standalone Next.js server on port 3000
- .dockerignore
- package.json
- tsconfig.json
- next.config.mjsSets output to standalone
- app/
- layout.tsx
- page.tsxCopilotKit provider, chat, focus_task browser tool
- api/copilotkit/[[…slug]]/route.tsCopilotKit runtime with the HttpAgent
Prerequisites
- Python 3.12 or newer, Node.js 20 or newer (or Docker).
- Keys:
OPENAI_API_KEY,DEEPSEEK_API_KEY. - Backend install:
pip install "10xgraph[openai]" "10xgraph-api[ag-ui]" python-dotenv(seebackend/requirements.txt). - Frontend package versions in
frontend/package.jsonuse caret ranges (@copilotkit/*^1.50.0,@ag-ui/client^0.0.40).frontend/package-lock.jsonpins them (CopilotKit 1.82.0 and@ag-ui/client0.0.40 at the time of writing), which ship the@copilotkit/*/v2import paths the app uses. The Dockerfile installs withnpm ci, so Docker builds use exactly those versions. - The frontend uses the
@copilotkit/react-core/v2chat components, so@copilotkit/react-uiis not installed.
Run it
Clone the repo and go to the example:
git clone https://github.com/10xGraph/10xGraph.git
cd 10xGraph/agentflow/examples/apps/copilotkit_aguiEnvironment variables
| Variable | Read by | Purpose |
|---|---|---|
OPENAI_API_KEY |
Backend | Used by the gpt-5-mini assistant |
DEEPSEEK_API_KEY |
Backend | Used by the DeepSeek planner (planner.py reads it with os.getenv) |
GRAPH_API_URL |
Frontend (server side) | Where the route handler finds the API server. Defaults to http://localhost:8000. Compose sets it to http://backend:8000 |
GRAPH_API_TOKEN |
Frontend (server side) | Sent as a bearer token. Only needed if you enable auth in 10xgraph.json |
With Docker
cp .env.example .env # add OPENAI_API_KEY and DEEPSEEK_API_KEY
docker compose up --buildOpen http://localhost:3000. The API server is on http://localhost:8000.
Without Docker
Use two terminals.
# 1. backend
cd backend
pip install -r requirements.txt
cp ../.env.example .env # add your keys
10xgraph dev --config 10xgraph.json --port 8000 --no-open
# 2. frontend
cd frontend
npm install
GRAPH_API_URL=http://localhost:8000 npm run devHow the code works
Backend configuration: backend/10xgraph.json
{
"agent": "graph.agent:app",
"env": ".env",
"auth": null,
"remote_tools": [],
"ag_ui": {
"enabled": true,
"allow_client_tools": true
},
"thread_name_generator": null
}agent is the import path of the compiled graph: the app variable in graph/agent.py. env names the file the server loads. auth is null, so the demo has no authentication. The ag_ui block is what makes this app work. enabled mounts POST /v1/ag-ui. allow_client_tools lets the server offer browser tools (here focus_task) to the model. Set it to false to ignore tools sent by the browser.
Shared state: backend/graph/state.py
class BoardState(AgentState):
"""Task board. CopilotKit reads these fields with useAgent(...).state."""
board_title: str = "My task board"
# Each task: {"id": int, "title": str, "status": "todo" | "doing" | "done", "priority": str}
tasks: list[dict[str, Any]] = Field(default_factory=list)
next_id: int = 1BoardState extends AgentState with three fields. The AG-UI endpoint sends custom fields to the browser as STATE_SNAPSHOT events (messages are not part of the snapshot). So the board is just this state. next_id gives each task a stable id.
Board tools: backend/graph/agent.py
def _new_task(state: BoardState, title: str, priority: str) -> dict:
task = {"id": state.next_id, "title": title, "status": "todo", "priority": priority}
state.next_id += 1
state.tasks.append(task)
return taskA small helper used by add_task and plan_goal. It changes the state in place.
def add_task(title: str, priority: str = "medium", state: BoardState | None = None) -> str:
"""Add one task to the board. priority is low, medium or high."""
if state is None:
return "No board state available."
task = _new_task(state, title, priority)
return f"Added task #{task['id']}: {title}"Every tool has a state: BoardState | None = None parameter. 10xGraph injects the current state into it, and the model never sees it in the tool schema. The tool changes state and the server sends the new fields to the UI. The docstring becomes the tool description the model reads. set_task_status follows the same pattern and validates the status against _STATUSES.
async def plan_goal(goal: str, state: BoardState | None = None) -> str:
"""Ask the planner agent to split a goal into steps and add them as tasks."""
...
steps = await plan_steps(goal)
for step in steps:
_new_task(state, step, "medium")
return f"Planner added {len(steps)} tasks: " + "; ".join(steps)plan_goal is async because it awaits another graph. It calls the DeepSeek planner and adds one task per step.
decision = interrupt(
value={"count": len(done), "titles": [t["title"] for t in done]},
message=f"Delete {len(done)} finished task(s)?",
reason="clear_done_approval",
response_schema={
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
},
)
if not decision or not decision.get("approved"):
return "The user declined. No tasks were deleted."
state.tasks = [t for t in state.tasks if t["status"] != "done"]clear_done_tasks is the human-in-the-loop step. interrupt() pauses the run. The AG-UI endpoint ends the stream with RUN_FINISHED and an interrupt outcome, and CopilotKit’s useInterrupt shows the approval card. When the user answers, the graph resumes and the tool runs again from the top, with interrupt() returning the answer. That is why the code comment says nothing with side effects may come before the call. The deletion happens only after approval. If the user cancels, decision is None and the tasks stay.
react_agent = ReactAgent(
model="gpt-5-mini",
state=BoardState(),
system_prompt=[...],
tools=[add_task, set_task_status, plan_goal, clear_done_tasks],
)
app = react_agent.compile(checkpointer=InMemoryCheckpointer())ReactAgent wires the MAIN and TOOL nodes and the routing between them. The system prompt tells the model which tool to use for which request, and to call the browser tool focus_task after it changes a task. app is the name 10xgraph.json points at. The InMemoryCheckpointer keeps thread state in process memory, which matters for deployment (see below).
Planner graph: backend/graph/planner.py
_planner = ReactAgent(
model="deepseek-chat",
base_url="https://api.deepseek.com",
api_key=os.environ["DEEPSEEK_API_KEY"],
system_prompt=[...],
).compile()A second graph with no tools, so ReactAgent builds a single MAIN node. base_url and api_key go to the agent, and an unknown model name uses the OpenAI-compatible client, which is how DeepSeek is reached. The prompt asks for one step per line, no numbering.
async def plan_steps(goal: str) -> list[str]:
"""Ask the DeepSeek planner for steps. Each call uses a fresh thread."""
result = await _planner.ainvoke(
{"messages": [Message.text_message(goal)]},
{"thread_id": f"planner-{uuid.uuid4()}"},
)
text = result["messages"][-1].text() if result.get("messages") else ""
steps = [line.strip(" -*\t") for line in text.splitlines()]
return [s for s in steps if s][:5]plan_steps calls the graph with ainvoke and a new thread id each time, so planner calls never share history. It splits the reply into lines, strips bullets, and keeps at most five steps.
CopilotKit runtime: frontend/app/api/copilotkit/[[...slug]]/route.ts
const runtime = new CopilotRuntime({
agents: {
// The key ("graph") is the agentId the React side refers to.
graph: new HttpAgent({
url: `${GRAPH_URL}/v1/ag-ui`,
headers: process.env.GRAPH_API_TOKEN
? { authorization: `Bearer ${process.env.GRAPH_API_TOKEN}` }
: {},
}),
},
runner: new InMemoryAgentRunner(),
});This is the AG-UI wiring. HttpAgent posts to /v1/ag-ui and reads the SSE stream. The key graph is the agentId that page.tsx and Board.tsx use. The route file runs on the Next.js server, so the optional token never reaches the browser.
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export const GET = handler;
export const POST = handler;
export const PATCH = handler;
export const DELETE = handler;One handler serves every method. The catch-all folder name [[...slug]] lets it answer all paths under /api/copilotkit.
Page and browser tool: frontend/app/page.tsx
function FocusTool({ onFocus }: { onFocus: (id: number) => void }) {
useFrontendTool({
name: "focus_task",
description: "Highlight a task on the board in the user's browser.",
parameters: z.object({ task_id: z.number().describe("The task id to highlight") }),
handler: async ({ task_id }) => {
onFocus(task_id);
return `Highlighted task ${task_id}`;
},
});
return null;
}focus_task exists only in the browser. CopilotKit sends its schema in RunAgentInput.tools, so the model can call it. The server ends the run with the call unanswered, the handler runs in the browser, and CopilotKit starts a new run with the returned string as the result. The component renders nothing; it only registers the tool.
<CopilotKitProvider runtimeUrl="/api/copilotkit" agentId="graph">
<FocusTool onFocus={setFocusedId} />
<Approval />
<div style={{ display: "grid", gridTemplateColumns: "320px 1fr", height: "100vh" }}>
<aside style={{ borderRight: "1px solid #eee", overflow: "auto" }}>
<Board focusedId={focusedId} />
</aside>
<CopilotChat agentId="graph" labels={{ title: "Task assistant" }} />
</div>
</CopilotKitProvider>The provider points at the route handler and the graph agent. The layout is a two-column grid: board on the left, chat on the right. focusedId is local React state that links the browser tool to the board highlight.
Board: frontend/components/Board.tsx
const { agent } = useAgent({ agentId: "graph" });
const state = (agent.state ?? {}) as BoardState;
const tasks = state.tasks ?? [];useAgent returns the agent object. Each STATE_SNAPSHOT from the server updates agent.state, and React re-renders. The component filters tasks into the todo, doing and done columns and highlights the task whose id matches focusedId. agent.state can be empty before the first run, so the code falls back to {}.
Approval: frontend/components/Approval.tsx
useInterrupt({
render: ({ interrupt, resolve, cancel }) => {
if (!interrupt) return null;
...
<button onClick={() => resolve({ approved: true })} style={{ flex: 1 }}>useInterrupt renders when the run ended with an interrupt. interrupt.message is the text from interrupt(message=...). resolve(payload) resumes the graph, and interrupt() in Python returns that payload, matching the response_schema (approved: boolean). cancel() resumes with nothing, so Python sees None.
Layout and config: layout.tsx, next.config.mjs, package.json, tsconfig.json
layout.tsx sets the page title and a base font. next.config.mjs sets output: "standalone", which makes next build produce a self-contained server.js that the Dockerfile runs. package.json has the scripts (dev, build, start) and dependencies: @ag-ui/client, @copilotkit/react-core, @copilotkit/runtime, Next 15, React 19 and zod for the tool parameter schema. tsconfig.json is a strict Next.js setup with an @/* path alias, used by imports such as @/components/Board.
Docker files
Backend Dockerfile:
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 PYTHONPATH=/app GRAPH_PATH=/app/10xgraph.json
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .It installs the requirements first, so Docker caches that layer until requirements.txt changes. PYTHONPATH=/app lets graph.agent:app import, and GRAPH_PATH points the server at the config file.
CMD ["uvicorn", "tenxgraph_api.src.app.main:app", "--host", "0.0.0.0", "--port", "8000"]The container starts the API server with a single uvicorn process. The comment in the file explains why: threads live in InMemoryCheckpointer, so an interrupt and its resume must reach the same worker. backend/.dockerignore keeps .env out of the image; keys come in through Compose’s env_file.
Frontend Dockerfile:
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]Two stages. The first installs and builds. The second copies only the standalone output and static assets, and runs server.js. GRAPH_API_URL is read at runtime by the route handler, so Compose can set it without a rebuild.
docker-compose.yml ties them together: the backend takes .env, the frontend gets GRAPH_API_URL: http://backend:8000 (the Compose service name is the hostname), and depends_on starts the backend first.
Deploy to production
What the code and config support:
- The two Dockerfiles and
docker-compose.ymlbuild and run both services. Any host that runs containers can run these images. - Set
OPENAI_API_KEYandDEEPSEEK_API_KEYon the backend. SetGRAPH_API_URLon the frontend so it can reach the API server. - To protect the API server, configure auth in
10xgraph.json(it isnullhere) and setGRAPH_API_TOKENon the frontend. The route handler then sends it as a bearer token. The AG-UI endpoint uses the same auth as/v1/graph/stream. - Keep the API server private. Only the frontend needs to reach it, because the browser talks to the Next.js route.
Also note that InMemoryAgentRunner in the CopilotKit route is in-memory too, so the frontend is not set up for multiple instances either.
Customize it
- Change a model: edit the
modelstring inbackend/graph/agent.pyorbackend/graph/planner.py. For Anthropic, usemodel="claude-sonnet-5-5", install10xgraph[anthropic]and setANTHROPIC_API_KEY. For Google, usemodel="gemini-2.5-flash", install10xgraph[google-genai]and setGEMINI_API_KEY. - Add a field to the board: add it to
BoardStateand read it inBoard.tsxfromagent.state. It reaches the browser in the next snapshot. - Add a tool: write a function with a docstring and a
state: BoardState | None = Noneparameter, then add it to thetoolslist and mention it in the system prompt. - Add another browser tool: call
useFrontendToolin a component under the provider, and tell the model about it in the system prompt. Setallow_client_toolstofalseif you do not want browser tools at all. - Add durable threads: replace
InMemoryCheckpointer()withPgCheckpointer. See Persistence. - Add approvals for other tools: call
interrupt()first in the tool and render the answer inApproval.tsx. See Human-in-the-loop.
Full code
Backend
backend/10xgraph.json
{
"agent": "graph.agent:app",
"env": ".env",
"auth": null,
"remote_tools": [],
"ag_ui": {
"enabled": true,
"allow_client_tools": true
},
"thread_name_generator": null
}backend/graph/agent.py
"""Main assistant (OpenAI gpt-5-mini): a task-board agent served over AG-UI.
Loaded by 10xgraph.json ("agent": "graph.agent:app"). The tools change `state`, and the API
server sends the changed fields to CopilotKit as STATE_SNAPSHOT events.
"""
from dotenv import load_dotenv
from graph.planner import plan_steps
from graph.state import BoardState
from tenxgraph.prebuilt.agent import ReactAgent
from tenxgraph.storage.checkpointer import InMemoryCheckpointer
from tenxgraph.utils import interrupt
load_dotenv()
_STATUSES = ("todo", "doing", "done")
def _new_task(state: BoardState, title: str, priority: str) -> dict:
task = {"id": state.next_id, "title": title, "status": "todo", "priority": priority}
state.next_id += 1
state.tasks.append(task)
return task
def add_task(title: str, priority: str = "medium", state: BoardState | None = None) -> str:
"""Add one task to the board. priority is low, medium or high."""
if state is None:
return "No board state available."
task = _new_task(state, title, priority)
return f"Added task #{task['id']}: {title}"
def set_task_status(task_id: int, status: str, state: BoardState | None = None) -> str:
"""Move a task to todo, doing or done."""
if state is None:
return "No board state available."
if status not in _STATUSES:
return f"Status must be one of {', '.join(_STATUSES)}."
for task in state.tasks:
if task["id"] == task_id:
task["status"] = status
return f"Task #{task_id} is now {status}."
return f"No task with id {task_id}."
async def plan_goal(goal: str, state: BoardState | None = None) -> str:
"""Ask the planner agent to split a goal into steps and add them as tasks."""
if state is None:
return "No board state available."
steps = await plan_steps(goal)
for step in steps:
_new_task(state, step, "medium")
return f"Planner added {len(steps)} tasks: " + "; ".join(steps)
def clear_done_tasks(state: BoardState | None = None) -> str:
"""Delete every task marked done. Asks the user for approval first."""
if state is None:
return "No board state available."
done = [t for t in state.tasks if t["status"] == "done"]
if not done:
return "There are no finished tasks to clear."
# interrupt() pauses the run. The AG-UI endpoint ends it with an interrupt outcome, and
# CopilotKit's useInterrupt renders the approval. The tool runs again on resume, so nothing
# with side effects may come before this call.
decision = interrupt(
value={"count": len(done), "titles": [t["title"] for t in done]},
message=f"Delete {len(done)} finished task(s)?",
reason="clear_done_approval",
response_schema={
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
},
)
if not decision or not decision.get("approved"):
return "The user declined. No tasks were deleted."
state.tasks = [t for t in state.tasks if t["status"] != "done"]
return f"Deleted {len(done)} finished task(s)."
react_agent = ReactAgent(
model="gpt-5-mini",
state=BoardState(),
system_prompt=[
{
"role": "system",
"content": (
"You manage a task board shown next to the chat. Use add_task for a single "
"task and plan_goal when the user states a larger goal. Use set_task_status "
"to move tasks and clear_done_tasks to delete finished ones. After you add or "
"change a task, call the browser tool focus_task with its id so the user sees "
"it. Keep replies short."
),
}
],
tools=[add_task, set_task_status, plan_goal, clear_done_tasks],
)
app = react_agent.compile(checkpointer=InMemoryCheckpointer())backend/graph/planner.py
"""Helper agent: breaks a goal into short task titles, served by DeepSeek.
It is a second, separate 10xGraph graph, called from a tool of the main assistant. Using a
model from another company shows that agents in one app do not have to share a provider.
"""
import os
import uuid
from dotenv import load_dotenv
from tenxgraph.core.state import Message
from tenxgraph.prebuilt.agent import ReactAgent
load_dotenv()
# No tools, so ReactAgent wires a single MAIN node. base_url and api_key are forwarded to the
# Agent; unknown model names use the OpenAI-compatible client.
_planner = ReactAgent(
model="deepseek-chat",
base_url="https://api.deepseek.com",
api_key=os.environ["DEEPSEEK_API_KEY"],
system_prompt=[
{
"role": "system",
"content": (
"You are a task planner. Break the user's goal into 3 to 5 concrete steps. "
"Reply with one step per line, no numbering, no extra text. "
"Keep each step under 8 words."
),
}
],
).compile()
async def plan_steps(goal: str) -> list[str]:
"""Ask the DeepSeek planner for steps. Each call uses a fresh thread."""
result = await _planner.ainvoke(
{"messages": [Message.text_message(goal)]},
{"thread_id": f"planner-{uuid.uuid4()}"},
)
text = result["messages"][-1].text() if result.get("messages") else ""
steps = [line.strip(" -*\t") for line in text.splitlines()]
return [s for s in steps if s][:5]backend/graph/state.py
"""Shared board state. Every field here is sent to the browser as an AG-UI STATE_SNAPSHOT."""
from typing import Any
from pydantic import Field
from tenxgraph.core.state import AgentState
class BoardState(AgentState):
"""Task board. CopilotKit reads these fields with useAgent(...).state."""
board_title: str = "My task board"
# Each task: {"id": int, "title": str, "status": "todo" | "doing" | "done", "priority": str}
tasks: list[dict[str, Any]] = Field(default_factory=list)
next_id: int = 1backend/requirements.txt
10xgraph[openai]
10xgraph-api[ag-ui]
python-dotenvFrontend
frontend/app/api/copilotkit/[[...slug]]/route.ts
// CopilotKit runtime. It runs on the Next.js server, so the 10xGraph API token (if any)
// never reaches the browser.
import { HttpAgent } from "@ag-ui/client";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
const GRAPH_URL = process.env.GRAPH_API_URL || "http://localhost:8000";
const runtime = new CopilotRuntime({
agents: {
// The key ("graph") is the agentId the React side refers to.
graph: new HttpAgent({
url: `${GRAPH_URL}/v1/ag-ui`,
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;frontend/app/layout.tsx
import type { ReactNode } from "react";
export const metadata = { title: "10xGraph + CopilotKit task board" };
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>{children}</body>
</html>
);
}frontend/app/page.tsx
"use client";
import { useState } from "react";
import { CopilotChat, CopilotKitProvider, useFrontendTool } from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
import { z } from "zod";
import { Approval } from "@/components/Approval";
import { Board } from "@/components/Board";
// Frontend tool: the model calls it, the run ends with the call unanswered, this handler runs
// in the browser, and CopilotKit starts a new run with the result.
function FocusTool({ onFocus }: { onFocus: (id: number) => void }) {
useFrontendTool({
name: "focus_task",
description: "Highlight a task on the board in the user's browser.",
parameters: z.object({ task_id: z.number().describe("The task id to highlight") }),
handler: async ({ task_id }) => {
onFocus(task_id);
return `Highlighted task ${task_id}`;
},
});
return null;
}
export default function Page() {
const [focusedId, setFocusedId] = useState<number | null>(null);
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit" agentId="graph">
<FocusTool onFocus={setFocusedId} />
<Approval />
<div style={{ display: "grid", gridTemplateColumns: "320px 1fr", height: "100vh" }}>
<aside style={{ borderRight: "1px solid #eee", overflow: "auto" }}>
<Board focusedId={focusedId} />
</aside>
<CopilotChat agentId="graph" labels={{ title: "Task assistant" }} />
</div>
</CopilotKitProvider>
);
}frontend/components/Approval.tsx
"use client";
import { useInterrupt } from "@copilotkit/react-core/v2";
// Renders the approval requested by interrupt() in the clear_done_tasks tool.
// resolve(payload) resumes the graph and interrupt() returns payload; cancel() returns None.
export function Approval() {
useInterrupt({
render: ({ interrupt, resolve, cancel }) => {
if (!interrupt) return null;
return (
<div
style={{
position: "fixed",
bottom: 20,
left: 20,
background: "white",
border: "1px solid #ccc",
borderRadius: 8,
padding: 16,
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
maxWidth: 300,
}}
>
<p style={{ marginTop: 0 }}>{interrupt.message}</p>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => resolve({ approved: true })} style={{ flex: 1 }}>
Approve
</button>
<button onClick={() => resolve({ approved: false })} style={{ flex: 1 }}>
Reject
</button>
<button onClick={() => cancel()}>Cancel</button>
</div>
</div>
);
},
});
return null;
}frontend/components/Board.tsx
"use client";
import { useAgent } from "@copilotkit/react-core/v2";
type Task = { id: number; title: string; status: "todo" | "doing" | "done"; priority: string };
type BoardState = { board_title?: string; tasks?: Task[] };
const COLUMNS: Task["status"][] = ["todo", "doing", "done"];
// Reads the graph's BoardState. Each STATE_SNAPSHOT from the server updates agent.state.
export function Board({ focusedId }: { focusedId: number | null }) {
const { agent } = useAgent({ agentId: "graph" });
const state = (agent.state ?? {}) as BoardState;
const tasks = state.tasks ?? [];
return (
<div style={{ padding: 16 }}>
<h2 style={{ marginTop: 0 }}>{state.board_title ?? "My task board"}</h2>
{COLUMNS.map((status) => (
<section key={status} style={{ marginBottom: 16 }}>
<h3 style={{ textTransform: "uppercase", fontSize: 12, color: "#666" }}>{status}</h3>
{tasks
.filter((t) => t.status === status)
.map((t) => (
<div
key={t.id}
style={{
padding: 8,
marginBottom: 6,
borderRadius: 6,
border: "1px solid #ddd",
background: t.id === focusedId ? "#fff3b0" : "white",
}}
>
#{t.id} {t.title} <small style={{ color: "#888" }}>({t.priority})</small>
</div>
))}
</section>
))}
{tasks.length === 0 && <p style={{ color: "#888" }}>No tasks yet. Ask the assistant.</p>}
</div>
);
}frontend/next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = { output: "standalone" };
export default nextConfig;frontend/package.json
{
"name": "copilotkit-agui-frontend",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@ag-ui/client": "^0.0.40",
"@copilotkit/react-core": "^1.50.0",
"@copilotkit/runtime": "^1.50.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.0"
}
}frontend/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}Deployment
docker-compose.yml
services:
backend:
build: ./backend
env_file: .env
ports:
- "8000:8000"
frontend:
build: ./frontend
environment:
GRAPH_API_URL: http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend.env.example
# Backend (read by the 10xGraph API server and the agents)
OPENAI_API_KEY=
DEEPSEEK_API_KEY=
# Frontend (Next.js server side only). Docker Compose sets GRAPH_API_URL itself.
GRAPH_API_URL=http://localhost:8000
# Only needed if you enable auth in 10xgraph.json
GRAPH_API_TOKEN=backend/Dockerfile
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 PYTHONPATH=/app GRAPH_PATH=/app/10xgraph.json
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
# One process on purpose: the demo keeps threads in InMemoryCheckpointer, so an
# interrupt and its resume must reach the same worker. For multiple workers, switch
# to PgCheckpointer and use the gunicorn image `10xgraph build` generates.
CMD ["uvicorn", "tenxgraph_api.src.app.main:app", "--host", "0.0.0.0", "--port", "8000"]backend/.dockerignore
.env
__pycache__frontend/Dockerfile
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]frontend/.dockerignore
node_modules
.next
.env*Source on GitHub: examples/apps/copilotkit_agui
Expected output
Illustrative session:
You: Plan a product launch and mark the first step as doing.
Assistant: I asked the planner and added 4 tasks. Task 1 is now doing.
(board: TODO 3 tasks, DOING "Define launch goals", task 1 highlighted)
You: Clear the finished tasks.
(approval card: "Delete 2 finished task(s)?" -> Approve)
Assistant: Deleted 2 finished task(s).Next steps
Frequently asked questions
- How do I enable the AG-UI endpoint in 10xGraph?
- Install `10xgraph-api[ag-ui]` and set `"ag_ui": {"enabled": true}` in `10xgraph.json`. The server then mounts `POST /v1/ag-ui`, which uses the same auth as `/v1/graph/stream`.
- Does the browser talk to the 10xGraph server directly?
- No. The browser calls the CopilotKit runtime in the Next.js route handler, and that handler calls `/v1/ag-ui` with an `HttpAgent`. Any API token stays on the server.
- Which AG-UI features does 10xGraph support?
- Streaming text, tool calls and results, reasoning, state snapshots, browser (frontend) tools and interrupt() approvals. It does not send MESSAGES_SNAPSHOT or state deltas.