MastertheMesh
agentgateway · CEL · cookbook
Cookbook

CEL Cookbook for agentgateway

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

Every place a CEL expression appears in agentgateway, in one page. Authorization, request and response transformation, rate limiting, observability, MCP tool filtering, and LLM transforms. Two JWT payloads to trace through every pattern, a variable reference, and the full custom-function table. All YAML is drop-in.

CEL agentgateway Authorization Transformation Rate limiting Observability MCP LLM JWT claims

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:

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

Encoding and hashing

IP / CIDR (from the Kubernetes IP and CIDR extensions)

Collection helpers (receiver syntax)

Null and error handling

Utility

Logging and tracing only

From the standard CEL library and the strings extension

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:

Group-gated MCP access

Token A

Only 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 A

Scope 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 A

Only 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 A

Group 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 B

Only 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 token

Only 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 B

Block 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 A

Every 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 A

The 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 request

Uses the uuid() custom function.

transformation:
  request:
    add:
    - name: x-request-id
      value: uuid()

Strip internal headers from the response

Any request

remove 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 A

Parse 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 request

Replace 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 A

Each user gets their own bucket. The descriptor key is the JWT subject.

localRateLimit:
  descriptors:
  - key: user
    value: jwt.sub

Per-team rate limit

Token A

Rate 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 routes

For 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:

Log only 5xx responses

Any request

Suppress 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 A

The 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 A

Add 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 request

The 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:

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 A

Force 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 A

Inject 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