Design: Gateway API Surface¶
Status: Partially reverted — v0.4 (see update below) Author: Jeryn Mathew Varghese Last updated: 2026-04
Update (2026-08-27): The
@route/@contractdecorator pair andRouteTable. from_class()/merge_contracts_from()described below were removed entirely. They were never actually wired intoHTTPGateway's real request-dispatch path — decorating a method never produced working validation, only unit tests exercised the mechanism by constructing objects by hand. An org-wide audit found zero real usage of@contractanywhere, including in civitas's own examples. Seedocs/milestones.md("Public documentation reliability") for the investigation and an LLM Council review of the fix options. YAMLroutes:(documented below and indocs/gateway.md) remains the sole, fully-working routing mechanism and was never affected by this.
Motivation¶
HTTPGateway provides the network bridge between external HTTP clients and the Civitas message bus. Out of the box it supports default URL conventions (POST /agents/{name}) — enough for internal tooling and simple integrations.
Production agentic APIs need a small but well-defined integration surface on top of that:
- Routes — clean, versioned URLs decoupled from agent names
- Validation — structured request/response contracts with automatic error responses
- Middleware — auth, logging, rate-limiting, without modifying agent code
- OpenAPI docs — auto-generated from agent contracts, zero extra writing
This spec defines that surface. The design principle is the same as HTTPGateway itself: the agent never sees HTTP. All HTTP concerns live at the gateway boundary.
Architecture¶
The agent only ever sees a Message. The gateway owns every HTTP concept — method, status code, headers, validation errors, OpenAPI schema.
Routes¶
Routes map an HTTP method + path to a named agent and a call mode (call for synchronous request-reply, cast for fire-and-forget).
Decorator (colocated with handler)¶
from civitas.gateway import route
class AssistantAgent(GenServer):
@route("POST", "/v1/chat")
async def handle_call(self, message: Message) -> Message | None:
"""Chat with the assistant."""
...
@route("POST", "/v1/notify", mode="cast")
async def handle_cast(self, message: Message) -> None:
"""Send a notification (fire-and-forget)."""
...
Topology YAML (topology-first)¶
- name: api
type: http_gateway
config:
routes:
- path: /v1/chat
agent: assistant
method: POST
mode: call
- path: /v1/notify
agent: assistant
method: POST
mode: cast
- path: /v1/status
agent: assistant
method: GET
mode: call
Path parameters¶
Path segments are extracted and merged into message.payload:
@route("GET", "/v1/sessions/{session_id}/history")
async def handle_call(self, message: Message) -> Message | None:
session_id = message.payload["session_id"] # extracted from path
...
Default routes (no config needed)¶
When no custom routes are defined, the gateway falls back to the default URL convention:
POST /agents/{name} → call(name, body)
POST /agents/{name}/cast → cast(name, body)
GET /agents/{name}/state → call(name, {__op__: "state"})
POST /broadcast → broadcast(body)
Validation¶
Contracts are declared with Pydantic models. The @contract decorator binds a request model and an optional response model to a route.
from pydantic import BaseModel, Field
from civitas.gateway import route, contract
class ChatRequest(BaseModel):
message: str = Field(..., description="The user's message", min_length=1)
session_id: str | None = Field(None, description="Session ID for conversation continuity")
class ChatResponse(BaseModel):
reply: str = Field(..., description="The assistant's response")
tokens_used: int = Field(..., description="Total tokens consumed")
session_id: str = Field(..., description="Session ID (created if not provided)")
class AssistantAgent(GenServer):
@route("POST", "/v1/chat")
@contract(request=ChatRequest, response=ChatResponse)
async def handle_call(self, message: Message) -> Message | None:
body = ChatRequest.model_validate(message.payload)
# ... agent logic ...
return self.reply(
ChatResponse(
reply="...",
tokens_used=120,
session_id=body.session_id or new_session_id(),
).model_dump()
)
Validation behaviour¶
| Scenario | HTTP status | Body |
|---|---|---|
| Request body invalid against schema | 422 | {"detail": [{"loc": [...], "msg": "...", "type": "..."}]} |
| Missing required field | 422 | Same as above |
| Reply payload invalid against response schema | 500 | {"error": "internal: response validation failed"} |
| No schema declared | — | Pass-through (no validation) |
422 error shape matches FastAPI exactly — clients written against FastAPI can migrate to Civitas without changing their error handling.
Schema via YAML¶
For teams that prefer keeping schemas out of agent code:
routes:
- path: /v1/chat
agent: assistant
method: POST
mode: call
schema:
request: myapp.schemas.ChatRequest
response: myapp.schemas.ChatResponse
Middleware¶
Middleware intercepts requests before they reach the route handler. Two forms are supported: stateless async functions and stateful GenServer middleware.
Stateless middleware (async functions)¶
from civitas.gateway import GatewayRequest, GatewayResponse, NextMiddleware
from civitas.config import settings
async def require_api_key(
request: GatewayRequest,
call_next: NextMiddleware,
) -> GatewayResponse:
if request.headers.get("X-API-Key") != settings.api_key.get():
return GatewayResponse(status=401, body={"error": "invalid API key"})
return await call_next(request)
async def log_requests(
request: GatewayRequest,
call_next: NextMiddleware,
) -> GatewayResponse:
response = await call_next(request)
logger.info("%s %s → %d", request.method, request.path, response.status)
return response
Stateful middleware (GenServer)¶
For middleware that needs shared state — rate limiters, session stores, token counters — declare a GenServer child and call it from a thin async wrapper:
class RateLimiter(GenServer):
"""Sliding-window rate limiter: max_requests per window_seconds per client."""
def __init__(self, name: str, max_requests: int = 100, window_seconds: int = 60):
super().__init__(name)
self._max = max_requests
self._window = window_seconds
self._counts: dict[str, list[float]] = {}
async def handle_call(self, message: Message) -> Message | None:
client_id = message.payload["client_id"]
allowed = self._check_and_increment(client_id)
return self.reply({"allowed": allowed, "remaining": self._remaining(client_id)})
def _check_and_increment(self, client_id: str) -> bool: ...
def _remaining(self, client_id: str) -> int: ...
async def rate_limit(request: GatewayRequest, call_next: NextMiddleware) -> GatewayResponse:
result = await request.gateway.call("rate_limiter", {"client_id": request.client_ip})
if not result.payload["allowed"]:
return GatewayResponse(
status=429,
headers={"Retry-After": "60"},
body={"error": "rate limit exceeded"},
)
return await call_next(request)
Middleware registration¶
Global middleware applies to all routes. Route-scoped middleware applies only to that route and runs after global middleware.
- name: api
type: http_gateway
config:
middleware:
- myapp.middleware.log_requests # global, runs first
- myapp.middleware.require_api_key # global
routes:
- path: /v1/chat
agent: assistant
method: POST
mode: call
middleware:
- myapp.middleware.rate_limit # route-scoped, runs after global
Execution order¶
A middleware that returns a response without calling call_next short-circuits the chain.
OpenAPI docs¶
The gateway introspects the route table and @contract schemas at startup and generates a complete OpenAPI 3.1 spec. No extra code required.
What gets auto-generated¶
- Paths — from the route table (method, path, operation ID)
- Request body schema — from the Pydantic
requestmodel in@contract - Response schema — from the Pydantic
responsemodel in@contract - Operation summary — from the handler method's docstring (first line)
- Operation description — from the full docstring
- Tags — from the agent name (all routes on
assistantare taggedassistant) - 422 response — auto-included whenever a request schema is declared
Enriching the spec¶
class ChatRequest(BaseModel):
message: str = Field(..., description="The user's message", examples=["What is Civitas?"])
session_id: str | None = Field(None, description="Session ID for continuity")
class AssistantAgent(GenServer):
@route("POST", "/v1/chat")
@contract(request=ChatRequest, response=ChatResponse)
async def handle_call(self, message: Message) -> Message | None:
"""Chat with the assistant.
Sends a message to the assistant and returns a reply.
Maintains session context when session_id is provided.
"""
...
Results in:
{
"paths": {
"/v1/chat": {
"post": {
"summary": "Chat with the assistant.",
"description": "Sends a message to the assistant and returns a reply.\nMaintains session context when session_id is provided.",
"tags": ["assistant"],
"requestBody": { ... },
"responses": {
"200": { ... },
"422": { "$ref": "#/components/responses/ValidationError" }
}
}
}
}
}
Disabling docs¶
- name: api
type: http_gateway
config:
docs:
enabled: false # disable entirely (production hardening)
# or:
path: /internal/docs # serve at a non-default path
GatewayRequest / GatewayResponse types¶
The middleware API exposes a thin request/response abstraction — not a full ASGI scope, not an HTTP framework object:
@dataclass
class GatewayRequest:
method: str # "GET", "POST", etc.
path: str # "/v1/chat"
path_params: dict[str, str] # {"session_id": "abc123"}
query_params: dict[str, str] # {"limit": "10"}
headers: dict[str, str] # lowercased header names
body: dict # parsed JSON body
client_ip: str
gateway: AgentProcess # reference to the gateway (for calling other agents)
@dataclass
class GatewayResponse:
status: int = 200
body: dict = field(default_factory=dict)
headers: dict[str, str] = field(default_factory=dict)
Full example¶
A complete agentic API with auth, rate-limiting, validation, and auto-generated docs:
# myapp/agents.py
from pydantic import BaseModel, Field
from civitas import GenServer
from civitas.gateway import route, contract
from civitas.messages import Message
class ChatRequest(BaseModel):
message: str = Field(..., min_length=1)
session_id: str | None = None
class ChatResponse(BaseModel):
reply: str
tokens_used: int
session_id: str
class AssistantAgent(GenServer):
@route("POST", "/v1/chat")
@contract(request=ChatRequest, response=ChatResponse)
async def handle_call(self, message: Message) -> Message | None:
"""Chat with the assistant."""
body = ChatRequest.model_validate(message.payload)
reply = await self.llm.chat(...)
return self.reply(ChatResponse(...).model_dump())
# topology.yaml
supervision:
name: root
strategy: ONE_FOR_ONE
children:
- name: api
type: http_gateway
config:
host: "0.0.0.0"
port: 8080
middleware:
- myapp.middleware.require_api_key
- myapp.middleware.log_requests
routes:
- path: /v1/chat
agent: assistant
method: POST
mode: call
middleware:
- myapp.middleware.rate_limit
- name: rate_limiter
type: gen_server
module: myapp.middleware
class: RateLimiter
config:
max_requests: 100
window_seconds: 60
- name: assistant
type: gen_server
module: myapp.agents
class: AssistantAgent
civitas run --topology topology.yaml
# API live at http://0.0.0.0:8080
# Docs at http://0.0.0.0:8080/docs
Implementation plan¶
Phase 1 — Routes + validation (v0.4, with HTTPGateway)¶
civitas/gateway/routing.py—RouteTable,@routedecorator, path parameter extractioncivitas/gateway/contracts.py—@contractdecorator, request/response Pydantic validation, 422 error formattingcivitas/gateway/types.py—GatewayRequest,GatewayResponse,NextMiddleware- Wire route table into
asgi.pyrequest dispatch loop
Phase 2 — Middleware (v0.4)¶
civitas/gateway/middleware.py— middleware chain runner, global + route-scoped registration- YAML middleware loading (dotted path → callable)
gatewayreference onGatewayRequestfor stateful GenServer middleware
Phase 3 — OpenAPI (v0.4)¶
civitas/gateway/openapi.py— spec builder: introspects route table + contracts/openapi.json,/docs(Swagger UI via CDN),/redocendpoints- Docstring → summary/description extraction
docs.enabled/docs.pathconfig options
What this is NOT¶
- No template rendering — Civitas serves APIs, not HTML pages
- No ORM integration — data access lives in GenServers, not in the gateway
- No session management built-in — implement as a GenServer middleware if needed
- No file uploads — multipart/form-data is out of scope for v0.4; add as a future extension
Dependencies¶
No new dependencies beyond what HTTPGateway already requires. OpenAPI spec generation uses pydantic's built-in model_json_schema() — no additional library needed.
Swagger UI and ReDoc are served from their respective CDNs (no static asset bundling required).
Open questions¶
| # | Question | Notes |
|---|---|---|
| Q1 | Should @route be on the GenServer method or declared separately on the gateway? |
Method decorator keeps contract colocated with handler — prefer this |
| Q2 | How does the gateway discover @route decorators on agents it doesn't import directly? |
Gateway reads route table from topology.yaml; decorators register at class definition time via a module-level registry |
| Q3 | Should middleware be async-only or support sync functions too? | Async-only — avoids run_in_executor overhead; sync middleware is a footgun in an async runtime |
| Q4 | How should streaming responses (SSE, chunked) work? | Out of scope for v0.4 — agent returns {"chunks": [...]} and gateway serialises; true streaming deferred |
| Q5 | Should OpenAPI docs be disabled by default in production? | No — leave on by default, document how to disable; many teams want docs in staging/prod |
Acceptance criteria¶
-
@routedecorator maps a GenServer method to an HTTP method + path - Path parameters extracted and available in
message.payload -
@contractvalidates request body against Pydantic model; returns 422 on failure with FastAPI-compatible error shape -
@contractvalidates response payload; returns 500 on mismatch - Global middleware runs before all routes
- Route-scoped middleware runs after global, before validation
- Middleware returning a response without calling
call_nextshort-circuits the chain - Stateful GenServer middleware callable via
request.gateway.call() -
GET /openapi.jsonreturns valid OpenAPI 3.1 spec -
GET /docsserves Swagger UI populated from the spec - Operation summaries populated from handler docstrings
- Tags populated from agent names
-
docs.enabled: falsedisables all doc endpoints - YAML-declared routes and schemas work without
@route/@contractdecorators - ≥ 15 unit tests (route matching, validation, middleware chain, OpenAPI generation)
- ≥ 3 integration tests (real HTTP client through full stack)