I’ve been spending more time exploring both Pydantic AI and CopilotKit.
What interests me is that they solve different parts of the same problem.
Pydantic AI gives Python developers a clean, Python-first way to define agents. CopilotKit focuses on something equally important: putting useful agent interfaces in front of users.
AG-UI is the protocol layer that makes this pairing particularly interesting.
This article walks through a minimal full-stack setup for connecting a Pydantic AI agent to a CopilotKit UI.
The stack looks like this:
- FastAPI for the backend API
- Pydantic AI for the agent itself
- AG-UI as the shared protocol layer
- Next.js for the frontend
- CopilotKit for the assistant UI
The goal is not to build a sophisticated production assistant yet.
It’s to establish a clean foundation that can be expanded later.
Why this pairing is interesting
Agent logic in Python. Assistant UI in React. AG-UI in the middle.
I like this combination because it keeps the responsibilities easy to reason about. Pydantic AI owns the backend agent behavior, FastAPI exposes the agent over HTTP, AG-UI defines the interaction shape, and CopilotKit gives the browser a ready-made assistant surface.
For a small demo, this may feel unnecessary. For larger systems, it quickly starts paying for itself.
How the pieces fit together
Before getting into files and commands, it helps to name the request flow.
At a high level, the request flow looks like this:
- The browser talks to the Next.js app.
- Next.js exposes a CopilotKit API route.
- The CopilotKit runtime forwards agent requests to the FastAPI backend.
- FastAPI exposes a Pydantic AI agent through the AG-UI adapter.
- The frontend uses a CopilotKit component to interact with the registered agent.
This separation becomes surprisingly valuable later.
It means the agent can stay in Python, the interface can stay in React, and the protocol between them can stay explicit.
That is the architectural boundary this repo is trying to demonstrate.
Code snapshot
The GitHub repository for this project is:
The repository can keep evolving after this article is published, so the exact code for this article is preserved separately:
- Branch:
article/001-pydantic-ai-copilotkit - Tag:
article-001
To check out the exact version used by this article:
git fetch --all --tags
git checkout article-001
Use the branch if you want to inspect or compare the article version by name:
git checkout article/001-pydantic-ai-copilotkit
The main branch may contain newer work that builds on this foundation.
Prerequisites
- Python 3.10+
uv, unless you prefer another Python package manager- Node.js and
pnpm - An API key for a model provider. This article uses OpenAI, but you can adapt the setup for another provider.
Create your project
All terminal commands below are written for Linux or macOS shells. Windows PowerShell users may need to adjust the commands slightly.
Open a terminal in the directory where you want to create the project and run:
uv init pydantic-ai-copilotkit-demo
Change into the new project:
cd pydantic-ai-copilotkit-demo
Sync the environment:
uv sync
Activate the virtual environment:
source .venv/bin/activate
VS Code
If you’re using VS Code, select the virtual environment you just created:
- Press
Ctrl+Shift+P. - Select
Python: Select Interpreter. - Select the interpreter from this project, usually marked as recommended.
Git ignore
If you plan to commit this project to Git, create a .gitignore before your first commit. At minimum, include:
.env
.venv
__pycache__/
Do this before committing so secrets and generated files do not end up in source control.
Backend setup
Install the minimum Python packages needed for this demo:
uv add python-dotenv fastapi uvicorn "pydantic-ai-slim[ag-ui,openai]"
If you’re not using OpenAI, replace the openai extra with the provider you want to use. See Pydantic AI’s slim install documentation for available extras.
I like keeping agents and API routers in separate directories because real projects often grow past a single file.
From the project root, create the backend folders:
mkdir -p backend/agents backend/routers
The structure should now include:
backend/
agents/
routers/
Create backend/.env:
OPENAI_API_KEY=<your_openai_api_key_here>
Do not commit this file to GitHub.
Agent creation
Create backend/agents/simple_agent.py:
from pydantic_ai import Agent
MODEL = "openai:gpt-4.1-mini"
INSTRUCTIONS = "Be extroverted!"
agent = Agent(model=MODEL, instructions=INSTRUCTIONS)
if __name__ == "__main__":
import asyncio
result = asyncio.run(agent.run("What is your name?"))
print(result.response)
For a real agent, the instructions will usually be longer. I recommend moving them into a separate file once they become more than a few lines. In production systems, model names and provider settings also belong in configuration rather than directly inside the agent module.
From the backend directory, run:
uv run --env-file .env python agents/simple_agent.py
If your OpenAI API key is valid, you should see a model response in the terminal.
FastAPI AG-UI endpoint
Next, expose the agent through a FastAPI endpoint that speaks AG-UI.
Architecture
The pieces stay intentionally small
FastAPI backend
Hosts health checks and exposes the Pydantic AI agent through a normal HTTP route.
AG-UI protocol
Gives the backend agent and frontend runtime a shared request and response shape.
CopilotKit frontend
Provides the assistant UI and forwards browser requests through the runtime route.
You could put this directly in main.py, but keeping it in a router makes the project easier to extend later.
The split is modest:
simple_agent.pyowns the Pydantic AI agent.agent_router.pyowns the HTTP route that exposes it.main.pyowns the FastAPI app and router wiring.
Create backend/routers/agent_router.py:
from fastapi import APIRouter, Request, Response
from pydantic_ai.ui.ag_ui import AGUIAdapter
from agents.simple_agent import agent as simple_agent
router = APIRouter()
@router.get("/")
async def root():
return {"message": "Hello, I'm the agent API!"}
@router.get("/health")
async def health():
return {
"api": "agent",
"status": "healthy",
}
@router.post("/simple-agent")
async def simple_agent_endpoint(request: Request) -> Response:
"""AG-UI endpoint for the simple Pydantic AI agent."""
return await AGUIAdapter.dispatch_request(request, agent=simple_agent)
Create backend/main.py:
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from routers.agent_router import router as agent_router
app = FastAPI()
app.include_router(agent_router, prefix="/agent")
@app.get("/")
async def root():
return {"message": "Hello, I'm the main API!"}
@app.get("/health")
async def health():
return {
"api": "main",
"status": "healthy",
}
From the backend directory, run:
uv run uvicorn main:app --reload
Check these URLs in your browser:
http://127.0.0.1:8000/http://127.0.0.1:8000/healthhttp://127.0.0.1:8000/agenthttp://127.0.0.1:8000/agent/health
The AG-UI endpoint itself is POST http://127.0.0.1:8000/agent/simple-agent, so you will call it through CopilotKit rather than opening it directly in the browser.
Next.js UI
This section assumes you have pnpm installed.
Open a new terminal in the project root and run:
pnpm create next-app@latest
Use ui as the project name and accept the recommended defaults.
Change into the UI project:
cd ui
Add the CopilotKit and AG-UI dependencies:
pnpm add @copilotkit/react-ui @copilotkit/react-core @copilotkit/runtime @ag-ui/client openai
Run Next.js:
pnpm dev
Navigate to http://localhost:3000.
For a small local demo, hardcoding http://localhost:8000/agent/simple-agent in the API route works. I prefer a tiny configuration layer because the backend URL usually changes between local development, preview deployments, and production.
It is a little more setup now, but it avoids mixing deployment details into the runtime route.
Create ui/.env.example:
COPILOTKIT_AGENT_BASE_URL=http://localhost:8000/agent/
COPILOTKIT_SIMPLE_AGENT_NAME=simple-agent
For local development, copy that to ui/.env.local:
cp .env.example .env.local
These variables do not need the NEXT_PUBLIC_ prefix because they are only read by the server-side API route.
Create ui/lib/copilot-agent-ids.ts:
export const SIMPLE_AGENT_ID = "simpleAgent";
Create ui/lib/copilot-agent-config.ts:
const DEFAULT_AGENT_BASE_URL = "http://localhost:8000/agent/";
const DEFAULT_SIMPLE_AGENT_NAME = "simple-agent";
function trimSlashes(value: string) {
return value.replace(/^\/+|\/+$/g, "");
}
function withTrailingSlash(value: string) {
return value.endsWith("/") ? value : `${value}/`;
}
export function getSimpleAgentUrl() {
const agentBaseUrl =
process.env.COPILOTKIT_AGENT_BASE_URL ?? DEFAULT_AGENT_BASE_URL;
const simpleAgentName =
process.env.COPILOTKIT_SIMPLE_AGENT_NAME ?? DEFAULT_SIMPLE_AGENT_NAME;
return new URL(trimSlashes(simpleAgentName), withTrailingSlash(agentBaseUrl))
.toString();
}
This keeps three related values distinct:
COPILOTKIT_AGENT_BASE_URLis the FastAPI router base URL.COPILOTKIT_SIMPLE_AGENT_NAMEis the backend route path for this agent.SIMPLE_AGENT_IDis the CopilotKit runtime id used by the frontend.
Keeping SIMPLE_AGENT_ID in its own file lets client components import the id without importing server-side environment configuration.
CopilotKit runtime route
Create ui/app/api/copilotkit/route.ts:
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import { NextRequest } from "next/server";
import { getSimpleAgentUrl } from "@/lib/copilot-agent-config";
import { SIMPLE_AGENT_ID } from "@/lib/copilot-agent-ids";
const serviceAdapter = new ExperimentalEmptyAdapter();
const runtime = new CopilotRuntime({
agents: {
[SIMPLE_AGENT_ID]: new HttpAgent({ url: getSimpleAgentUrl() }),
},
});
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
This route does two important things:
- It lets the browser talk to CopilotKit’s runtime at
/api/copilotkit. - It registers the FastAPI AG-UI endpoint as the
simpleAgentagent.
From the frontend’s point of view, it only needs to know the CopilotKit runtime URL and the agent id.
Assistants route group
This structure is optional, but it keeps CopilotKit-specific pages and layout code separate from the rest of the app.
Create the route group folder:
mkdir -p "ui/app/(assistants)/simple-agent"
Next.js route groups do not affect the URL path, so a page at ui/app/(assistants)/simple-agent/page.tsx is served at /simple-agent, not /assistants/simple-agent.
Create ui/app/(assistants)/layout.tsx:
import {
CopilotKitProvider,
} from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
export default function AssistantsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit">
{children}
</CopilotKitProvider>
);
}
Create ui/app/(assistants)/simple-agent/page.tsx:
"use client";
import { CopilotSidebar } from "@copilotkit/react-core/v2";
import { SIMPLE_AGENT_ID } from "@/lib/copilot-agent-ids";
export default function SimpleAgentPage() {
return (
<div className="flex h-full min-h-0 flex-col gap-4 px-4 pb-6">
<CopilotSidebar agentId={SIMPLE_AGENT_ID} />
</div>
);
}
The agentId must match the key you registered in CopilotRuntime. Importing SIMPLE_AGENT_ID in both places prevents those values from drifting apart.
For this demo, that may seem like a tiny detail.
Once you have multiple agents or multiple assistant screens, it becomes one of those small decisions that keeps the project easier to change.
Try it
With both servers running, navigate to:
http://localhost:3000/simple-agent
You should now have a CopilotKit sidebar connected to the Pydantic AI agent running behind FastAPI.
Next steps
From Minimal Demo To Useful Assistant
This is not the end goal. It is the point where the backend, protocol layer, frontend runtime, and assistant UI are all talking to each other.
Once that foundation works, the interesting work can begin: tools, richer UI, better instructions, graph-based workflows, and agent behavior that starts to feel genuinely useful.
Conclusion
This gives you a minimal assistant built with Pydantic AI, FastAPI, AG-UI, and CopilotKit.
It does not do much yet.
That is exactly the point.
The useful part is the foundation:
- A Python agent running behind FastAPI
- An AG-UI endpoint that CopilotKit can call
- A Next.js runtime route that registers the agent
- A CopilotKit UI component that can interact with it
From here, the next steps are the parts that make the assistant more interesting:
- adding tools
- improving instructions
- building richer CopilotKit UI components
- experimenting with Pydantic Graph
- shaping the demo into more production-shaped agent workflows