
Write HTML. Render video. Built for agents.
PydanticRPC lets you define request and response models with Pydantic and serve them over gRPC, ConnectRPC, or MCP. It generates protobuf files and service code at runtime or from the CLI, and includes support for async servers, streaming, reflection, health checks, and error mapping.
Builders who want to expose Python models and agent actions as RPC services with less schema plumbing.
You can turn Python methods into RPC endpoints without managing protobuf files by hand.
Builds `.proto` files from Python method signatures and Pydantic model types.
Provides `Server`, `AsyncIOServer`, `ASGIApp`, and `WSGIApp` for synchronous, async, and web-style RPC services.
Exposes services as MCP tools for assistants over stdio or HTTP/SSE.
Handles server streaming, client streaming, and bidirectional streaming in gRPC and ConnectRPC.
Maps Python exceptions to gRPC or Connect status codes with `error_handler`.
Supports Pydantic field and model serializers, including nested serializer strategies.
Includes a CLI package and an option to skip runtime generation with `PYDANTIC_RPC_SKIP_GENERATION`.
Adds gRPC reflection, health checking, and TLS examples and tests.
pip install pydantic-rpc
pip install pydantic-rpc-cli # Includes hypercorn and gunicorn
# Run with uvicorn uv run uvicorn greeting_asgi:app --port 3000 # Or run streaming example uv run python examples/streaming_connect_python.py
PydanticRPC is a Python library that enables you to rapidly expose Pydantic models via gRPC/Connect RPC services without writing any protobuf files. Instead, it automatically generates protobuf files on the fly from the method signatures of your Python objects and the type signatures of your Pydantic models.
Below is an example of a simple gRPC service that exposes a PydanticAI agent:
import asyncio
from openai import AsyncOpenAI
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
from pydantic_rpc import AsyncIOServer, Message
# `Message` is just an alias for Pydantic's `BaseModel` class.
class CityLocation(Message):
city: str
country: str
class Olympics(Message):
year: int
def prompt(self):
return f"Where were the Olympics held in {self.year}?"
class OlympicsLocationAgent:
def __init__(self):
client = AsyncOpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama_api_key",
)
ollama_model = OpenAIModel(
model_name="llama3.2",
openai_client=client,
)
self._agent = Agent(ollama_model)
async def ask(self, req: Olympics) -> CityLocation:
result = await self._agent.run(req.prompt())
return result.data
if __name__ == "__main__":
# New enhanced initialization API (optional - backward compatible)
s = AsyncIOServer(service=OlympicsLocationAgent(), port=50051)
loop = asyncio.get_event_loop()
loop.run_until_complete(s.run())
And here is an example of a simple Connect RPC service that exposes the same agent as an ASGI application:
import asyncio
from openai import AsyncOpenAI
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
from pydantic_rpc import ASGIApp, Message
class CityLocation(Message):
city: str
country: str
class Olympics(Message):
year: int
def prompt(self):
return f"Where were the Olympics held in {self.year}?"
class OlympicsLocationAgent:
def __init__(self):
client = AsyncOpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama_api_key",
)
ollama_model = OpenAIModel(
model_name="llama3.2",
openai_client=client,
)
self._agent = Agent(ollama_model, result_type=CityLocation)
async def ask(self, req: Olympics) -> CityLocation:
result = await self._agent.run(req.prompt())
return result.data
# New enhanced initialization API (optional - backward compatible)
app = ASGIApp(service=OlympicsLocationAgent())
grpcio-tools.pydantic for robust type validation and serialization.grpc_health.v1.AsyncIOServer.connect-pythonWhen using Connect-RPC with ASGIApp:
/<package>.<service>/<Method> (e.g., /chat.v1.ChatService/SendMessage)Content-Type: application/json or application/connect+json for requestsFor detailed examples and testing instructions, see the examples directory.
Install PydanticRPC via pip:
pip install pydantic-rpc
For CLI support with built-in server runners:
pip install pydantic-rpc-cli # Includes hypercorn and gunicorn
Note: All new features are fully backward compatible. Existing code continues to work without modification.
All server classes now support optional initialization with services:
# Traditional API (still works)
server = AsyncIOServer()
server.set_port(50051)
await server.run(MyService())
# New enhanced API (optional)
server = AsyncIOServer(
service=MyService(),
port=50051,
package_name="my.package"
)
await server.run()
# Same for ASGI/WSGI apps
app = ASGIApp(service=MyService(), package_name="my.package")
Automatically map exceptions to gRPC/Connect status codes:
from pydantic_rpc import error_handler
import grpc
class MyService:
@error_handler(ValidationError, status_code=grpc.StatusCode.INVALID_ARGUMENT)
@error_handler(KeyError, status_code=grpc.StatusCode.NOT_FOUND)
async def get_user(self, request: GetUserRequest) -> User:
# Exceptions are automatically converted to proper status codes
if request.id not in users_db:
raise KeyError(f"User {request.id} not found")
return users_db[request.id]
PydanticRPC supports two main protocols:
Server and AsyncIOServerASGIApp and WSGIAppfrom pydantic_rpc import Server, Message
class HelloRequest(Message):
name: str
class HelloReply(Message):
message: str
class Greeter:
# Define methods that accepts a request and returns a response.
def say_hello(self, request: HelloRequest) -> HelloReply:
return HelloReply(message=f"Hello, {request.name}!")
if __name__ == "__main__":
server = Server()
server.run(Greeter())
import asyncio
from pydantic_rpc import AsyncIOServer, Message
class HelloRequest(Message):
name: str
class HelloReply(Message):
message: str
class Greeter:
async def say_hello(self, request: HelloRequest) -> HelloReply:
return HelloReply(message=f"Hello, {request.name}!")
async def main():
# You can specify a custom port (default is 50051)
server = AsyncIOServer(port=50052)
await server.run(Greeter())
if __name__ == "__main__":
asyncio.run(main())
The AsyncIOServer automatically handles graceful shutdown on SIGTERM and SIGINT signals.
from pydantic_rpc import ASGIApp, Message
class HelloRequest(Message):
name: str
class HelloReply(Message):
message: str
class Greeter:
async def say_hello(self, request: HelloRequest) -> HelloReply:
return HelloReply(message=f"Hello, {request.name}!")
app = ASGIApp()
app.mount(Greeter())
# Run with uvicorn:
# uvicorn script:app --host 0.0.0.0 --port 8000
from pydantic_rpc import WSGIApp, Message
class HelloRequest(Message):
name: str
class HelloReply(Message):
message: str
class Greeter:
def say_hello(self, request: HelloRequest) -> HelloReply:
return HelloReply(message=f"Hello, {request.name}!")
app = WSGIApp()
app.mount(Greeter())
# Run with gunicorn:
# gunicorn script:app
PydanticRPC provides native Connect-RPC support via connect-python, including full streaming capabilities and PEP 8 naming conventions. Check out our ASGI examples:
# Run with uvicorn
uv run uvicorn greeting_asgi:app --port 3000
# Or run streaming example
uv run python examples/streaming_connect_python.py
This will launch a connect-python-based ASGI application that uses the same Pydantic models to serve Connect-RPC requests.
connect-python provides full support for streaming RPCs with automatic PEP 8 naming (snake_case):
from typing import AsyncIterator
from pydantic_rpc import ASGIApp, Message
class StreamRequest(Message):
text: str
count: int
class StreamResponse(Message):
text: str
index: int
class StreamingService:
# Server streaming
async def server_stream(self, request: StreamRequest) -> AsyncIterator[StreamResponse]:
for i in range(request.count):
yield StreamResponse(text=f"{request.text}_{i}", index=i)
# Client streaming
async def client_stream(self, requests: AsyncIterator[StreamRequest]) -> StreamResponse:
texts = []
async for req in requests:
texts.append(req.text)
return StreamResponse(text=" ".join(texts), index=len(texts))
# Bidirectional streaming
async def bidi_stream(
self, requests: AsyncIterator[StreamRequest]
) -> AsyncIterator[StreamResponse]:
idx = 0
async for req in requests:
yield StreamResponse(text=f"Echo: {req.text}", index=idx)
idx += 1
app = ASGIApp()
app.mount(StreamingService())
[!NOTE] Please install
protoc-gen-connect-pythonto run the connect-python example.
By default, PydanticRPC generates .proto files and code at runtime. If you wish to skip the code-generation step (for example, in production environment), set the environment variable below:
export PYDANTIC_RPC_SKIP_GENERATION=true
When this variable is set to "true", PydanticRPC will load existing pre-generated modules rather than generating theΖm on the fly.
By default your files will be generated in the current working directory where you ran the code from, but you can set a custom specific directory by setting the environment variable below:
export PYDANTIC_RPC_PROTO_PATH=/your/path
You can also set an environment variable to reserve a set number of fields for proto generation, for backward and forward compatibility.
export PYDANTIC_RPC_RESERVED_FIELDS=1
PydanticRPC supports streaming responses for both gRPC and Connect-RPC services.
If a service class method's return type is typing.AsyncIterator[T], the method is considered a streaming method.
Please see the sample code below:
import asyncio
from typing import Annotated, AsyncIterator
from openai import AsyncOpenAI
from pydantic import Field
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
from pydantic_rpc import AsyncIOServer, Message
# `Message` is just a pydantic BaseModel alias
class CityLocation(Message):
city: Annotated[str, Field(description="The city where the Olympics were held")]
country: Annotated[
str, Field(description="The country where the Olympics were held")
]
class OlympicsQuery(Message):
year: Annotated[int, Field(description="The year of the Olympics", ge=1896)]
def prompt(self):
return f"Where were the Olympics held in {self.year}?"
class OlympicsDurationQuery(Message):
start: Annotated[int, Field(description="The start year of the Olympics", ge=1896)]
end: Annotated[int, Field(description="The end year of the Olympics", ge=1896)]
def prompt(self):
return f"From {self.start} to {self.end}, how many Olympics were held? Please provide the list of countries and cities."
class StreamingResult(Message):
answer: Annotated[str, Field(description="The answer to the query")]
class OlympicsAgent:
def __init__(self):
client = AsyncOpenAI(
base_url='http://localhost:11434/v1',
api_key='ollama_api_key',
)
ollama_model = OpenAIModel(
model_name='llama3.2',
openai_client=client,
)
self._agent = Agent(ollama_model)
async def ask(self, req: OlympicsQuery) -> CityLocation:
result = await self._agent.run(req.prompt(), result_type=CityLocation)
return result.data
async def ask_stream(
self, req: OlympicsDurationQuery
) -> AsyncIterator[StreamingResult]:
async with self._agent.run_stream(req.prompt(), result_type=str) as result:
async for data in result.stream_text(delta=True):
yield StreamingResult(answer=data)
if __name__ == "__main__":
s = AsyncIOServer()
loop = asyncio.get_event_loop()
loop.run_until_complete(s.run(OlympicsAgent()))
In the example above, the ask_stream method returns an AsyncIterator[StreamingResult] object, which is considered a streaming method. The StreamingResult class is a Pydantic model that defines the response type of the streaming method. You can use any Pydantic model as the response type.
Now, you can call the ask_stream method of the server described above using your preferred gRPC client tool. The example below uses buf curl.
% buf curl --data '{"start": 1980, "end": 2024}' -v http://localhost:50051/olympicsagent.v1.OlympicsAgent/AskStream --protocol grpc --http2-prior-knowledge
buf: * Using server reflection to resolve "olympicsagent.v1.OlympicsAgent"
buf: * Dialing (tcp) localhost:50051...
buf: * Connected to [::1]:50051
buf: > (#1) POST /grpc.reflection.v1.ServerReflection/ServerReflectionInfo
buf: > (#1) Accept-Encoding: identity
buf: > (#1) Content-Type: application/grpc+proto
buf: > (#1) Grpc-Accept-Encoding: gzip
buf: > (#1) Grpc-Timeout: 119997m
buf: > (#1) Te: trailers
buf: > (#1) User-Agent: grpc-go-connect/1.12.0 (go1.21.4) buf/1.28.1
buf: > (#1)
buf: } (#1) [5 bytes data]
buf: } (#1) [32 bytes data]
buf: < (#1) HTTP/2.0 200 OK
buf: < (#1) Content-Type: application/grpc
buf: < (#1) Grpc-Message: Method not found!
buf: < (#1) Grpc-Status: 12
buf: < (#1)
buf: * (#1) Call complete
buf: > (#2) POST /grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo
buf: > (#2) Accept-Encoding: identity
buf: > (#2) Content-Type: application/grpc+proto
buf: > (#2) Grpc-Accept-Encoding: gzip
buf: > (#2) Grpc-Timeout: 119967m
buf: > (#2) Te: trailers
buf: > (#2) User-Agent: grpc-go-connect/1.12.0 (go1.21.4) buf/1.28.1
buf: > (#2)
buf: } (#2) [5 bytes data]
buf: } (#2) [32 bytes data]
buf: < (#2) HTTP/2.0 200 OK
buf: < (#2) Content-Type: application/grpc
buf: < (#2) Grpc-Accept-Encoding: identity, deflate, gzip
buf: < (#2)
buf: { (#2) [5 bytes data]
buf: { (#2) [434 bytes data]
buf: * Server reflection has resolved file "olympicsagent.proto"
buf: * Invoking RPC olympicsagent.v1.OlympicsAgent.AskStream
buf: > (#3) POST /olympicsagent.v1.OlympicsAgent/AskStream
buf: > (#3) Accept-Encoding: identity
buf: > (#3) Content-Type: application/grpc+proto
buf: > (#3) Grpc-Accept-Encoding: gzip
buf: > (#3) Grpc-Timeout: 119947m
buf: > (#3) Te: trailers
buf: > (#3) User-Agent: grpc-go-connect/1.12.0 (go1.21.4) buf/1.28.1
buf: > (#3)
buf: } (#3) [5 bytes data]
buf: } (#3) [6 bytes data]
buf: * (#3) Finished upload
buf: < (#3) HTTP/2.0 200 OK
buf: < (#3) Content-Type: application/grpc
buf: < (#3) Grpc-Accept-Encoding: identity, deflate, gzip
buf: < (#3)
buf: { (#3) [5 bytes data]
buf: { (#3) [25 bytes data]
{
"answer": "Here's a list of Summer"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [31 bytes data]
{
"answer": " and Winter Olympics from 198"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [29 bytes data]
{
"answer": "0 to 2024:\n\nSummer Olympics"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [20 bytes data]
{
"answer": ":\n1. 1980 - Moscow"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [20 bytes data]
{
"answer": ", Soviet Union\n2. "
}
buf: { (#3) [5 bytes data]
buf: { (#3) [32 bytes data]
{
"answer": "1984 - Los Angeles, California"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [15 bytes data]
{
"answer": ", USA\n3. 1988"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [26 bytes data]
{
"answer": " - Seoul, South Korea\n4."
}
buf: { (#3) [5 bytes data]
buf: { (#3) [27 bytes data]
{
"answer": " 1992 - Barcelona, Spain\n"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [20 bytes data]
{
"answer": "5. 1996 - Atlanta,"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [22 bytes data]
{
"answer": " Georgia, USA\n6. 200"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [26 bytes data]
{
"answer": "0 - Sydney, Australia\n7."
}
buf: { (#3) [5 bytes data]
buf: { (#3) [25 bytes data]
{
"answer": " 2004 - Athens, Greece\n"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [20 bytes data]
{
"answer": "8. 2008 - Beijing,"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [18 bytes data]
{
"answer": " China\n9. 2012 -"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [29 bytes data]
{
"answer": " London, United Kingdom\n10."
}
buf: { (#3) [5 bytes data]
buf: { (#3) [24 bytes data]
{
"answer": " 2016 - Rio de Janeiro"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [18 bytes data]
{
"answer": ", Brazil\n11. 202"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [24 bytes data]
{
"answer": "0 - Tokyo, Japan (held"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [21 bytes data]
{
"answer": " in 2021 due to the"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [26 bytes data]
{
"answer": " COVID-19 pandemic)\n12. "
}
buf: { (#3) [5 bytes data]
buf: { (#3) [28 bytes data]
{
"answer": "2024 - Paris, France\n\nNote"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [41 bytes data]
{
"answer": ": The Olympics were held without a host"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [26 bytes data]
{
"answer": " city for one year (2022"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [42 bytes data]
{
"answer": ", due to the Russian invasion of Ukraine"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [29 bytes data]
{
"answer": ").\n\nWinter Olympics:\n1. 198"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [27 bytes data]
{
"answer": "0 - Lake Placid, New York"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [15 bytes data]
{
"answer": ", USA\n2. 1984"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [27 bytes data]
{
"answer": " - Sarajevo, Yugoslavia ("
}
buf: { (#3) [5 bytes data]
buf: { (#3) [30 bytes data]
{
"answer": "now Bosnia and Herzegovina)\n"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [20 bytes data]
{
"answer": "3. 1988 - Calgary,"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [25 bytes data]
{
"answer": " Alberta, Canada\n4. 199"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [26 bytes data]
{
"answer": "2 - Albertville, France\n"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [13 bytes data]
{
"answer": "5. 1994 - L"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [24 bytes data]
{
"answer": "illehammer, Norway\n6. "
}
buf: { (#3) [5 bytes data]
buf: { (#3) [23 bytes data]
{
"answer": "1998 - Nagano, Japan\n"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [16 bytes data]
{
"answer": "7. 2002 - Salt"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [24 bytes data]
{
"answer": " Lake City, Utah, USA\n"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [18 bytes data]
{
"answer": "8. 2006 - Torino"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [17 bytes data]
{
"answer": ", Italy\n9. 2010"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [40 bytes data]
{
"answer": " - Vancouver, British Columbia, Canada"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [13 bytes data]
{
"answer": "\n10. 2014 -"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [20 bytes data]
{
"answer": " Sochi, Russia\n11."
}
buf: { (#3) [5 bytes data]
buf: { (#3) [16 bytes data]
{
"answer": " 2018 - Pyeong"
}
buf: { (#3) [5 bytes data]
buf: { (#3) [24 bytes data]
{
"answer": "chang, South Korea\n12."
}
buf: < (#3)
buf: < (#3) Grpc-Message:
buf: < (#3) Grpc-Status: 0
buf: * (#3) Call complete
buf: < (#2)
buf: < (#2) Grpc-Message:
buf: < (#2) Grpc-Status: 0
buf: * (#2) Call complete
%
Empty request/response messages are automatically mapped to google.protobuf.Empty:
from pydantic_rpc import AsyncIOServer, Message
class EmptyRequest(Message):
pass # Automatically uses google.protobuf.Empty
class GreetingResponse(Message):
message: str
class GreetingService:
async def say_hello(self, request: EmptyRequest) -> GreetingResponse:
return GreetingResponse(message="Hello!")
async def get_default_greeting(self) -> GreetingResponse:
# Method with no request parameter (implicitly empty)
return GreetingResponse(message="Hello, World!")
Pydantic's serialization decorators are fully supported:
from typing import Any
from pydantic import field_serializer, model_serializer
from pydantic_rpc import Message
class UserMessage(Message):
name: str
age: int
@field_serializer('name')
def serialize_name(self, name: str) -> str:
"""Always uppercase the name when serializing."""
return name.upper()
class ComplexMessage(Message):
value: int
multiplier: int
@model_serializer
def serialize_model(self) -> dict[str, Any]:
"""Custom serialization with computed fields."""
return {
'value': self.value,
'multiplier': self.multiplier,
'result': self.value * self.multiplier # Computed field
}
The serializers are automatically applied when converting between Pydantic models and protobuf messages.
1. Nested Message serializers are now supported (v0.8.0+)
class Address(Message):
city: str
@field_serializer("city")
def serialize_city(self, city: str) -> str:
return city.upper()
class User(Message):
name: str
address: Address # β Address's serializers ARE applied with DEEP strategy
@field_serializer("name")
def serialize_name(self, name: str) -> str:
return name.upper() # β This IS applied
Serializer Strategy Control: You can control how nested serializers are applied via environment variable:
# Apply serializers at all nesting levels (default)
export PYDANTIC_RPC_SERIALIZER_STRATEGY=deep
# Apply only top-level serializers
export PYDANTIC_RPC_SERIALIZER_STRATEGY=shallow
# Disable all serializers
export PYDANTIC_RPC_SERIALIZER_STRATEGY=none
Performance Impact:
2. New fields added by serializers are ignored
class ComplexMessage(Message):
value: int
multiplier: int
@model_serializer
def serialize_model(self) -> dict[str, Any]:
return {
"value": self.value,
"multiplier": self.multiplier,
"result": self.value * self.multiplier # β Won't appear in protobuf
}
Problem: The result field doesn't exist in the Message definition, so it's not in the protobuf schema.
3. Type must remain consistent
class Ba
Sign in to join the discussion.
No comments yet. Be the first to say what this is good for.

Write HTML. Render video. Built for agents.
Ultra-lightweight, open-source, self-hosted personal AI agent framework in Python with WebUI, tools, memory, MCP, multi-agent workflows, automation, and chat apps
SkillOpt is a text-space optimizer that trains reusable natural-language skills for frozen LLM agents through trajectory-driven edits, validation-gated updates, and deployable best_skill.md artifacts.

Omnigent is an open-source AI agent framework and meta-harness: orchestrate Claude Code, Codex, Cursor, Pi, and custom agents β swap harnesses without rewriting, enforce policies and sandboxing, and collaborate in real time from any device.
A theoretical reconstruction of the Claude Mythos architecture, built from first principles using the available research literature.
π·οΈ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl!