Pydantic AI over AG-UI¶
contrib ยท v0.2.1
Run a Pydantic AI agent over the AG-UI protocol on a chanx websocket: streamed text, tool calls and human-in-the-loop approvals, with the conversation kept server-side and replayed to a reconnecting client as MESSAGES_SNAPSHOT.
| Install | copit add @chanx-kit/pydantic-ai-ag-ui |
| Import from | pydantic_ai_ag_ui |
| Requires kits | ag-ui, conversation-store |
| Contract | ag-ui@1 |
| UI kits | ag-ui |
| Python packages | chanx>=2.10.2,<3, pydantic-ai-slim[ag-ui]>=2.13 |
| Keywords | agent, ag-ui, pydantic-ai, streaming, approvals |
| Source | kits/pydantic_ai_ag_ui |
Run a Pydantic AI agent over the AG-UI protocol on a chanx websocket.
Builds on the ag-ui kit,
which carries the protocol. This kit supplies the agent, the conversation, and the
approval round trip.
Pydantic AI speaks AG-UI itself, so this kit passes its stream through whole via
run_events. A paused run reports itself through RUN_FINISHED, whose outcome
carries the approval request, so a synthesised one would discard it.
Use it¶
Point a topic at an agent and list it on a consumer:
from pydantic_ai import Agent, DeferredToolRequests
from chanx.fast_channels.websocket import AsyncJsonWebsocketConsumer
from .ws_kits.ag_ui import AgUiEventMessage
from .ws_kits.pydantic_ai_ag_ui import PydanticAIAgUiTopic
agent = Agent("openai:gpt-5", output_type=[str, DeferredToolRequests])
class TaskletTopic(PydanticAIAgUiTopic):
agent = agent
class AgentConsumer(AsyncJsonWebsocketConsumer[AgUiEventMessage]):
channel_layer_alias = "default"
topics = [TaskletTopic]
A client subscribes to agui:thread:<thread_id> and sends ag_ui_run. Streamed
text, tool calls and thinking all arrive as AG-UI events, so an AG-UI frontend needs
no changes.
Approvals, without a protocol of your own¶
Mark a tool requires_approval=True and the run pauses by itself:
The paused run ends with RUN_FINISHED carrying an
interrupt โ the tool call, a prompt,
and the JSON schema of the answer it expects. The client resumes by sending another
ag_ui_run with a resume entry:
{
"action": "ag_ui_run",
"payload": {
"threadId": "t1",
"runId": "run-2",
"messages": [],
"resume": [
{"interruptId": "int-call-1", "status": "resolved", "payload": {"approved": true}}
]
}
}
Approval is deny-by-default: only an explicit approved: true runs the tool.
Because the interrupt id travels to every connection on the thread, any tab can
answer it, and the server keeps no pending-approval state of its own.
The conversation stays here¶
AG-UI has the client send the whole message list with every run. This kit keeps the
conversation server-side instead, so messages carries only the new turn โ which is
what makes a resumed approval work with "messages": [].
Persistence comes from the
conversation-store
kit, which holds an opaque string so one backend serves every agent framework. This kit
converts at the edge with ModelMessagesTypeAdapter.
Warning
The default InMemoryConversationStore is process-local and not durable.
Implement ConversationStore against your database before running this anywhere
real.
class TaskletTopic(PydanticAIAgUiTopic):
agent = agent
conversation_store = PostgresConversationStore()
On subscribe the topic sends the conversation back as AG-UI's MESSAGES_SNAPSHOT,
converted by the same adapter that writes the live stream โ so a reload renders the
prompts, tool cards and answers it had before, with no replay protocol of its own.
Set send_transcript = False for a client that keeps its own messages.
Anything else a connection needs goes in send_initial_state, the ag-ui kit's hook,
which runs before the run in flight is replayed:
class TaskletTopic(PydanticAIAgUiTopic):
agent = agent
async def send_initial_state(self) -> None:
await super().send_initial_state()
await self.send_run_event(await self.task_state(), seq=None)
To hand ownership back to the client instead, override load_history to return []
and let run_input.messages be the whole conversation.
State and dependencies¶
Return a StateDeps from agent_deps and AG-UI's state is applied to it before
the run, so tools read it through RunContext:
from pydantic_ai.ui import StateDeps
class TaskletTopic(PydanticAIAgUiTopic):
agent = agent
def agent_deps(self, run_input):
return StateDeps(TaskletState())
Anything that is not a StateDeps (or another StateHandler) leaves state alone.
Use the same hook for per-connection dependencies: the authenticated user, a tenant
id, the thread id.
Several tabs on one agent¶
Set broadcast_run_events from the ag-ui kit and every connection on the thread
watches the run, including one that joins part-way through:
See that kit's README for what the client owes you in return.
Customise¶
| Hook | Default | Purpose |
|---|---|---|
agent |
None |
The agent to run |
get_agent() |
returns agent |
Choose an agent per connection |
agent_deps(run_input) |
None |
Dependencies, and AG-UI state binding |
load_history(run_input=None) |
from conversation_store |
The conversation, for a run or for the transcript |
transcript() |
MESSAGES_SNAPSHOT |
The conversation as AG-UI messages |
save_history(messages) |
to conversation_store |
Where the conversation is written |
on_run_complete(result) |
saves history | React to a finished or paused run |
conversation_store |
InMemoryConversationStore() |
Conversation persistence |