Skip to main content
Real backends serve many users, and different users get different capabilities. Hexgate splits that into two pieces:
  • HexgateContext — the per-request scope. Marks “this invocation acts on behalf of alice, with roles X, and these attributes.” Async context manager; pushes a fact-bearing Biscuit through the agent runtime.
  • Role policies — one policy.yaml per role, optionally inheriting from a base mixin. The runtime picks the right one at call time based on the active context’s primary role.
The two are deliberately decoupled: tokens carry identity (who is calling), policy files carry rules (what they can do).

Minimal example

No manual attenuate_for_user, extract_facts, or ToolUseContext plumbing at the call site. The runtime mints the per-request token, picks the billing role’s policy file, and evaluates its constraints against each tool call.

Filtering on attributes (ctx.*)

Beyond the role, a context can carry an open attributes bag that policy constraints read through the ctx.* namespace — turning role-based access into attribute-based (ABAC) filtering:
A missing ctx.<key> fails closed (deny), like any absent reference.
Attributes are exactly as trustworthy as the code that sets them. ctx.* may gate deny decisions, but it carries the same trust contract as user_roles: populate attributes from trusted server-side data (your auth/session/IdP), never from raw client input. Hexgate does not independently verify attribute values.

FastAPI pattern

The scope must enclose the streaming iteration itself, because the role is resolved lazily — read on each tool call as the agent runs (see Notes). The robust shape is to open the scope inside the generator that produces the response:
Don’t scope auth in @app.middleware("http"). Starlette’s BaseHTTPMiddleware returns the response before the StreamingResponse body is iterated, so a async with HexgateContext(...): return await call_next(request) exits the scope before the first event is produced. Because hexgate resolves the role at tool-call time, every call during streaming then reads no role and silently falls back to the default policy — a silent authorization downgrade, no error raised. Scope inside the generator (above), or use a pure ASGI middleware that wraps the send channel — not BaseHTTPMiddleware.
For a non-streaming endpoint, async with HexgateContext(...): return await invoke_agent(...) is fine — the agent runs to completion inside the scope.

HexgateContext fields

Only the first role in user_roles reaches policy selection today (read via primary_role); the rest are carried but inert until multi-role selection lands.

Role policies — one file per role

Agents that need per-role behaviour ship a policies/ directory instead of a single policy.yaml:
Each role file is a complete AgentPolicy. Inheritance is left-to-right, child wins on conflicts:
See constraints for the expression grammar (including ctx.*) and a role-scoped end-to-end walkthrough.

Notes

  • Single-file policies still work. A legacy policy.yaml is treated as the default role — no migration needed.
  • Lazy attenuation. HexgateContext.__aenter__ only pushes a contextvar — the cryptographic work happens inside stream_agent / invoke_agent the first time the agent runs. Errors surface at first agent call, not at scope entry.
  • Local agents skip attenuation. A context scope around a load_local_agent agent logs a warning and runs with no facts. The default policy still applies — use load_hexgate_agent for the full signed chain.
  • Explicit override. Passing tool_use_context= explicitly to stream_agent / invoke_agent wins over an active context scope. Useful for tests or one-off bypass.
  • Sync callers. HexgateContext exposes both async with ctx: and ctx.sync_scope(). The async form is the primary API; the sync mirror exists for CLI loops and Runner.run_sync-style callers.