A didactic project demonstrating how to integrate CopilotKit (React) with LangGraph (Python) to build an AI-powered interface that reads and controls UI state in real-time.
This project demonstrates a simple but powerful use case: an AI agent that can read and change a square's color through natural language conversation.
What makes this interesting:
- Bidirectional sync: Frontend state (React)
↔️ Backend AI (Python) - Real-time updates: AI always knows the current UI state, even if changed manually
- Tool calling: AI can execute actions in the UI through natural language
- State synchronization challenge: Solved through prompt engineering
- Change square color via chat (English or other languages)
- Ask about current color - AI always knows the real state
- Manual controls (buttons) - AI stays synchronized
- Multilingual support with automatic translation to HTML color names
┌─────────────────────────────────────────────────────────┐
│ Frontend (React) - Port 5173 │
│ ┌────────────────────────────────────────────────────┐ │
│ │ useCopilotReadable ──→ Sends current state │ │
│ │ - Current color: "blue" │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ useCopilotAction ──→ Defines tools │ │
│ │ - setSquareColor(color) │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────┬───────────────────────────────────────┘
│ HTTP/SSE
↓
┌─────────────────────────────────────────────────────────┐
│ BFF (Express.js) - Port 4000 │
│ ┌────────────────────────────────────────────────────┐ │
│ │ CopilotRuntime │ │
│ │ - Proxies requests to Python backend │ │
│ │ - Handles CopilotKit protocol │ │
│ │ - Manages tool execution flow │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────┬───────────────────────────────────────┘
│ HTTP/SSE
↓
┌─────────────────────────────────────────────────────────┐
│ Backend (FastAPI/Python) - Port 8000 │
│ ┌────────────────────────────────────────────────────┐ │
│ │ LangGraph Agent (chat_node) │ │
│ │ 1. Receives context (current state) │ │
│ │ 2. Receives actions (available tools) │ │
│ │ 3. Builds system message with CURRENT STATE │ │
│ │ 4. Invokes GPT-4o with tools │ │
│ │ 5. Returns response or tool call │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
This project uses a BFF pattern with an Express.js proxy layer:
// client/server/index.ts — BFF (Express)
import {
CopilotRuntime,
copilotRuntimeNodeHttpEndpoint,
ExperimentalEmptyAdapter,
LangGraphHttpAgent,
} from "@copilotkit/runtime";
import express from "express";
const app = express();
const serviceAdapter = new ExperimentalEmptyAdapter();
app.use("/api/copilotkit", (req, res, next) => {
const runtime = new CopilotRuntime({
agents: {
agent: new LangGraphHttpAgent({
url: "http://localhost:8000/api/copilotkit", // Python backend
}),
},
});
const handler = copilotRuntimeNodeHttpEndpoint({
endpoint: "/api/copilotkit",
runtime,
serviceAdapter,
});
return handler(req, res, next);
});
const PORT = Number(process.env.BFF_PORT || 4000);
app.listen(PORT, () => {
console.log(`BFF listening on http://localhost:${PORT}/api/copilotkit`);
});Architecture with BFF:
React Frontend (5173)
↓
Express BFF (4000)
↓
Python Backend (8000)
What the BFF does:
- Acts as a proxy between frontend and Python backend
- Handles CopilotKit Runtime logic (message formatting, tool execution)
- Converts between browser-friendly and LangGraph formats
- Simplifies frontend code — no need to handle LangGraph details directly
Why use a BFF:
- Separation of concerns: frontend does not depend on LangGraph internals
- Type safety: TypeScript on both frontend and BFF
- Flexibility: can add middleware, logging, authentication, etc.
- CORS handling: avoids cross-origin issues by proxying requests
Frontend (React):
// Send current state to AI
useCopilotReadable({
description: "The current color of the square",
value: color, // "blue"
});
// Define actions AI can call
useCopilotAction({
name: "setSquareColor",
description: "Set the color of the square",
parameters: [{ name: "color", type: "string" }],
handler: async ({ color }) => setColor(color),
});Backend (Python):
# Receives automatically from CopilotKit
frontend_data = state["copilotkit"]["context"] # Current state
frontend_tools = state["copilotkit"]["actions"] # Available actionsCopilotKit automatically handles tool execution:
User: "Change to green"
↓
Backend: AI decides to call setSquareColor("green")
↓
CopilotKit: Intercepts tool call, executes in frontend
↓
Frontend: setColor("green") runs
↓
Backend: Receives tool result, AI confirms to user
↓
AI: "I've set the color to green"
- Node.js 18+
- Python 3.11+
- OpenAI API key
- (Optional) LangSmith API key for debugging
- Clone the repository
git clone <repository-url>
cd copilotkit- Frontend Setup
cd client
npm install- Backend Setup
cd server
uv sync # Creates venv and installs dependencies from pyproject.toml- Environment Variables
Create server/.env:
# Required
OPENAI_API_KEY=your_openai_api_key_here
# Optional (for debugging)
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your_langsmith_api_key_here
LANGCHAIN_PROJECT=copilotkit-squareOption A — one command for web + BFF (recommended)
cd client
npm run dev:both # starts BFF and Vite togetherNote: ensure the Python backend is running before issuing requests through the BFF.
Option B — separate terminals
- Backend (Python):
cd server
make start # or: uv run python server.py- Frontend (React) + BFF (Node):
cd client
make start # or: npm run bff & npm run devOption C — one command from the repo root
make start # runs frontend, BFF, and Python backend togetherThe scripts/start-services.sh helper ensures each service is started in the right order and shuts everything down when you stop the command.
Ports and environment
- Frontend (Vite):
5173(default) - BFF (Node/Express):
4000(default, override withBFF_PORT) - Python LangGraph API:
8000(default)
Relevant environment variables
# Required
OPENAI_API_KEY=your_openai_api_key_here
# Optional
BFF_PORT=4000 # overrides BFF listen port
FRONTEND_PORT=5173 # overrides the Vite dev server port
PORT=8000 # overrides the Python FastAPI/uvicorn port
LANGCHAIN_TRACING_V2=true # enable LangSmith tracing
LANGCHAIN_API_KEY=your_langsmith_api_key_here
LANGCHAIN_PROJECT=copilotkit-squareNotes:
- Server-side variables (
OPENAI_API_KEY,LANGCHAIN_*) are used by the Python backend. Place them inserver/.env. BFF_PORTaffects the Node/Express BFF. It can be provided via shell env when runningnpm run bff,npm run dev:both, or the root-levelmake start.FRONTEND_PORTandPORTlet you change the exposed Vite and FastAPI ports respectively when using the new helpers.
You can run the entire stack from a single Docker image. This is useful for local development and production deployment.
make docker-build
# or: docker build -t copilotkit-stack .export OPENAI_API_KEY="your-openai-api-key"
make docker-runThis runs the container with NODE_ENV=development, which:
- Starts Vite dev server with hot reload
- Enables React Fast Refresh
- Provides real-time code updates
export OPENAI_API_KEY="your-openai-api-key"
make docker-run-prodThis runs the container with NODE_ENV=production, which:
- Serves pre-built optimized frontend assets
- Uses production-optimized configurations
- Ideal for deployment environments
You can also run the container manually:
# Development mode
docker run --rm \
-e OPENAI_API_KEY="your-openai-api-key" \
-e NODE_ENV=development \
-e LANGCHAIN_API_KEY="optional-langsmith-key" \
-e LANGCHAIN_PROJECT="copilotkit-square" \
-p 5173:5173 -p 4000:4000 -p 8000:8000 \
copilotkit-stack
# Production mode
docker run --rm \
-e OPENAI_API_KEY="your-openai-api-key" \
-e NODE_ENV=production \
-p 5173:5173 -p 4000:4000 -p 8000:8000 \
copilotkit-stackPort Overrides:
You can override the default ports by setting FRONTEND_PORT, BFF_PORT, and/or PORT environment variables when running the container.
Important Notes:
OPENAI_API_KEYis required at runtime- The image includes both dev dependencies and production build
- The startup script (
scripts/start-services.sh) automatically detectsNODE_ENVand runs the appropriate mode - Default mode is production if
NODE_ENVis not set
copilotkit/
├── client/ # React frontend
│ ├── src/
│ │ ├── App.tsx # Main component with CopilotKit hooks
│ │ └── App.module.css # Styles with CSS variables
│ ├── server/
│ │ └── index.ts # BFF (Backend For Frontend) - Express proxy
│
├── server/ # Python backend
│ ├── server.py # FastAPI server exposing LangGraph
│ ├── agent.py # LangGraph agent implementation
│ ├── .env # Environment variables
│ └── AGENT_EXPLANATION.md # Detailed technical explanation
│
└── README.md # This file
Let's trace what happens when a user asks: "What is the square color?"
// Current React state
const [color, setColor] = useState("red");
// Automatically sends to backend
useCopilotReadable({
description: "The current color of the square",
value: color, // ← "red"
});CopilotKit sends to backend:
{
"copilotkit": {
"context": [
{
"description": "The current color of the square",
"value": "red"
}
]
}
}# Extract frontend data
frontend_data = state["copilotkit"]["context"]
# Build current state string
current_state = "The current color of the square: red\n"
# Create system message
system_message = SystemMessage(content=f"""
===== CURRENT STATE (ALWAYS USE THIS) =====
{current_state}
============================================
CRITICAL: Use ONLY this current state value.
Ignore any different colors mentioned in chat history.
""")messages_to_send = [
system_message, # Instructions + current state
*state["messages"], # Conversation history
state_reminder # Current state reminder (injected at end)
]
response = await model.ainvoke(messages_to_send)GPT-4 receives:
[SystemMessage] CURRENT STATE: red ...
[HumanMessage] "Change to green"
[AIMessage] "I've set it to green"
[HumanMessage] "What is the square color?" ← Current question
[SystemMessage] [REAL-TIME UPDATE] Current: red ← Override!
Despite having said "green" in memory, GPT-4 sees:
- Strong instruction: "Use ONLY current state"
- Current state: "red"
- Recent reminder: "red"
Response: "The current color of the square is red" ✅
return Command(goto="__end__", update={"messages": response})CopilotSidebar shows: "The current color of the square is red"
function Chat() {
const [color, setColor] = useState("blue");
// ... rest of component
}useCopilotReadable({
description: "The current color of the square",
value: color, // AI always gets the current value
});Key point: This runs on every render, so AI always sees the latest state.
useCopilotAction({
name: "setSquareColor",
description: "Set the color of the square",
parameters: [
{
name: "color",
type: "string",
description: "The new color for the square",
},
],
handler: async ({ color }) => {
setColor(color); // Actually changes the state
},
});Key point: CopilotKit automatically:
- Sends this tool definition to the backend
- Executes the handler when AI calls it
- Syncs the result back
return (
<>
<CopilotSidebar
defaultOpen
instructions="You help users change and read the color..."
suggestions={[
{
title: "Change square color",
message: "Choose a new random background color.",
},
{
title: "What is the square color?",
message: "What is the square color?",
},
]}
/>
<div className={styles.container}>
<Square color={color} />
<div className={styles.buttonContainer}>
<button onClick={() => setColor("red")}>Red</button>
<button onClick={() => setColor("blue")}>Blue</button>
<button onClick={() => setColor("green")}>Green</button>
</div>
</div>
</>
);from copilotkit import CopilotKitState
class AgentState(CopilotKitState):
pass # Inherits: messages, copilotkit.context, copilotkit.actionsasync def chat_node(state: AgentState, config: RunnableConfig):
# Get data sent from frontend
copilotkit_state = state.get("copilotkit", {})
frontend_tools = copilotkit_state.get("actions", []) # Tools
frontend_data = copilotkit_state.get("context", []) # Current state
all_tools = tools + frontend_tools # Extract current values from frontend
current_state = ""
for data in frontend_data:
value = data.get("value") if isinstance(data, dict) else getattr(data, "value", None)
desc = data.get("description") if isinstance(data, dict) else getattr(data, "description", "")
if value:
current_state += f"{desc}: {value}\n" system_message = SystemMessage(content=f"""You help users change and read the color of a square.
===== CURRENT STATE (ALWAYS USE THIS) =====
{current_state}
============================================
CRITICAL: The value above is the ONLY source of truth.
Ignore any different colors mentioned in chat history.
RULES:
1. When asked about the color: Answer ONLY using the CURRENT STATE above
2. When asked to choose/change a color: Use setSquareColor tool ONCE, then confirm
3. After you already responded to a request, do NOT repeat the action
4. IMPORTANT: Always use English HTML color names (e.g., "red", "blue", "green")
even if the user requests the color in another language (e.g., "azul" → "blue")""") # Add state reminder at the end for highest priority
state_reminder = SystemMessage(
content=f"[REAL-TIME UPDATE] {current_state}This is the ACTUAL current state RIGHT NOW. Use this value, not what you said before."
)
messages_to_send = [
SystemMessage(content=modified_system_content),
*state["messages"],
state_reminder # ← Injected at the end!
]Why inject twice (start + end)?
- LLMs give more weight to recent messages
- System message at start = general instructions
- System message at end = "this is the truth RIGHT NOW"
model = ChatOpenAI(
model="gpt-4o",
temperature=0.7,
)
# Bind frontend tools to the model
if all_tools:
model_with_tools = model.bind_tools(all_tools, parallel_tool_calls=False)
else:
model_with_tools = model
# Call GPT-4
response = await model_with_tools.ainvoke(messages_to_send, config) await copilotkit_emit_state(config, state) # Sync to frontend
return Command(goto="__end__", update={"messages": response})Add to server/.env:
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your_langsmith_key
LANGCHAIN_PROJECT=copilotkit-squareVisit https://smith.langchain.com to see:
- Every message sent to/from GPT-4
- Tool calls and responses
- Timing and token usage
- Exact state at each step
- CopilotKit Documentation
- LangGraph Documentation
- LangChain Documentation
- OpenAI API Documentation
- LangSmith for Debugging
This is a study project for educational purposes.
Last updated: 2025-10-25
Built with ❤️ to understand CopilotKit Open Source SDK + LangGraph integration