What CEL is and where it runs
CEL (Common Expression Language) is a small, fast, side-effect-free expression language designed by Google for policy evaluation. Agentgateway compiles CEL expressions from your CRD YAML once at config-load time and evaluates them per-request inside the Rust dataplane. Expressions run against a snapshot of the current request, response, identity context, and backend metadata. They never appear in request bodies and are always authored by an operator with CRD-write permissions.
CEL shows up in six surfaces across the agentgateway CRDs. This page covers all of them:
- Authorization rules (allow / deny / require against JWT claims, MCP tool names, source IPs, SPIFFE identities)
- Header and body transformations (request and response rewrite)
- Rate-limit descriptors (per-user, per-team, token-based cost)
- Logging, tracing, and metrics enrichment (custom log fields, trace attributes, Prometheus labels)
- MCP tool-level filtering
- LLM request/response transforms
The JWT payloads we'll use throughout
Two reference payloads. Every pattern in this page cites which one it matches against, so you can trace the claims in each expression back to these.
Token A: Alice the analyst
Human user, finance team, read-only scope.
{
"sub": "alice@example.com",
"iss": "https://idp.example.com",
"aud": ["agentgateway.example.com"],
"groups": ["finance", "analysts"],
"scope": "read mcp:tools",
"department": "risk",
"clearance": 3
}
Token B: Agent Butler acting on behalf of Coach
Service-to-service, nested act chain (RFC 8693), elevated scope.
{
"sub": "agent-coach",
"iss": "https://sts.internal.example.com",
"aud": ["backend-mcp"],
"scope": "read write admin",
"act": {
"sub": "agent-butler",
"act": {
"sub": "alice@example.com"
}
},
"may_act": {
"sub": "agent-butler"
}
}
Variables available in CEL
The dataplane populates these namespaces before evaluating your expression. What's available depends on the route type and the phase (request vs response).
| Namespace | Fields | Available when |
|---|---|---|
request.* |
.method, .uri, .path, .pathAndQuery, .host, .scheme, .version, .headers, .body, .startTime, .endTime. request.headers is a header-view with chainable methods: .redacted(), .join(), .raw(), .split(), .cookie("name"). request.uri and request.pathAndQuery expose .query("k"), .addQuery("k","v"), .setQuery("k","v"). |
Every request. Accessing request.body forces buffering up to the body buffer limit (default 2 MiB). |
response.* |
.code, .headers, .body |
Response-phase only (transformation, logging) |
jwt.* |
All standard claims + custom claims as nested fields (jwt.groups, jwt.act.sub, jwt.department, etc.) |
When JWT auth is configured |
apiKey.* |
API key identity fields | When API key auth is configured |
basicAuth.* |
Basic auth claims | When basic auth is configured |
mcp.* |
.methodName, .sessionId, .tool.{name, target, arguments, result, error}, .prompt.{name, target}, .resource.{name, target} |
MCP routes. Request-time CEL gets identity fields (tool, prompt, resource); methodName, sessionId, and tool payloads are populated post-request. |
llm.* |
.requestModel, .responseModel, .provider, .streaming, .inputTokens, .outputTokens, .cachedInputTokens, .reasoningTokens, .totalTokens, .serviceTier, .prompt[].{role, content}, .completion[], .params.{temperature, top_p, max_tokens, ...} |
AI backends only |
llmRequest |
The raw parsed LLM request body. Present only during LLM policies; post-LLM phases (logs etc.) do not see it. | AI backends only |
source.* |
.address, .port, .rawAddress, .rawPort, .identity.{trustDomain, namespace, serviceAccount} (SPIFFE), .subjectAltNames, .issuer, .subject, .subjectCn, .unverifiedWorkload.{name, namespace, serviceAccount} |
Every request. Prefer source.identity.* (cryptographic) over source.unverifiedWorkload.* (resolved from source IP) for trust-sensitive checks. |
backend.* |
.name, .type (ai / mcp / static / service), .protocol |
Every request |
env.* |
.podName, .namespace, .gateway |
Every request |
extauthz.* / extproc.* |
External auth / external processing metadata | When ext-auth or ext-proc is configured |
metadata.* |
Cross-phase state written by transformation | Transformation context |
Custom functions reference
Agentgateway registers these functions on top of the standard CEL library. They're available in every CEL expression across all six surfaces.
Two call conventions show up. Functions documented as foo(x) are global, called with arguments. Functions shown as obj.foo(x) use receiver syntax: the value left of the dot is the target. merge, regexReplace, filterKeys, mapValues, and with all require a receiver. Calling them as a global (e.g. merge(a, b)) errors at runtime.
JSON
json(string|bytes): parse JSON into a CEL value. Example:json(request.body).some_field.toJson(value): serialise a CEL value to a JSON string. Example:toJson({"hello": "world"}).unvalidatedJwtPayload(jwtString): decode a JWT payload without signature validation. Use with care.
Encoding and hashing
base64.encode(string|bytes)/base64.decode(string|bytes)(decode returnsbytes; wrap instring()if you want a string)sha256.encode(string|bytes)/sha1.encode(string|bytes)/md5.encode(string|bytes): return the lowercase hex digest
IP / CIDR (from the Kubernetes IP and CIDR extensions)
ip(string): parse an IP. The IP object exposes.family(),.isLoopback(),.isGlobalUnicast(),.isLinkLocalUnicast(),.isLinkLocalMulticast(),.isUnspecified().isIP(string):trueif the string is a valid IP.cidr(string): parse a CIDR block. The CIDR object exposes.containsIP(string|ip),.containsCIDR(cidr),.ip(),.masked(),.prefixLength().
Collection helpers (receiver syntax)
obj.with(ident, expr): evaluateexprwithidentbound toobj. Example:json(request.body).with(b, b.field_a + b.field_b).map.mapValues(ident, expr): transform every value in a map. (Standard CELmaponly iterates keys.)map.filterKeys(ident, expr): keep entries where the key predicate is true. Example:m.filterKeys(k, !k.startsWith("x_")).map.merge(other): shallow merge intomap(other wins on collisions). Example:{"a":2,"k":"v"}.merge({"a":3})→{"a":3,"k":"v"}.
Null and error handling
default(expr, fallback): returnfallbackifexprcannot be resolved (missing header, missing key).coalesce(...args): evaluate left to right and return the first non-null. Unlikedefault, swallows any error from earlier expressions, not just missing keys.
Utility
str.regexReplace(regex, replacement)(receiver) : regex substitution. Example:"/id/1234/data".regexReplace("/id/[0-9]*/", "/id/{id}/").uuid(): generate a v4 UUID.random(): random float in[0.0, 1.0].fail(message): force evaluation failure with a message.variables(): returns all currently available variables (debugging). Enables capture of every field, so don't leave it on in production.
Logging and tracing only
flatten(value): flatten a list or struct into many fields. Example:headers: 'flatten(request.headers)'emitsheaders.user-agent, etc.flattenRecursive(value): likeflattenbut recurses through nested structures.
From the standard CEL library and the strings extension
- Standard:
contains,size,has,map,filter,all,exists,exists_one,matches,startsWith,endsWith,max,string,bytes,double,int,uint. - Strings ext:
split,join,replace,substring,charAt,indexOf,lastIndexOf,lowerAscii,upperAscii,trim,stripPrefix,stripSuffix. - Duration/time:
duration,timestamp,getFullYear,getMonth,getDayOfYear,getDayOfMonth,getDate,getDayOfWeek,getHours,getMinutes,getSeconds,getMilliseconds.
Authorization (Allow / Deny / Require)
OSS / ENT
CEL field: traffic.authorization on
AgentgatewayPolicy (and the enterprise wrapper). The shape is
one action per policy: action: Allow | Deny | Require plus
policy.matchExpressions[], where every expression in the list
is AND-ed. For MCP, you can also attach authorization at the backend with
backend.mcp.authorization, which runs per-tool: list_tools
filters the listing, and call_tool rejects calls that don't match.
The semantics across multiple policies:
- Allow rules trigger deny-by-default. If any Allow policy matches, the request is allowed; if Allow policies exist and none match, the request is denied.
- Deny rules block on match. A Deny expression that fails to evaluate (for example, a missing JWT claim) is treated as not matching, which is risky — prefer
Requirewhen the check is critical. - Require rules are conjunctive across merged policies: every Require policy must match.
- With no rules at all, traffic is allowed by default.
Group-gated MCP access
Token AOnly callers in the finance group can reach this backend. Full policy shape shown once; later examples show just the matchExpressions block.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: rbac-finance
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: mcp-route
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- 'jwt.groups.exists(g, g == "finance")'
Read-only tool allowlist by scope
Token AScope must contain read and the tool must be one of the allowed set. Both expressions in matchExpressions must be true (AND).
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- 'jwt.scope.contains("read")'
- 'mcp.tool.name in ["get_profile", "list_accounts", "search_tools"]'
Per-user tool binding
Token AOnly Alice can call the get_me tool. Attach at the MCP backend so the rule runs per tool call.
backend:
mcp:
authorization:
action: Allow
policy:
matchExpressions:
- 'jwt.sub == "alice@example.com" && mcp.tool.name == "get_me"'
Layered RBAC + capability allowlist
Token AGroup membership + scope + tool set, all required.
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- 'jwt.groups.exists(g, g == "analysts")'
- 'jwt.scope.contains("mcp:tools")'
- 'mcp.tool.name in ["get_user_financial_profile", "analyze_portfolio"]'
RFC 8693 actor-chain check
Token BOnly allow calls that came through agent-butler acting on behalf of alice@example.com.
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- 'jwt.act.sub == "agent-butler" && jwt.act.act.sub == "alice@example.com"'
Source IP restriction (CIDR)
Any tokenOnly allow requests from the 10.0.0.0/8 private range. containsIP takes the address as a string directly, so the ip(...) wrap is optional.
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- 'cidr("10.0.0.0/8").containsIP(source.address)'
Deny rule: block a tool for everyone except admins
Token A Token BBlock delete_account unless the caller's scope includes admin. The has() guard is critical: if the token has no scope claim, the bare !jwt.scope.contains(...) would error and the Deny rule would silently miss, falling through to allow.
traffic:
authorization:
action: Deny
policy:
matchExpressions:
- 'mcp.tool.name == "delete_account" && (!has(jwt.scope) || !jwt.scope.contains("admin"))'
Require rule: issuer pinning alongside other policies
Token AEvery request must come from a token issued by https://idp.example.com. Require is conjunctive across all merged policies, so this combines with the Allow rules above rather than replacing them.
traffic:
authorization:
action: Require
policy:
matchExpressions:
- 'jwt.iss == "https://idp.example.com"'
Transformation (request + response rewrite)
ENT
CEL fields under traffic.transformation on
EnterpriseAgentgatewayPolicy:
.request.set[].value,
.request.add[].value,
.request.body,
.request.remove[],
.response.set[].value,
.response.add[].value,
.response.body,
.response.remove[].
The value on set/add entries is a CEL expression.
remove is a plain header-name list (not CEL).
Inject JWT subject as a header to upstream
Token AThe upstream MCP server sees x-user-id: alice@example.com without parsing the JWT itself.
transformation:
request:
set:
- name: x-user-id
value: jwt.sub
Add a correlation ID to every request
Any requestUses the uuid() custom function.
transformation:
request:
add:
- name: x-request-id
value: uuid()
Strip internal headers from the response
Any requestremove is a plain list, not CEL. It runs after the response comes back from the upstream.
transformation:
response:
remove:
- x-internal-trace
- x-debug-info
Rewrite the request body with a JSON merge
Token AParse the incoming body, merge in the caller's department from the JWT, serialise back to JSON. merge is receiver-style: it takes the map on the left and merges the argument map into it.
transformation:
request:
body: 'json(request.body).merge({"caller_department": jwt.department}).toJson()'
Hash a header value for pseudonymisation
Any requestReplace the x-user-email header with a SHA-256 hash so the upstream gets an opaque identifier instead of PII.
transformation:
request:
set:
- name: x-user-hash
value: 'sha256.encode(request.headers["x-user-email"])'
Rate limiting
ENT
CEL fields:
traffic.localRateLimit.descriptors[].value and
traffic.remoteRateLimit.descriptors[].cost on
EnterpriseAgentgatewayPolicy.
The value expression produces the descriptor key
that the rate-limit server counts against. The cost
expression (used with token-based rate limiting) computes how
many tokens this request should consume.
Per-user rate limit
Token AEach user gets their own bucket. The descriptor key is the JWT subject.
localRateLimit:
descriptors:
- key: user
value: jwt.sub
Per-team rate limit
Token ARate limit by the first group in the JWT. Every member of finance shares one bucket.
localRateLimit:
descriptors:
- key: team
value: jwt.groups[0]
Token-based cost from the LLM response
AI routesFor AI backends, the rate limit counts tokens consumed, not requests. llm.totalTokens is already an integer bound by the schema and survives streaming, so use it directly rather than re-parsing the response body.
remoteRateLimit:
descriptors:
- key: team
value: jwt.groups[0]
cost: 'llm.totalTokens'
Observability (logging, tracing, metrics)
OSS / ENT
These live on spec.frontend on
AgentgatewayPolicy (or the enterprise wrapper) and only
attach to Gateway targets:
frontend.accessLog.filter(CEL bool — emit log only if true)frontend.accessLog.attributes.add[].{name, expression}(CEL string)frontend.tracing.randomSampling(CEL float 0 to 1, or bool — evaluated head-of-stream, so response-phase fields are not available)frontend.tracing.clientSampling(same shape; controls re-sampling of already-traced requests)frontend.tracing.attributes.add[].{name, expression}(CEL string)frontend.metrics.attributes.add[].{name, expression}(CEL string)
Log only 5xx responses
Any requestSuppress access logs for successful requests. The filter expression is evaluated after the response is received.
frontend:
accessLog:
filter: 'response.code >= 500'
Add user identity to every trace span
Token AThe OTEL span carries user.id = alice@example.com so traces can be filtered by user. default() keeps the attribute populated when the optional claim is missing.
frontend:
tracing:
attributes:
add:
- name: user.id
expression: 'jwt.sub'
- name: user.department
expression: 'default(jwt.department, "unknown")'
Custom Prometheus label from a JWT claim
Token AAdd a department label to every Prometheus metric so request rates can be broken down by team. Keep label cardinality low — per-user IDs will blow up Prometheus.
frontend:
metrics:
attributes:
add:
- name: department
expression: 'default(jwt.department, "none")'
Sample 10% of requests for tracing
Any requestThe expression returns a float between 0.0 and 1.0. Sampling is head-of-stream, so the response is not available yet. To bias sampling toward interesting requests, branch on request-phase fields (path, headers, JWT claims) instead.
frontend:
tracing:
randomSampling: '0.1'
# Branch on request-phase fields:
frontend:
tracing:
randomSampling: 'request.path.startsWith("/admin/") ? 1.0 : 0.1'
LLM request transformations
OSS / ENT
CEL fields under spec.backend.ai on
AgentgatewayPolicy (and the enterprise wrapper). The policy
attaches to an AgentgatewayBackend of type ai:
backend.ai.transformations[].{field, expression}— set a top-level field on the LLM request body to the CEL result. Higher priority thandefaultsoroverrideswhen the same field is set in more than one place.backend.ai.defaults[].{field, value}— supply a default for an absent field (literal JSON, not CEL).backend.ai.overrides[].{field, value}— force a field value, overriding caller input (literal JSON, not CEL).backend.ai.prompt(separate from CEL) — prepend or append static system-prompt content. Not a CEL hook.
For PII redaction and response-side guardrails, agentgateway has a
separate backend.ai.promptGuard mechanism (not CEL). There
is no general-purpose CEL hook for rewriting the LLM response body in
stream today; for that, use Prompt Guard or ExtProc.
Pin the temperature to a per-team value
Token AForce temperature to a team-specific value, overriding whatever the caller sent. The expression result is assigned to the top-level field named in field.
# EnterpriseAgentgatewayPolicy targeting an AI backend
# spec.backend.ai.transformations[].{field, expression}
backend:
ai:
transformations:
- field: temperature
expression: 'jwt.groups.exists(g, g == "research") ? 0.9 : 0.2'
Tag every request with the calling team
Token AInject caller identity into a top-level metadata field on the LLM request so downstream usage tracking can split spend by team. The provider must accept the field; OpenAI's Chat Completions API does.
backend:
ai:
transformations:
- field: metadata
expression: '{"team": jwt.groups[0], "user": jwt.sub}'
Best-practices checklist
Working with CEL in agentgateway
- CEL expressions compile once at config load and run per-request. Keep them simple. A nested
.map().map().map()burns CPU on every request. - For anything security-critical, prefer
RequireoverDeny. A Deny expression that errors at evaluation (for example, a missing JWT claim referenced withouthas()ordefault()) does not match the rule, so the Deny silently fails open. Require is conjunctive: any expression that doesn't evaluate to true rejects the request. - Always pin
jwt.issalongside claim checks (as aRequirepolicy). Without it, a token from a different IdP that happens to be signed by a key in the same JWKS will match. - Guard optional claims with
has(jwt.claim)ordefault(jwt.claim, fallback). A reference to an absent claim errors the expression — fine insideAllow(no match → no allow), risky insideDeny(no match → no block). - For IP checks, use
cidr(...).containsIP(source.address). String comparison breaks on IPv6, embedded zeros, and port suffixes.containsIPtakes the address as a string directly. - Distinguish receiver-style from global functions:
json,toJson,uuid,default,coalesceare global;merge,regexReplace,with,filterKeys,mapValuesrequire a receiver (x.merge(y), notmerge(x, y)). Global form on a receiver-only function errors at runtime. - For LLM rate limiting, read
llm.totalTokensdirectly. It's an integer bound by the schema and survives streaming, so you never need to re-parseresponse.body. - Use
variables()during development to see every variable available in the current context. It enables capture of every field, so don't leave it on in production. - The
metadatafield ontransformation.requestwrites values that subsequent policies can read via themetadata.*CEL namespace. Useful for computing something once and reusing it across header/body/log expressions without re-parsing.