HTTP Gateway¶
HTTPGateway exposes Civitas agents as a REST API. It is a supervised AgentProcess that starts a uvicorn ASGI server and translates inbound HTTP requests into call or cast messages on the bus. Agents behind the gateway never see HTTP — they handle Message like any other agent. The gateway handles routing, request parsing, response serialization, middleware, OpenAPI generation, and optional HTTP/3.
pip install 'civitas[http]' # HTTP/1.1 + HTTP/2 (uvicorn + pydantic)
pip install 'civitas[http3]' # adds QUIC / HTTP/3
Minimal setup¶
from civitas import AgentProcess, Runtime, Supervisor
from civitas.gateway import GatewayConfig, HTTPGateway
from civitas.messages import Message
class EchoAgent(AgentProcess):
async def handle(self, message: Message) -> Message | None:
return self.reply({"echo": message.payload.get("text", "")})
config = GatewayConfig(
host="127.0.0.1",
port=8080,
routes=[
{"method": "POST", "path": "/v1/echo", "agent": "echo", "mode": "call"},
],
)
runtime = Runtime(
supervisor=Supervisor("root", children=[
HTTPGateway("api", config=config),
EchoAgent("echo"),
])
)
await runtime.start()
curl -X POST http://127.0.0.1:8080/v1/echo \
-H 'Content-Type: application/json' \
-d '{"text": "hello"}'
# → {"echo": "hello"}
Route configuration¶
Routes are declared in GatewayConfig.routes as a list of dicts:
| Field | Type | Description |
|---|---|---|
method |
str |
HTTP method: "GET", "POST", "PUT", "DELETE", etc. |
path |
str |
URL path, optionally with {param} placeholders |
agent |
str |
Name of the target agent |
mode |
"call" | "cast" |
call waits for a reply; cast returns 202 immediately |
Path parameters are extracted and merged into the message payload automatically:
routes=[
# {session_id} is extracted into message.payload["session_id"]
{"method": "GET", "path": "/sessions/{session_id}", "agent": "session_store", "mode": "call"},
{"method": "POST", "path": "/sessions/{session_id}/messages", "agent": "chat", "mode": "call"},
{"method": "DELETE","path": "/sessions/{session_id}", "agent": "session_store", "mode": "cast"},
]
Query parameters are also merged into the payload. If both path_params and query_params contain the same key, path params win.
Default routes¶
If routes is empty, the gateway registers three default routes for every agent in the supervision tree:
| Method | Path | Mode | Description |
|---|---|---|---|
POST |
/agents/{name} |
call |
Send a message and wait for reply |
POST |
/agents/{name}/cast |
cast |
Fire-and-forget, returns 202 |
GET |
/agents/{name}/state |
call |
Fetch agent state (agent must handle type: "state") |
The default routes are a development convenience. For production, declare explicit routes.
GatewayConfig reference¶
| Field | Type | Default | Description |
|---|---|---|---|
host |
str |
"0.0.0.0" |
Bind address |
port |
int |
8080 |
HTTP port |
port_quic |
int \| None |
None |
UDP port for HTTP/3 / QUIC |
tls_cert |
str \| None |
None |
Path to TLS certificate file |
tls_key |
str \| None |
None |
Path to TLS private key file |
request_timeout |
float |
30.0 |
Seconds before a call times out with 504 |
enable_http3 |
bool |
False |
Enable QUIC / HTTP/3 (requires TLS + port_quic) |
routes |
list[dict] |
[] |
Route declarations (empty = default routes) |
middleware |
list[str] |
[] |
Dotted import paths for middleware callables |
docs_enabled |
bool \| None |
None |
Tri-state: None auto-enables Swagger UI unless gateway auth is configured; True/False forces it |
docs_path |
str |
"/docs" |
Base path for API docs |
tls_ca_cert |
str \| None |
None |
Path to a private CA cert, for verifying client certs in mTLS |
client_cert_mode |
str |
"none" |
mTLS mode: "none", "optional", or "required" |
mtls_source |
str |
"direct" |
"direct" (civitas reads the TLS peer cert itself) or "proxy_header" (trust an upstream proxy's Client-Cert header) |
trusted_proxy_cidrs |
frozenset[str] |
frozenset() |
Peer IP ranges trusted to supply Client-Cert in proxy_header mode |
grpc_enabled |
bool |
False |
Also serve a gRPC surface alongside HTTP |
grpc_port |
int \| None |
None |
Port for the gRPC surface |
grpc_reflection |
bool |
True |
Enable gRPC server reflection |
ws_routes |
list[dict] |
[] |
WebSocket route declarations |
stream_queue_maxsize |
int |
256 |
Max buffered messages for a stream_reply() |
stream_idle_timeout |
float |
300.0 |
Seconds of inactivity before an open stream is closed |
max_stream_duration |
float |
3600.0 |
Hard cap on a single stream's lifetime |
Request/response validation¶
There is currently no built-in request/response schema validation. An earlier
@route/@contract decorator pair (Pydantic-based, advertised as adding automatic 422/500
validation) was removed (2026-08-27): it was never actually wired into HTTPGateway's real
dispatch path — decorating a method never produced working validation, since HTTPGateway
builds and owns its RouteTable from YAML routes: alone. An org-wide audit found zero real
usage of the decorators anywhere, including in civitas's own examples; see
docs/milestones.md ("Public documentation reliability") for the investigation. If you need
request/response validation today, validate inside handle() yourself (e.g. YourModel.
model_validate(message.payload), returning self.reply({"error": ...}) on failure) — the
gateway itself performs no schema checks.
Middleware¶
Middleware are async callables that wrap every request before it reaches the agent. Declare them as dotted import paths in GatewayConfig.middleware. They execute in order — first entry is the outermost wrapper.
# myapp/middleware.py
from civitas.gateway import GatewayRequest, GatewayResponse
async def require_api_key(request: GatewayRequest, next_fn) -> GatewayResponse:
if request.headers.get("x-api-key") != "secret":
return GatewayResponse(status=401, body={"error": "unauthorized"})
return await next_fn(request)
async def log_requests(request: GatewayRequest, next_fn) -> GatewayResponse:
import logging, time
t0 = time.monotonic()
response = await next_fn(request)
elapsed = (time.monotonic() - t0) * 1000
logging.getLogger("gateway").info("%s %s → %d (%.1fms)",
request.method, request.path, response.status, elapsed)
return response
config = GatewayConfig(
port=8080,
middleware=[
"myapp.middleware.require_api_key", # runs first
"myapp.middleware.log_requests",
],
routes=[...],
)
To short-circuit the chain (e.g. for auth failures), return a GatewayResponse directly without calling next_fn.
GatewayRequest fields¶
| Field | Type | Description |
|---|---|---|
method |
str |
HTTP method ("GET", "POST", etc.) |
path |
str |
Request path, e.g. "/v1/chat/abc123" |
path_params |
dict[str, str] |
Extracted {param} values from the route pattern |
query_params |
dict[str, str] |
URL query string parameters |
headers |
dict[str, str] |
Lowercase header names |
body |
dict |
Parsed JSON request body |
client_ip |
str |
Remote client IP address |
gateway |
HTTPGateway |
Reference to the gateway process |
client_cert |
dict \| None |
mTLS leaf, set by the ASGI edge only when the client presents one: {"dn": <full subject DN>, "leaf_pem": <PEM>} |
auth |
dict \| None |
Verified identity set by auth middleware (e.g. {"id": "<principal>"}), never merged into the dispatched payload |
GatewayResponse fields¶
| Field | Type | Description |
|---|---|---|
status |
int |
HTTP status code (default 200) |
body |
dict |
Response body, serialized to JSON |
headers |
dict[str, str] |
Additional response headers |
WebSocket & gRPC auth¶
When JWT (require_jwt) and/or mTLS (client_cert_mode) auth is configured for the HTTP surface, that protection is automatically inherited by the WebSocket and gRPC surfaces — no extra config keys. This closes the gap where an operator who configured require_jwt got HTTP-only protection while WS and gRPC stayed open.
- WebSocket (JWT only). The bearer token rides the
Sec-WebSocket-Protocolhandshake header as a single pinned subprotocol,civitas.bearer.<jwt>. It is verified before the socket is accepted; a missing or invalid token closes the handshake (WS close code4401) and the negotiated subprotocol is echoed on success. WS mTLS is not yet supported (see #25) — a gateway configured withclient_cert_modebut no JWT logs a startup warning that its WS routes are unauthenticated. - gRPC (JWT + mTLS). A server interceptor verifies the
authorization: Bearer <jwt>metadata entry and, whenclient_cert_mode="required", enforces transport-level mTLS (require_client_auth) plus the same DN allowlist HTTP uses (CIVITAS_GATEWAY_MTLS_ALLOWED_DNS). TheHealthandServerReflectionservices are exempt so load-balancer probes and reflection clients keep working.client_cert_mode="optional"is rejected at startup for gRPC (grpc.aio has noCERT_OPTIONALequivalent), and enforcing JWT over a plaintext (non-TLS) gRPC port is refused (the token would travel in cleartext).
See design/gateway-ws-grpc-auth.md for the full rationale (decisions D1–D11).
HTTP mTLS: two working modes¶
Updated 2026-08-23:
mtls_source="direct"is now fixed and real (R10, design/gateway-http-mtls-direct.md) -- bothdirectandproxy_headerare valid, working modes today. Pickdirectfor a genuinely self-hostable, single-process deployment (no reverse proxy required); pickproxy_headerwhen TLS is already terminated at a real reverse proxy (nginx/Envoy/Traefik) in front of civitas.
require_client_cert authorizes a request on the client certificate's full subject DN, using the
exact-match allowlist in CIVITAS_GATEWAY_MTLS_ALLOWED_DNS -- unchanged by either mode below.
direct mode (default)¶
uvicorn itself never exposes the client certificate from its own TLS handshake to the ASGI app
(uvicorn#400) -- civitas works around this with
TlsAwareHttpToolsProtocol, a thin uvicorn HTTP protocol subclass that reads the real peer
certificate straight off the TLS transport (ssl_object.getpeercert(binary_form=True)) and
populates the same ASGI extension shape require_client_cert already expects. Wired in
automatically whenever client_cert_mode != "none" and mtls_source is left at its default
("direct") -- no extra config needed:
config = GatewayConfig(
port=8443,
tls_cert="server.pem",
tls_key="server.key",
tls_ca_cert="ca.pem", # a dedicated private CA -- never a public/broad one
client_cert_mode="required",
middleware=["civitas.gateway.mtls.require_client_cert"],
)
See design/gateway-http-mtls-direct.md for the full mechanism and how it was verified empirically before implementation.
proxy_header mode¶
An alternative pattern for when TLS is already terminated at a reverse proxy: it forwards the verified client certificate to civitas as the IETF-standard RFC 9440 Client-Cert header. civitas decodes it, extracts the DN, and feeds it into the unchanged DN-allowlist authorization.
Enable it with two new GatewayConfig fields:
config = GatewayConfig(
port=8080,
mtls_source="proxy_header", # trust an upstream proxy's Client-Cert header
trusted_proxy_cidrs=frozenset({"10.0.0.0/8"}), # ...only from these peer IPs
client_cert_mode="none", # required in proxy_header mode
middleware=["civitas.gateway.mtls.require_client_cert"], # required — authorizes the DN
)
The DN allowlist is unchanged: set CIVITAS_GATEWAY_MTLS_ALLOWED_DNS (semicolon-separated full subject DNs). In topology YAML:
- name: api
type: http_gateway
config:
port: 8080
middleware:
- civitas.gateway.mtls.require_client_cert
auth:
mtls:
mtls_source: proxy_header
trusted_proxy_cidrs:
- 10.0.0.0/8
What changes in this mode¶
- Trust is keyed on the true TCP peer IP. civitas checks the immediate peer against
trusted_proxy_cidrsbefore reading the header; a request from outside the set has itsClient-Certignored entirely (treated as no certificate →401). civitas forces uvicorn'sproxy_headers=Falsehere, so a client-suppliedX-Forwarded-Forcan never rewrite the peer IP the check reads. client_ipbecomes the proxy's IP. Because uvicorn's proxy-header handling is disabled,GatewayRequest.client_ip(used by rate limiting and access logs) is the proxy's address, not the originating client's. Recovering the real client IP fromX-Forwarded-Forfor those consumers is a deliberate non-goal in this mode.client_cert_modemust be"none". Requesting a direct-TLS client cert and trusting a proxy-forwarded one on the same HTTP surface is contradictory (validated at config time). If the same deployment also needs gRPC with direct required mTLS, run gRPC on a separateHTTPGatewayinstance withclient_cert_mode="required"andmtls_sourceleft at its default.require_client_certmust be inmiddleware. Otherwise the certificate is extracted every request but never authorized — a silently open gateway. The gateway refuses to start in that combination.- Keep
trusted_proxy_cidrsas narrow as the topology allows (the proxy's actual address(es)). Widening it re-opens the header-injection risk the trust check exists to close.
Wire format (RFC 9440)¶
The proxy MUST send the leaf certificate as:
— the base64 encoding of the raw DER certificate (no line breaks), delimited with a colon on each side (an RFC 8941 Structured-Field Byte Sequence). This is base64 DER, not PEM. Per RFC 9440 §4 the proxy MUST strip any client-supplied Client-Cert/Client-Cert-Chain header and set its own — civitas has no visibility into what the proxy received, so this strip-then-set sanitization is the operator's responsibility. civitas does not read Client-Cert-Chain.
RFC 9440 emission is still nascent across proxies. As of 2026-07, nginx has no native support (nginx#178), Envoy's native
Client-Certfilter is in review upstream (its shipping mechanism is XFCC, a different format), and Traefik emits a base64-DER cert under a different header name. The examples below produce the exactClient-Cert: :<base64-DER>:civitas expects; verify directive syntax against your proxy's version.
nginx¶
nginx has no built-in RFC 9440 support, but a PEM certificate body is base64 DER once the -----BEGIN/END----- armor and line breaks are removed. $ssl_client_raw_cert (a documented variable, populated when ssl_verify_client is on) gives the PEM; an njs function wraps it per RFC 9440:
// /etc/nginx/njs/client_cert.js
function client_cert(r) {
var pem = r.variables.ssl_client_raw_cert;
if (!pem) {
return ""; // no cert -> empty header -> civitas sees no cert -> 401
}
// PEM body IS base64(DER); strip the armor + whitespace and wrap per RFC 9440 §2.2.
var b64 = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
return ":" + b64 + ":";
}
export default { client_cert };
http {
js_path "/etc/nginx/njs/";
js_import cc from client_cert.js;
js_set $rfc9440_client_cert cc.client_cert;
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
# Terminate mTLS against your dedicated private client CA.
ssl_client_certificate /etc/nginx/tls/client-ca.crt;
ssl_verify_client on;
location / {
# strip-then-set: proxy_set_header overwrites any client-supplied value (RFC 9440 §4).
proxy_set_header Client-Cert $rfc9440_client_cert;
proxy_set_header Client-Cert-Chain "";
proxy_pass http://127.0.0.1:8080;
}
}
}
Envoy¶
Envoy's built-in client-cert forwarding uses the XFCC (x-forwarded-client-cert) header, whose Cert element is URL-encoded PEM — a different format civitas does not parse. A native RFC 9440 Client-Cert filter is in review upstream; until it ships in your build, emit the header with a Lua filter. forward_client_cert_details: SANITIZE_SET strips any inbound XFCC so it can't be spoofed (RFC 9440 §4):
http_filters:
- name: envoy.filters.http.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
default_source_code:
inline_string: |
function envoy_on_request(request_handle)
-- strip-then-set: drop any client-supplied values first (RFC 9440 §4).
request_handle:headers():remove("client-cert")
request_handle:headers():remove("client-cert-chain")
local ssl = request_handle:streamInfo():downstreamSslConnection()
if ssl == nil or not ssl:peerCertificatePresented() then
return
end
-- urlEncodedPemEncodedPeerCertificate() -> URL-encoded PEM; decode, drop the
-- armor, keep the base64(DER) body, wrap per RFC 9440 §2.2.
local pem = ssl:urlEncodedPemEncodedPeerCertificate():gsub("%%(%x%x)", function(h)
return string.char(tonumber(h, 16))
end)
local b64 = pem:gsub("%-+[^%-]+%-+", ""):gsub("%s+", "")
request_handle:headers():add("client-cert", ":" .. b64 .. ":")
end
# ... your router filter ...
The DownstreamTlsContext on the listener still terminates mTLS (require_client_certificate: true with your client CA in validation_context). Verify the Lua SSL methods against your Envoy version, or adopt the native filter above once released.
Traefik¶
Traefik's passTLSClientCert middleware (pem: true) forwards the client certificate as base64 DER (the PEM body with delimiters and newlines removed) in the X-Forwarded-Tls-Client-Cert header:
# dynamic configuration
http:
middlewares:
client-cert:
passTLSClientCert:
pem: true # -> X-Forwarded-Tls-Client-Cert: <base64-DER>
routers:
api:
rule: "Host(`api.example.com`)"
middlewares:
- client-cert
tls:
options: mtls # a TLSOption with clientAuth.clientAuthType: RequireAndVerifyClientCert
service: civitas-api
The encoding is right (base64 DER), but the header name (X-Forwarded-Tls-Client-Cert, not Client-Cert) and the missing colon delimiters mean it is not directly RFC-9440-consumable, and Traefik's built-in middleware cannot rename a header or wrap its value. Bridge it to Client-Cert: :<base64-DER>: with a Traefik plugin (Yaegi) or a one-hop sidecar (e.g. the nginx map/njs snippet above reading $http_x_forwarded_tls_client_cert) that also strips any client-supplied Client-Cert. This is the one proxy of the three without a turn-key RFC 9440 story today.
OpenAPI and Swagger UI¶
The gateway generates an OpenAPI spec from the declared routes. Documentation is enabled by default:
Disable it in production:
Every route shows a generic object schema for its request body and 200/202 response — there is no per-route schema generation (see Request/response validation above).
Topology YAML¶
supervision:
name: root
strategy: ONE_FOR_ONE
children:
- name: api
type: http_gateway
config:
host: "0.0.0.0"
port: 8080
request_timeout: 15.0
docs_enabled: true
middleware:
- myapp.middleware.require_api_key
routes:
- method: POST
path: /v1/chat/{session_id}
agent: chat
mode: call
- method: GET
path: /v1/sessions/{session_id}
agent: session_store
mode: call
- name: chat
type: myapp.agents.ChatAgent
- name: session_store
type: myapp.agents.SessionStore
HTTP/3 / QUIC¶
Enable HTTP/3 with TLS certificates and a UDP port:
config = GatewayConfig(
host="0.0.0.0",
port=8443,
port_quic=8444,
tls_cert="/etc/certs/server.crt",
tls_key="/etc/certs/server.key",
enable_http3=True,
routes=[...],
)
The gateway injects Alt-Svc: h3=":8444" into every HTTP/1.1 and HTTP/2 response. Clients that support HTTP/3 will upgrade automatically on the next request.
enable_http3 requires pip install 'civitas[http3]' and valid TLS credentials — the gateway will raise ValueError at startup if either is missing.
Trace context and message type¶
Two special headers influence gateway behavior:
traceparent (W3C trace context) — if present, the gateway extracts the trace_id and parent_span_id from the header and stamps them onto the outgoing message. This connects external traces to the Civitas trace tree.
X-Civitas-Type — overrides the type field on the outgoing message. By default the gateway sets type to "http.request". Use this header to dispatch to a specific handler in a multi-type agent.
What the gateway does¶
WebSocket support. Declare ws_routes in GatewayConfig to bridge a WebSocket session to an agent: inbound frames become cast messages, agent replies (including stream_reply() chunks) become outbound frames. When JWT auth is configured, the bearer token rides the Sec-WebSocket-Protocol handshake header — see WebSocket & gRPC auth above.
gRPC surface. Set grpc_enabled=True and grpc_port to serve the same agents over gRPC alongside HTTP, including server reflection and the same JWT/mTLS auth stack.
First-party rate limiting. civitas.gateway.ratelimit.RateLimiter (a GenServer) plus the rate_limit middleware implement a sliding-window limiter shared across every request. Wire it from topology YAML:
children:
- name: rate_limiter # the fixed name the middleware calls
type: gen_server
module: civitas.gateway.ratelimit
class: RateLimiter
config: {max_requests: 100, window_seconds: 60}
# in the gateway's own config:
middleware: [civitas.gateway.ratelimit.rate_limit]
What the gateway does not do¶
No TLS termination proxy. The gateway can serve TLS directly via uvicorn's SSL support, but it is not a reverse proxy. Put nginx or Caddy in front if you need load balancing, certificate management, or connection pooling at scale.
No built-in identity/AuthZ system. civitas ships the auth seam (middleware chain), JWT bearer verification, and mTLS — but no roles, scopes, SCIM, or IdP integration. Implement your own AuthZ in a middleware callable and declare it in GatewayConfig.middleware; see Control-plane write actions & the AuthNZ seam below.
Control-plane write actions & the AuthNZ seam (v0.9.6)¶
A topology_server node (served by HTTPGateway since v0.9.5) exposes read-only
introspection (/topology, /agents, /metrics, …) and, since v0.9.6, two
control-plane write actions:
| Method | Path | Effect |
|---|---|---|
POST |
/agents/{name}/suspend |
Suspend an agent (optional JSON {"reason", "category"}) |
POST |
/agents/{name}/resume |
Resume a suspended agent |
POST |
/agents/{name}/restart |
Force-restart (kill) an agent — crashes it; its supervisor restarts it per policy |
GET |
/agents/{name}/mailbox |
Non-destructive mailbox peek (message metadata only, never payloads) |
POST |
/agents/{name}/mailbox |
Inject an application message (reserved _agency./civitas. types rejected) |
Every write action is audited, with the authenticated principal recorded as the actor
(initiated_by / approver).
GET /processes additionally reports the deployment shape —
{"deployment": {"transport": "in_process"|"zmq"|"nats", "mode":
"single_process"|"multi_process"|"distributed"}, ...} — and a per-process
container: {"containerized": bool, "orchestrator": ...} hint, so a client can
tell single-vs-multi-process (and containerization) explicitly rather than
inferring it from row counts. Read-only reporting; civitas does not manage
containers. civitas does not ship AuthZ — no roles, scopes, SCIM, or
IdP integration. It ships the seam, an honest audit binding, and a safe
default:
- The seam is the middleware chain. Plug in a middleware that authenticates
against your own system (SCIM / IdP / OPA / anything), denies with a
403if it wants (that is your AuthZ — civitas never sees a role), and on allow sets one field:request.auth["principal"] = {"id": "<who this is>"}(a dict; add any sibling keys you like — civitas reads onlyid).require_jwtalready sets it from the JWTsub. - The honest audit binding. The authenticated principal — never a
client-supplied body field — is recorded as the audited actor
(
initiated_byfor suspend,approverfor resume). A client cannot spoof it by putting__principal__in the request body; the gateway injects the authenticated identity last. - The safe default. With no auth middleware configured, writes still work
and the principal is
{"id": "unauthenticated"}— the localhost bind (127.0.0.1by default) is the security control. A single dev needs no ceremony. The gateway logs a loud warning (not a block) if write routes are served on a non-localhost bind with no auth middleware.
Configure it on the topology_server node — auth.middleware gates every route
except /health (which stays reachable for liveness probes):
- type: topology_server
name: topo
config:
host: 127.0.0.1
port: 6789
auth:
middleware: [myapp.auth.require_my_auth] # your seam; omit for a single dev
See examples/control_plane_auth.py for a runnable "bring your own auth"
middleware, and design/control-plane-writes.md
for the full rationale.
See also¶
- messaging.md — call vs. cast semantics, message routing
- supervision.md — supervising the gateway alongside agents
- observability.md — W3C trace context propagation
- topology.md — YAML topology configuration