How a request flows¶
Every call an agent makes travels the same request path. Each guarantee Pontifex offers, authentication, least privilege, and audit, is a stage on that path. Follow the path and you understand the product.
flowchart TB
req["Request"] --> auth["Authenticate<br/>API key or JWT"]
auth --> id["CallerIdentity"]
id --> rl["Rate limit"]
rl --> scope["Scope check"]
scope --> tool["Tool handler"]
tool --> audit["Audit log"]
Nothing reaches your code until the call has a verified identity, that identity sits within its rate limit, and its scopes permit the tool. Nothing leaves without a row in the audit log.
Authentication¶
Two credential types resolve to one identity.
-
API keys
Tokens prefixed
sk_…, for scripts, CI, and machine-to-machine callers. Pontifex hashes them at rest and never stores the plaintext. -
OAuth 2.1 JWTs
For interactive clients (Claude Desktop, agents). Pontifex validates them against your provider's JWKS. Any OIDC provider works: Auth0, Entra, Clerk, Keycloak.
Both produce a CallerIdentity: a stable owner_id, the granted scopes, and a
rate_limit_rpm.
Downstream code never knows which credential the caller used. The scope check, the audit, and the rate limit run the same way either way. To wire each path, see Authenticate callers.
flowchart LR
key["sk_… API key"] --> res["API-key resolver"]
jwt["OAuth 2.1 JWT"] --> val["JWKS validation"]
res --> id["CallerIdentity<br/>owner · scopes · rate limit"]
val --> id
id --> path["Same path from here:<br/>rate limit · scope check · audit"]
Note
JWT validation is asymmetric-only and rejects alg: none. A caller can't forge a
claim to raise their rate limit or widen their scopes. Those come from server
config, not the token.
Scopes¶
Permissions take the form namespace:resource:action. For example,
orders:order:read.
A tool declares the scope it requires. The runtime checks it before the handler runs.
| Scope | Grants |
|---|---|
orders:order:read |
one tool |
orders:*:read |
read across the whole namespace |
orders:*:* |
full access to the namespace |
Wildcards let you grant breadth on purpose, not by accident. A caller gets scopes from their API key or their JWT claims and can never expand them at runtime. For the full match rules, see Errors & scopes.
The tool runtime¶
tool_runtime is the decorator that wraps each handler. It applies the guarantees
around your code, doing four things:
- Checks the scope. No
namespace:resource:action? The call is denied with a structured error. - Runs your handler. You return plain data. The one exception you raise is
InvalidInput, for bad arguments. - Writes the audit row. Who called, what, when, which data source, cache hit, latency.
- Normalizes errors. Success passes through unchanged. A raised error becomes a
structured
ToolError, and no stack traces leak to the caller.
Your handler stays small and namespace-focused. The cross-cutting concerns live in the decorator, applied the same way to every tool.
Data adapters¶
A tool never makes external calls itself. It goes through the DataAdapter
protocol.
A DataSourceManager orders adapters by health and tracks their success and
failure, so a tool can walk the available sources and fail over when one is down.
flowchart TB
tool["Tool"] --> cache{"In cache?"}
cache -->|hit| out["Return data"]
cache -->|miss| mgr["DataSourceManager"]
mgr --> a1["Adapter A · priority 1"]
a1 -->|ok| out
a1 -.->|down / breaker open| a2["Adapter B · fallback"]
a2 -->|ok| out
Tip
Keeping I/O behind adapters makes tools testable and resilient. Adapters are also
where Cache, async_retry, and CircuitBreaker plug in. Pontifex contains one
flaky upstream rather than handing it to the caller. To build one, see
Resilient adapters.
Connectors¶
Already have an OpenAPI spec? A connector generates the tools for you.
Each generated tool is still wrapped in tool_runtime, with a derived scope, still
calling through a DataAdapter. The request path stays the same. Only the authoring
changes. That is how you onboard a system with config instead of code.
Audit¶
Every tool call produces an AuditRecord, written by an AuditWriter.
DbAuditWriterpersists to Postgres. The production default.NoopAuditWriterdiscards. For tests.
This trail gives you the durable answer for compliance and incident response: who touched what, and when. The writer is a protocol, so you can route audit events to your own sink too.