agentgateway + AxonFlow Integration
Prerequisites: AxonFlow Enterprise ≥ 9.7.0 (Getting Started), agentgateway v1.3.1+, the axonflow-gateway-adapters binary (built from the AxonFlow Enterprise source tree — see below).
- Interception point: agentgateway's three native external-policy seams —
mcpGuardrails/ExtMcp (MCP method layer),extAuthz(Envoy ext_authz, HTTP headers), andextProc(Envoy ext_proc, HTTP bodies) — each calling the AxonFlowaxonflow-gateway-adaptersgRPC service, which translates to Decision Mode (/api/v1/decide+ the engine's check-input/check-output endpoints). - Governs (can block): MCP
tools/call(and other MCP methods) on both request and response; LLM/HTTP requests and responses via ext_proc; HTTP requests via ext_authz. Prompt and tool content is policy-checked and can be denied or engine-redacted. Exception: an ext_proc leg configured for streaming completions (responseBodyMode: none, adapter opt-in) governs the request only — the streamed response body is not scanned. - Fail-closed on both sides: agentgateway's seams default to
failClosed/deny, and the adapters block on an unreachable PDP (request plane configurable; the response plane is unconditionally fail-closed).
Why pair agentgateway with AxonFlow
agentgateway (a Linux Foundation project) is a high-performance data plane for agentic traffic: MCP multiplexing, A2A, LLM provider routing, JWT validation, rate limiting — and its own inline guardrails. It is a Policy Enforcement Point.
What a data plane structurally does not carry is the regulated-market decision layer, and that is the layer AxonFlow provides as the Policy Decision Point its callouts consult:
- Regulated-market PII decisioning — Indonesia NIK (checksum-validated, not regex-guessed), Singapore NRIC, Aadhaar/PAN, and per-category, per-tenant detection posture — decided centrally, not per-gateway-config.
- Durable, tamper-evident audit lineage — every gateway callout produces a canonical decision record with
decision_id,trace_id/correlation_id, and plane attribution, chained across the request and response planes and exportable as compliance evidence. A gateway log line disappears with the pod; the decision trail does not. - Compliance frameworks as policy — OJK / UU PDP (Indonesia), RBI & SEBI (India), MAS (Singapore), EU AI Act lineage — with cross-border transfer bases recorded on the decision.
- Human-in-the-loop verdicts —
needs_approvalis a first-class PDP verdict, not a config branch. - One policy plane for every enforcement point — the same policies govern your SDKs, plugins (Claude Code, Cursor, Codex, Claude Desktop), and gateways; changing a policy changes them everywhere, with one audit trail.
The division of labor: agentgateway owns the data path; AxonFlow owns the decision and the evidence. AxonFlow never sits inline as a proxy — the gateway consults it at each governance touchpoint below and keeps forwarding traffic itself.
Architecture
┌────────────────────────── data plane ──────────────────────────┐
MCP / LLM │ agentgateway │ MCP servers /
clients ──▶│ mcpGuardrails (ExtMcp) extAuthz extProc │──▶ LLM providers /
│ │ gRPC │ gRPC │ gRPC │ HTTP upstreams
└────────┼───────────────────┼───────────────┼──────────────────┘
▼ ▼ ▼
┌─────────────────────────────────────────────────┐
│ axonflow-gateway-adapters (gRPC) │ ← thin protocol translator,
│ ExtMcp · ext_authz · ext_proc (one binary) │ zero policy/redaction logic
└────────────────────────┬────────────────────────┘
▼ HTTPS
┌─────────────────────────────────────────────────┐
│ AxonFlow PDP — /api/v1/decide, │ ← verdicts, engine redaction,
│ /api/v1/mcp/check-input, …/check-output │ canonical audit lineage
└─────────────────────────────────────────────────┘
The adapters are deliberately thin: they translate wire formats and never make a policy or redaction decision themselves. A redacted payload is always the engine's bytes (the obligation two-touch flow). Each seam tells the PDP what it is able to do, so the PDP only ever asks it for work it can actually perform — see Seam capabilities.
The three seams
| agentgateway seam | Layer | Blocks | Mutates content | AxonFlow contract |
|---|---|---|---|---|
mcpGuardrails (ExtMcp gRPC) | MCP methods (tools/call, …) | ✅ | ✅ params (request) + result (response) | decide (stage tool) + engine check-input / check-output |
extAuthz (Envoy ext_authz) | HTTP request (headers + optional bounded body) | ✅ | headers only | decide |
extProc (Envoy ext_proc) | HTTP request + response bodies | ✅ | ✅ both bodies | decide + engine check-input / check-output |
Choosing a seam: govern MCP traffic with ExtMcp; govern LLM/HTTP legs with ext_proc when content may need redaction; use ext_authz for pure allow/deny gating. ext_authz is headers-only — it cannot mask content — so when a policy calls for redacting a request body on that seam, the PDP applies your organization's obligation-fallback posture instead of the adapter blocking locally.
Seam capabilities and the obligation-fallback posture
Requires platform ≥ 9.11.0 and adapters ≥ 9.11.0.
Each seam advertises what it can do on every decision request (fulfillment_capabilities):
| Seam | Advertises | Can mask the content it forwards? |
|---|---|---|
mcpGuardrails (ExtMcp) | request_body_redaction | ✅ rewrites tool params |
extProc — request with a body | request_body_redaction | ✅ rewrites the body |
extProc — bodyless request | request_header_mutation | ❌ the only content is the request line, and ext_proc cannot rewrite :path/:method |
extAuthz | request_header_mutation | ❌ headers only |
Capability is a property of the call path, not the seam: extProc is body-capable when it has a body to rewrite and headers-only on a bodyless request, so a PII-bearing path (e.g. GET /users?nik=…) on that seam takes the fallback posture below, exactly as ext_authz does.
The PDP emits an obligation only to a seam that can discharge it. When a redaction would land on a seam that cannot mask (ext_authz), the PDP suppresses the obligation and applies your organization's obligation-fallback posture:
| Posture | Verdict | Content | Audit trail |
|---|---|---|---|
log (default) | allow | forwarded unmasked | canonical audit_logs row records the suppressed redaction + the detected categories |
block | deny | not forwarded | canonical audit_logs row records the deny + what was suppressed |
log is the default because it matches what the posture that produced the obligation already asked for: an organization on PII_ACTION=redact has said "mask it, don't block it" — it values continuity — so a seam that cannot mask degrades to detect-and-audit rather than to an outage. You always get the audit record; what you lose on this seam is the masking. Choose block if you require that content a policy wanted masked never reaches the backend unmasked.
Set it per organization (admin, sso:configure):
# block: deny instead of forwarding unmasked on a seam that cannot redact
curl -X PUT https://portal.example.com/api/settings/detection-posture/obligation_fallback \
-H 'content-type: application/json' -b "$SESSION" -d '{"action":"block"}'
# revert to the default (log)
curl -X DELETE https://portal.example.com/api/settings/detection-posture/obligation_fallback \
-b "$SESSION"
Only block and log are accepted for this category — redact is precisely what the seam cannot do, and warn has no enforcement distinct from log.
The posture applies only to an obligation that was suppressed, so it never affects a seam that can redact: with block set, an ext_proc or ExtMcp leg still masks and forwards exactly as before.
Alert on axonflow_decision_obligation_fallbacks_total{obligation, action, stage, origin} — a non-zero rate means a seam is receiving content it cannot govern (for example a headers-only leg fronting an LLM that is being sent PII).
A ≥ 9.11.0 adapter against a ≤ 9.10.0 platform is the one unsafe combination to leave in place: the older platform does not understand fulfillment_capabilities, so it still emits the obligation to ext_authz. The adapter then fails closed with a 403 and an ERROR log naming the version mismatch — safe, but every PII-bearing request on that seam is blocked. The reverse (an older adapter against a ≥ 9.11.0 platform) is unaffected: it advertises nothing, reads as a legacy caller, and behaves exactly as it did before.
Run the adapters
The axonflow-gateway-adapters binary serves all three seams on one gRPC listener (plus standard gRPC health). It is an Enterprise component, available two ways:
Prebuilt image (recommended). Enterprise customers pull the multi-arch (linux/amd64 + linux/arm64) image — pin it by the digest from your welcome bundle:
Authenticate to the registry first with the read:packages token from your welcome bundle:
echo "$AXONFLOW_GHCR_TOKEN" | docker login ghcr.io -u <username-from-welcome-bundle> --password-stdin
docker run -d --name axonflow-gateway-adapters \
-p 9090:9090 \
-e AXONFLOW_ENDPOINT=https://your-axonflow-agent:8080 \
-e AXONFLOW_ORG_ID=your-org \
-e AXONFLOW_LICENSE_KEY=your-license-key \
-e AXONFLOW_TENANT_ID=your-tenant \
-e AXONFLOW_GATEWAY_ID=agentgateway-prod \
ghcr.io/getaxonflow/axonflow-gateway-adapters:v9.19.0
# production: pin by digest instead of tag —
# ghcr.io/getaxonflow/axonflow-gateway-adapters@sha256:<GATEWAY_ADAPTERS_DIGEST from your welcome bundle>
The image runs as a non-root user and bundles grpc_health_probe for exec-based health checks (the container HEALTHCHECK uses it; on ECS, point the container health check at CMD-SHELL grpc_health_probe -addr=:9090). The adapter refuses to start without AXONFLOW_ENDPOINT rather than serving ungoverned.
From source. Enterprise customers with source access can build the static binary directly:
# from the axonflow-enterprise checkout
cd ee && go build ./platform/agent/gateway_adapters/cmd/axonflow-gateway-adapters
AXONFLOW_ENDPOINT=https://your-axonflow-agent:8080 \
AXONFLOW_ORG_ID=your-org \
AXONFLOW_LICENSE_KEY=your-license-key \
AXONFLOW_TENANT_ID=your-tenant \
AXONFLOW_GATEWAY_ID=agentgateway-prod \
GATEWAY_ADAPTERS_LISTEN=:9090 \
axonflow-gateway-adapters
| Variable | Default | Purpose |
|---|---|---|
AXONFLOW_ENDPOINT | — (required) | PDP base URL |
AXONFLOW_ORG_ID / AXONFLOW_LICENSE_KEY | — | HTTP Basic PDP credentials (together) |
AXONFLOW_TENANT_ID | — | tenant scope on every decision |
AXONFLOW_GATEWAY_ID | agentgateway | caller_identity.gateway_id on the audit row |
AXONFLOW_DEFAULT_STAGE | llm | decide stage for the HTTP seams (llm|tool|agent); ExtMcp always uses tool |
AXONFLOW_FAIL_MODE | closed | request-plane posture on PDP-unreachable (closed|open) |
AXONFLOW_REQUEST_TIMEOUT | 10s | engine-call bound (circuit posture) |
AXONFLOW_CONNECTOR_TAG | agentgateway | connector_type origin tag on fulfillment audit rows |
AXONFLOW_MAX_BODY_BYTES | 8388608 (8 MB) | scannable-payload bound; larger payloads fail closed |
AXONFLOW_BREAKER_THRESHOLD / AXONFLOW_BREAKER_COOLDOWN | 5 / 30s | consecutive PDP transport failures that open the circuit, and how long it stays open |
AXONFLOW_TRUST_IDENTITY_HEADERS | false | forward client X-User-Email/X-Session-Id on the response-governance call; on platform ≥ 9.9.0 the engine attributes audit rows from them when the same flag is also set on the agent (attribution only — never a verdict; earlier platforms ignore them). Enable ONLY when a hop upstream of agentgateway re-sets them from a validated source (see the trust boundary below) |
AXONFLOW_EXTPROC_RESPONSE_GOVERNANCE | buffered | which ext_proc response body modes are accepted: buffered requires every leg to hand the adapter the whole response, so every response is scanned; off also accepts responseBodyMode: none legs, which run with the response ungoverned — the streaming (SSE) seam. Any other value refuses to boot |
GATEWAY_ADAPTERS_LISTEN | :9090 | gRPC listen address |
Configure agentgateway
MCP governance (ExtMcp)
binds:
- port: 3000
listeners:
- routes:
- backends:
- mcp:
targets:
- name: your-mcp-server
mcp: { host: http://mcp-backend:3001/mcp }
policies:
mcpGuardrails:
processors:
- kind: remote
methods:
"tools/call": full # request + response phases
host: axonflow-adapters:9090
failureMode: failClosed # the default — keep it
A denied tools/call surfaces to the MCP client as JSON-RPC error -32001 whose data payload carries the AxonFlow decision_id, verdict, and reasons — quotable evidence, straight from the wire.
LLM leg (ext_proc)
- port: 3001
listeners:
- routes:
- backends:
- ai:
name: openai
provider:
openAI: { model: gpt-4o-mini }
policies:
backendAuth:
key: $OPENAI_API_KEY # gateway-held provider credential
extProc:
host: axonflow-adapters:9090
failureMode: failClosed
processingOptions:
requestHeaderMode: send
responseHeaderMode: send
requestBodyMode: buffered # REQUIRED — always; see the mode matrix below
responseBodyMode: buffered # governed response; `none` = streaming, opt-in
Set both body modes explicitly: agentgateway defaults both directions to fullDuplexStreamed, which the adapter rejects fail-closed.
backendAuth matters twice: agentgateway's openAI provider does not inject a key by itself, and holding the provider credential at the gateway frees the client's own Authorization header to carry the end-user JWT the adapter forwards for audit attribution.
The adapter buffers each body, decides on the extracted prompt (OpenAI-shaped bodies get model + last user message; anything else is gated whole), fulfills redaction obligations through the engine against the full body, and governs the response body through the engine's response-governance endpoint before the client sees it. To override the decide stage per route, use the gateway-config metadataContext (clients cannot influence it):
extProc:
# …
metadataContext:
axonflow: { stage: '"tool"' } # CEL string literal
ext_authz routes use the axonflow-stage context extension shown below instead. Stage overrides come only from these gateway-controlled channels — the adapter deliberately ignores any client-sent stage header, so the governed party can never pick its own policy layer.
Streaming completions (SSE) — prompt redaction without buffering the response
Requires adapters ≥ 9.11.0.
Buffering a response body is what lets the adapter scan it — and it is also what destroys an SSE stream: the client gets its first token only after the provider has finished. For streaming chat (stream: true) the seam therefore splits the two directions: the request is still buffered, decided, and engine-redacted in full before the provider sees it, while the response streams through untouched.
extProc:
host: axonflow-adapters:9090
failureMode: failClosed
processingOptions:
requestHeaderMode: send
responseHeaderMode: send
requestBodyMode: buffered # the prompt is still decided + redacted in full
responseBodyMode: none # the completion streams; NOT scanned (see below)
and on the adapter:
AXONFLOW_EXTPROC_RESPONSE_GOVERNANCE=off # exact string; anything else refuses to boot
The response body is not governed on such a leg. No response scan, no response redaction, no response-plane block — if the model emits sensitive content, the client receives it. That is the trade this mode makes, which is why it takes the adapter-side opt-in: without AXONFLOW_EXTPROC_RESPONSE_GOVERNANCE=off, a responseBodyMode: none leg is rejected fail-closed, so an edit to gateway YAML alone can never switch response governance off. The opt-in is process-wide — once set, any ext_proc route on that adapter may run ungoverned-response by advertising none; run a separate adapter process for legs that must keep response governance. The posture is logged at adapter startup and once per ungoverned stream. When responses must be governed, use responseBodyMode: buffered (non-streaming legs) or govern the traffic at the MCP seam.
The request plane is identical on a streaming leg: a deny still blocks before the provider is called (the client gets the 403, no tokens are spent, nothing streams), AXONFLOW_FAIL_MODE behaves the same, and the AXONFLOW_MAX_BODY_BYTES bound applies unchanged. Note the meaning of AXONFLOW_FAIL_MODE=open here: the engine performs the redaction, so with the PDP unreachable a fail-open leg forwards the prompt unredacted. Choose closed (the default) if prompts must never reach the provider unmasked.
ext_proc body-mode matrix
The adapter validates the advertised modes per direction and rejects anything else fail-closed with an error naming the fix:
requestBodyMode \ responseBodyMode | buffered | none | bufferedPartial / streamed / fullDuplexStreamed |
|---|---|---|---|
buffered | ✅ governed both ways (default posture) | ✅ only with AXONFLOW_EXTPROC_RESPONSE_GOVERNANCE=off — response ungoverned | ❌ rejected |
| anything else | ❌ rejected | ❌ rejected | ❌ rejected |
(streamed is not expressible in agentgateway v1.3.1 config, but this table is the adapter's validation matrix and the adapter also faces raw Envoy, whose STREAMED mode is likewise rejected fail-closed.)
The request direction is never negotiable: the adapter decides on the full prompt or not at all. Partial response modes stay rejected in both postures — scanning a fragment of a response and calling it governed would be a false claim of coverage; a leg that cannot buffer its response says so honestly with none.
Streaming legs declare their capabilities per call path like every other leg (platform ≥ 9.11.0): the request-body path advertises request_body_redaction (it rewrites the prompt), while a bodyless request on the same leg advertises request_header_mutation — a deliberately truthful "I can set headers, I cannot mask content" declaration, kept non-empty because an empty declaration would read as a legacy caller. See the seam-capability section above for how the PDP uses these.
Upgrading from ≤ 9.10.x: earlier adapters accepted responseBodyMode: none silently (the response plane simply never ran). From 9.11.0 that combination requires the opt-in — a leg already running none fails closed after the upgrade until you set AXONFLOW_EXTPROC_RESPONSE_GOVERNANCE=off on the adapter. This is deliberate: an ungoverned response plane is now an explicit, logged decision instead of a config side effect. Platform version does not matter for this feature — the mode gate is adapter-side (a ≥ 9.11.0 adapter streams fine against a 9.9.0+ platform).
Sizing for large prompts (history + RAG context)
Conversation history plus retrieved context routinely exceeds 64 KB. Two different limits apply, on different seams:
- ext_authz buffers at most
includeRequestBody.maxRequestBytes(the reference config uses 65536); a larger request is rejected by the gateway with413before the adapter is ever called, andAXONFLOW_FAIL_MODEcannot help (it is pre-callout). If you gate LLM routes with ext_authz, raisemaxRequestBytesaccordingly, and decide the oversize posture explicitly: Envoy's ext_authz buffer settings support allowing an over-limit request to continue without the body (partial-body allow) — configure that only if an uninspected request is acceptable on that route. - ext_proc (this seam) has no gateway-side cap in the reference config; the bound is the adapter's
AXONFLOW_MAX_BODY_BYTES(default 8 MB), which fails closed when exceeded. A 130 KB history+RAG prompt streams through this seam redacted, no tuning required.
HTTP allow/deny (ext_authz)
- port: 3002
listeners:
- routes:
- backends:
- host: your-upstream:8080
policies:
extAuthz:
host: axonflow-adapters:9090
failureMode: deny # the default — keep it
includeRequestBody: { maxRequestBytes: 65536 }
protocol:
grpc:
context:
axonflow-stage: llm # optional per-route stage override
Denials return a direct 403 JSON response with decision_id, trace_id, verdict, and reasons; allows stamp x-axonflow-decision-id / x-axonflow-trace-id onto the upstream request and expose the decision to later CEL filters as extauthz.* dynamic metadata.
End-user identity and audit attribution
agentgateway forwards the inbound request headers with each callout. The adapters propagate:
Authorization: Bearer <jwt>→ the decision'suser_token— this is the attribution channel: the PDP resolves and attributes it (an invalid token is denied), on both the decide and response-governance calls;traceparent→ trace stitching across gateway layers (trace correlation).
So the audit row names the actual end user behind the gateway, not just the gateway service account — the attribution regulators ask for.
Trust boundary details:
X-User-Email/X-Session-Idare trust-gated at both ends, ignored by default. The adapter refuses to forward them unlessAXONFLOW_TRUST_IDENTITY_HEADERS=true, because they are client-assertable and agentgateway applies route header modifiers after the ext_proc callout — no gateway config could strip a forged value before the adapter sees it. On the engine side, platforms ≥ 9.9.0 honor the forwarded headers for audit attribution only (audit_logs.user_email/session_id— never a verdict, authz decision, or tenant resolution) under the sameAXONFLOW_TRUST_IDENTITY_HEADERSflag set on the agent — one deployment decision, set it in both places. Platforms ≤ 9.8.x derive audit identity from the validateduser_tokenand ignore these headers on the decide/check-output planes. Opt in only when a hop upstream of agentgateway strips the inbound headers and re-sets them from a validated source (e.g. a jwtAuth claim). See Per-developer identity — the trust gate.- ExtMcp response-plane rows attribute to the gateway. agentgateway's ExtMcp response callout structurally carries no headers, so MCP response-plane governance rows attribute to the gateway credentials; the request-plane row for the same call still names the end user. The reference config additionally suppresses the identity headers at the ExtMcp seam (
requestHeaders: {disallowed: [x-user-email, x-session-id]}) as defense-in-depth.
Failure posture
| Failure | Request plane | Response plane |
|---|---|---|
| PDP unreachable / 5xx / circuit open | block (default) or forward with AXONFLOW_FAIL_MODE=open | always block |
| PDP rejects the call (4xx) | always block | always block |
| Redaction obligation unfulfillable (engine error, redactor didn't run, unforwardable output) | always block | always block |
| Redaction lands on a call path that structurally cannot mask (ext_authz; ext_proc on a bodyless request) | not a failure — the PDP decides: obligation-fallback posture (log → allow + audit, block → deny) | n/a |
| Payload exceeds the scannable bound (8 MB default) | always block | always block |
Response body arrives on a leg that advertised responseBodyMode: none | n/a | always block — the gateway contradicted its own advertisement, so the response cannot be proven scanned |
On an opted-in streaming leg the response column does not apply — there is no response plane on that leg by explicit configuration, and the request column is unchanged.
Both layers default fail-closed independently — agentgateway's failClosed/deny covers a dead adapter; the adapter's posture covers a dead PDP. Response-plane governance additionally requires the engine's redaction_evaluated attestation (platform ≥ 9.7.0), so "the redactor wasn't running" is indistinguishable from a block, never from an allow.
Coexistence with agentgateway's native guardrails
agentgateway ships inline promptGuard regex/webhook guardrails; keep using them for cheap structural checks if you like. Route regulated decisions — PII with legal definitions (NIK, NRIC, Aadhaar), approval workflows, anything you must later evidence to OJK/RBI/SEBI/EU-AI-Act auditors — through the AxonFlow seams, where every decision lands in the compliance-grade trail. The two compose: the seams run as ordered processors alongside native policies.
Verification
The integration ships with a runtime E2E harness (runtime-e2e/2886_agentgateway_pep_adapters/ in the platform repo) that drives a real agentgateway binary, a real MCP server, and a real LLM provider through all three seams against a live PDP — allow, deny, engine-redaction, and fail-closed on every seam. Its config template is the canonical reference config.
See also
- Decision Mode architecture — the PDP contract these adapters speak
- Detection posture — per-org, per-category PII actions the decisions honor
- Governance Architecture & Coverage — how gateway seams fit the runtime modes
- Community vs Enterprise — the adapters are an Enterprise feature
