agentgateway v1.7.0: How Agent Session Detection Works

Part of: Agent Security

agentgateway v1.7.0 detects a coding agent's session from headers like x-claude-code-session-id, with an 8-character minimum that fails silently, no error.

agentgateway v1.7.0 (GA, 2026-10-27 ) can tell which coding-agent session sent a request without any client-side change: it reads the session header the agent is already sending. themsquared/agw-17-hands-on validates the detection rule against the real v1.7.0 binary and a mock LLM backend, no API key, no network calls, five curl requests that show exactly when it fires and when it doesn’t.

Defensive security research. This post analyses publicly disclosed findings so the controls that stop them can be tested. It describes mechanisms, not procedures, and links the primary sources for anyone verifying the work.

Why this matters before the mechanism

Without session detection, agentgateway can rate limit or log by API key or IP address, but it has no way to group the many requests one agent run makes into a single unit. Coding agents like Claude Code, Cursor, and Codex already send a session or conversation id in their own headers so their own backends can correlate turns. v1.7.0 reads that same header. Once agentgateway knows which requests belong together, that grouping is available to any CEL-driven policy: a rate limit keyed per session instead of per key, an audit trail grouped by session, routing a whole session to the same backend, or flagging a session whose request pattern looks unusual. None of this needs the client to adopt a new header or send extra metadata. It needs the client to keep doing what it already does.

The headers it reads, in priority order

agentgateway’s detection logic (crates/agentgateway/src/http/session.rs in the v1.7.0 tag) checks, in order:

  1. Any header shaped x-<vendor>-session-id. Claude Code sends x-claude-code-session-id; this is a generic pattern, not a Claude-specific special case, so Cursor’s x-cursor-session-id or any other vendor’s equivalent is detected the same way.
  2. Codex’s unprefixed session-id, session_id, thread-id, or conversation_id headers, but only when the request’s User-Agent starts with codex. This gate exists because those header names are generic enough that treating them as agent-session signals from any client would produce false positives.
  3. A vendor-less x-session-id header, as a last resort.

Whichever header matches, the detected value becomes available to CEL as request.agent.session.

The rule that silently drops a session id

The validation is stricter than it looks, and it fails quietly:

fn valid(value: &HeaderValue) -> Option<&str> {
    value.to_str().ok().filter(|v| {
        v.len() >= 8
            && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
    })
}

A session id under 8 characters, or one containing a character outside [A-Za-z0-9_-], doesn’t get rejected with an error. It gets treated exactly like a missing header: request.agent.session is simply never set, and nothing in the response or the logs calls that out. If a session grouping you expect to see isn’t showing up, this rule is the first thing to check, not a deeper config problem.

Proving it with a mock backend

agw-17-hands-on ’s config points agentgateway’s standalone LLM route at a 40-line Python HTTP server instead of a real provider, using v1.7.0’s custom provider with the completions wire format:

# config/config.yaml
config:
  session:
    key: disabled   # demo only: v1.7 standalone always provisions a session key on first run
frontendPolicies:
  accessLog:
    add:
      agent.session: request.agent.session
llm:
  port: 4000
  models:
  - name: mock
    provider:
      custom:
        formats:
        - type: completions
    params:
      baseUrl: http://127.0.0.1:8091/v1
      model: mock-model

request.agent.session only depends on the incoming request, not on what the backend does with it, so a mock response proves the same behavior a real Anthropic or OpenAI call would. That also means this demo costs nothing to run and needs no API key.

Five requests, one gateway:

$ curl -s -X POST http://localhost:4000/v1/chat/completions \
    -H "x-claude-code-session-id: 79291636abc123" -d '{...}'          # request 1
$ curl -s -X POST http://localhost:4000/v1/chat/completions \
    -H "x-claude-code-session-id: 79291636abc123" -d '{...}'          # request 2, same session
$ curl -s -X POST http://localhost:4000/v1/chat/completions \
    -H "x-cursor-session-id: cursorsession999" -d '{...}'             # request 3, different vendor header
$ curl -s -X POST http://localhost:4000/v1/chat/completions -d '{...}'  # request 4, no session header
$ curl -s -X POST http://localhost:4000/v1/chat/completions \
    -H "x-claude-code-session-id: short1" -d '{...}'                  # request 5, 6-character id

All five return HTTP 200. The access log, with agent.session: request.agent.session wired into accessLog.add, is where the real answer is:

http.path=/v1/chat/completions agent.session="79291636abc123"
http.path=/v1/chat/completions agent.session="79291636abc123"
http.path=/v1/chat/completions agent.session="cursorsession999"
http.path=/v1/chat/completions
http.path=/v1/chat/completions

Requests 1 and 2 share a session, as expected from a repeated header. Request 3’s completely different vendor header still gets picked up, which is the generic-pattern claim proven rather than assumed. Requests 4 and 5 both have no agent.session field: one sent no header at all, the other’s short1 is 6 characters, two short of the minimum. Same outcome, two different causes, neither one visible unless you already know to look for it.

This is the same accessLog.add mechanism the v1.6.0 cost-tracking post used to surface llm.cost.total into the request log: any CEL field, request.agent.session included, becomes a logged attribute with one line of config.

Overriding detection

config.standardAttributes.session takes a CEL expression that runs before the automatic header scan. Returning a string from it sets the session explicitly, which is useful if an internal proxy or SDK already generates its own session identifier under a header name agentgateway wouldn’t otherwise recognize. Returning null marks the request as having no session even when a technically-valid vendor header is present. If the expression itself errors, or returns something other than a string, agentgateway falls back to the header-based detection described above rather than failing the request. This repo doesn’t set the override, so everything in the demo runs through the default header scan.

What this doesn’t cover

This demo is scoped to proving the detection rule, not building something on top of it. It doesn’t exercise a real LLM provider (the mock backend never sees or uses the session value), Codex’s User-Agent-gated header set, or any policy that actually acts on request.agent.session, like a per-session rate limit. v1.7.0 shipped a lot more than this one field: MCP tool-call guardrails (written against a pre-release build, now generally available), model-catalog metadata over /v1/models, service-tier-aware cost pricing, and a new agctl standalone analytics CLI. This post is one field, validated.

Repo: themsquared/agw-17-hands-on . Release notes: agentgateway v1.7.0 .

Frequently asked questions

How does agentgateway detect which coding agent session sent a request?

It scans request headers for any name shaped x--session-id, such as x-claude-code-session-id or x-cursor-session-id, and takes the first valid value it finds. It also recognizes Codex’s unprefixed session-id and conversation_id headers, but only when the User-Agent string starts with codex. A vendor-less x-session-id header is the last fallback. The result is exposed as request.agent.session in CEL and in access logs.

Why isn't my agent session id showing up in agentgateway's access logs?

The value has to be at least 8 characters long and contain only letters, digits, hyphens, or underscores. A shorter or differently-formatted value is treated as if the header were never sent: no error, no warning, the request just has no detected session. Check length and character set first before assuming the feature is broken.

How do I log agentgateway's detected agent session?

Add agent.session: request.agent.session under frontendPolicies.accessLog.add in the gateway config. The field then appears on every access log line as agent.session="" whenever a session was detected, and is omitted entirely when it wasn’t, rather than logging an empty string. This is the same accessLog.add mechanism agentgateway v1.6.0 used to surface llm.cost.total and per-request token counts into the structured request log.

Can I override agentgateway's automatic agent session detection?

Yes. config.standardAttributes.session takes a CEL expression that runs before the header-based fallback. Returning a string from it sets the session explicitly; returning null marks the request as having no session even if a valid vendor header is present. If the expression errors or returns a non-string, agentgateway falls back to the automatic header scan.

Disagree or have a war story? Reply on X