When session.enabled is true (the default) and listener.session_api_addr
is non-empty (default :9094; :47601 on a local install), the proxy exposes
the captured session store over HTTP. Intended for operators debugging the
plugin pipeline — via kubectl port-forward in a cluster, or directly on a
laptop — and for the agentop TUI.
Trust model: no authentication. Bind only on in-cluster addresses, never
behind ingress. Payloads may contain raw user messages, LLM completions, and
tool results.
Where listener.bind_loopback_only is set (a local install), every route
answers 403 to a request whose Host is not localhost, 127.0.0.1 or
::1, or that carries an Origin header, so a web page cannot read it
through DNS rebinding. The stats server applies the same check.
| Method & Path | Format | Purpose |
|---|---|---|
GET / |
text | One-line-per-endpoint index — answers "is this the session API, and on the right port?" |
GET /v1/sessions |
application/json |
List active sessions (see below) |
GET /v1/sessions/{id} |
application/json |
The session's most recent events; ?limit=N and ?before=<seq> page backward. ?view=summary omits the large payloads |
GET /v1/sessions/{id}/events/{seq} |
application/json |
One event by its sequence number |
GET /v1/sessions/{id}/timeline |
application/json |
The session as spans: Claude Code agents, tool calls, subagent spawns and returns, and hosts (see below) |
GET /v1/sessions/{id}/search |
application/json |
The seqs of the session's events whose bodies or capture hold ?q=, as the detail view shows them (see below) |
DELETE /v1/sessions |
application/json |
Clear every session: the store, and the session archive where one runs. The cost ledger is kept |
DELETE /v1/sessions/{id} |
application/json |
Erase one session: the store's entry and, where the archive runs, its files (see below) |
GET /v1/events |
text/event-stream |
SSE stream of new events; ?session=<id> filters to one session. Heartbeat every 30s |
GET /v1/pipeline |
application/json |
Active pipeline composition, per plugin: name, direction, position, capabilities, runtime config. onError is sent only when it is not the default — in practice observe, since an on_error: off plugin is not built. A plugin under observe runs and records, but its rejections, body writes and redirects are dropped (header writes still apply), so its config says what it would do. agentop renders this as the Pipeline pane (P) |
GET /v1/plugins |
application/json |
Catalog of every registered plugin, active or not. 404 when the binary built its session API without WithCatalog. agentop renders this as the Catalog pane (C) |
GET /v1/usage |
application/json |
Windowed usage aggregation — the aggregate behind agentop cost; parameters and semantics in pricing.md |
GET /healthz |
text | Liveness probe |
{"sessions": [{
"id": "…", "createdAt": "…", "updatedAt": "…", "eventCount": 42, "active": true,
"title": "…", "agent": "claude-code", "totalTokens": 1186300, "costMicros": 254600,
"avoidedMicros": 3700, "promptContext": {"tokens": 16384, "stated": true,
"at": "2026-10-15T20:01:02Z", "msgs": 12}, "inferenceHost": "api.anthropic.com",
"procs": ["41963.1791718279766260000"], "lastRequestAt": "…"
}, {
"id": "pending:claude-code@41963.1791718279766260000", "eventCount": 12, "…": "…",
"linkedTo": [{"session": "…", "first": "…", "last": "…"}], "linkedAll": true
}]}id, createdAt, updatedAt, eventCount and active are always present;
every other field is omitted rather than zero when unknown. The fields worth
knowing by name:
titleis a suggestion, not an identifier — derived from the session's own events (a/rename, else a<user_query>, else ordinary user prose, with<system-reminder>blocks excised). Folded at append time and first-wins, except that a/renamealways overrides. Nothing addresses a session by its title. agentop'sTITLEcolumn uses it only as a fallback, after a harvested Claude Code transcript title. For its re-harvest backoff, agentop still treats a row with only a served title as unnamed, so it keeps looking for the harvested title.inferenceHost— the host of the session's most recent request that carried an inference parse; after a redirect, the server it was sent to rather than the host the client named. Folded at append time, latest wins. Tunnel rows, MCP calls, responses and denials leave it as it was: an event with no inference parse must not clear it. agentop's sessions-tableSERVERcolumn reads it, and so do the count of running sessions onS's result line and the warning fromagentop server remove.inferenceHostFromHistoryistruewhen that host came only from the archive's fold, because no request the proxy's current entry for the session holds has set it. On a local install the inference-router keeps its pins in the plugin store (~/.cortex/plugin-state.json), which a restart keeps, so both of agentop's counts include such a resumed session.procs— the processes,<pid>.<start>, whose own session header named this session. Recorded from each request'sprocandsessionHeader, and continued from the archive's fold like the other figures.- Pending buckets. A coding agent's calls before its process names a session
are recorded under
pending:<agent>@<pid>.<start>(orpending:<agent>when the process could not be looked up), carrying theirproc. Nothing renames a bucket. Instead, when the list is read, a bucket whose process later named a session getslinkedTo— the sessions it named that its calls belong to, each with thefirstandlastrequest that named it; a call belongs to the first session its process named at or after it — andlinkedAllwhen every request in it falls before one of them. A response goes where its request went. agentop hides alinkedAllbucket that links to one session and shows its calls inside it; a bucket linked to two sessions, or with calls after its process's last claim, stays a row of its own. ?archived=trueadds history where the proxy runs a session archive (session.archive, laptop only): every session the archive holds and memory does not, markedresident: false, plus a top-levelarchiveobject —{bytes, maxBytes, retentionDays}and, only when nonzero,droppedEvents,writeErrors,droppedRenames,droppedCaptures,paused, any of which means the history has gaps. A session in both is listed once, as its resident row, and that row covers the session's whole history:eventCount,totalTokens,costMicros,title,agent,createdAtand the rest continue from the archive's fold of what came before this process's entry. This applies to the default list too, not only with?archived=true: with an archive andsession.max_eventsunset, a restart changes no figure. Without an archive, the figures start over at a restart, the parameter is ignored, and noarchiveobject is sent. That is how a client tells "no history here" from "no history yet".
The session's most recent events. ?limit=N (default 500, max 2000) sets the
window; ?before=<seq> returns the page ending just before that event, so the
whole session is reachable by paging backward from the tail. totalEvents is
the session's true length and oldestSeq the oldest event the store still
holds — both present only when this response is not the whole session, so a
client can tell "this is the beginning" from "there is more behind me" without
a second request. 404 for an unknown or expired session.
One response is not a full snapshot: with session.max_events unset a
session can hold thousands of events, and one real session's whole history
encoded to over a gigabyte. The response is written one event at a time rather
than encoded whole, so serving it costs the proxy heap proportional to one
event — but a client that reads it all still pays for all of it. Page with
before rather than raising limit.
?view=summary omits the large payloads. agentop sends it on each session page fetch (the
events table). It does not send it on GET /v1/sessions/{id}/timeline. The script SDK (core/plugins/script/sdk/cortex.py) sends it too. The
proxy then removes these fields from each event in the response:
inference:messages,tools,toolCalls,toolResultsandhistoryToolCalls. It addsmessageCountandtoolCount, the lengths ofmessagesandtools. Each one is absent when it is zero.a2a:artifact.mcp:paramsandresult.
All other fields stay, which includes inference.completion, a2a.parts and
plugins. The stored events do not change. The value must be exactly summary.
With any other value, the proxy sends full events. GET /v1/sessions/{id}/events/{seq} always
returns the full event. /v1/events streams full events.
The response echoes "view": "summary" when it applied the summary. A proxy
that does not know the parameter ignores it. It sends full events and no view
field. So a client that asked for the summary reads view to know which it got.
Do not infer it from missing bodies: a session with no inference traffic has no
bodies to omit.
With a session archive, a page continues from disk below the oldest event
memory holds: a session resumed after a restart pages back through its whole
history, one evicted from memory is served instead of 404'd, and
totalEvents/oldestSeq describe the merged history. GET /v1/sessions/{id}/events/{seq} falls back to the archive the same way, and
answers 404 rather than a neighbour when neither holds that seq.
?raw=1 adds the exact bytes (#1494), under raw, when the archive holds a
capture for the event. Only the archive holds them, so raw is absent without an
archive, for an event that was not captured or predates capture, and for the
second or so before the archive flushes a new event. A request event's raw:
| Field | What it is |
|---|---|
request |
the body as the agent sent it |
forwarded |
the body as Cortex sent it upstream; absent when no plugin rewrote it |
requestHeaders |
the request headers, without credential headers |
query |
the URL's raw query |
A response event's raw has response: {status, headers, body, decoded, truncated, incomplete}. body is up to 10 MiB of the body as it came off the wire,
in its Content-Encoding, unless decoded says the proxy decompressed it to read it
for a plugin. truncated means it was longer, and incomplete means it was not read
to its end: the stream broke off, or the proxy, a plugin or the client stopped it. A body that is not valid UTF-8 — every compressed body — is in
requestBase64, forwardedBase64 or bodyBase64 instead. An unknown raw
value is ignored, so an older proxy answers with the event alone.
The session's history as spans, for agentop's Timeline pane. Computed from the
whole session: memory first, then the archive behind it (a 6,000-event session
takes a few milliseconds; reading one that is only on disk takes over a second).
The result is cached per session until its newest event changes, however long
that takes; an unanswered request still ages out on each read, as it would on a
fresh compute. A pane refreshing every second costs one compute per new event,
and a session only on disk is read once. Reads at most 50,000 events, newest first;
truncated: true says older ones exist. 404 for an unknown session. ?from= and
?to= (RFC 3339) keep only the spans that overlap the range; agents is always
the whole list.
{"id": "…", "lastSeq": 1225, "start": "…", "end": "…",
"agents": [{"id": "main", "label": "main agent"},
{"id": "a1bf9707ee0e71012", "parent": "main", "label": "Survey the code",
"type": "Explore", "spawnTool": "toolu_…", "spawnedAt": "…", "returnedAt": "…"}],
"turns": [{"agent": "main", "start": "…", "end": "…", "finish": "tool_use", "seq": 12}],
"jobs": [{"agent": "main", "id": "bk42", "start": "…", "end": "…", "seq": 40}],
"tools": [{"agent": "main", "id": "toolu_…", "name": "Bash", "start": "…", "end": "…",
"preview": "go test ./...", "seq": 13}],
"returns": [{"agent": "a1bf9707ee0e71012", "into": "main", "at": "…", "seq": 90}],
"hosts": [{"host": "api.github.com", "kind": "tunnel", "start": "…", "end": "…", "status": 200, "seq": 7}]}Turns and tools are attributed to any known client (Claude Code, Bob, Codex,
OpenCode, Pi — the canonical name parsed from the User-Agent); each gets a
single main row, and every model call is a turn, including classifier side
calls, labeled by model. Claude Code additionally gets its subagent tree,
returns and background jobs — those key on the agent ids and marker strings
(agentRole, subagent, parentSubagent) no other client sends. Requests
from unknown clients render as host rows only.
- A turn is one model call, request to end of response. Left out:
progress-label and away-recap calls (matched by their prompt text in the
last non-system message — Claude's own prompt strings, applied to every
client), and Claude Code's tool-less side calls (titler, classifier).
Another client's side calls are kept, cost and all.
errormarks a call that failed: an upstream error, a refusal, the client giving up, a finish ofcontent_filter,failedorcancelled, or a response that ended with no finish reason, which is a stream cut mid-reply.resentmarks a call that sent its agent's previous call's conversation again unchanged: a retry. - A tool runs from the response that requested it to the agent's first
request carrying its result (
inference.toolResults[].toolUseId). A tool Claude Code started while the response was still streaming can have its result recorded first; its end is clamped to its start. A CONNECT to a model host that could not be dialed, after the tool started and before its result, ends it there unless its agent, or an agent below it, made a successful turn in between: the agent tried to report back and the network was gone, so the result arrived only when it came back, or never. A failure the agent recovered from is a blip and ends nothing. A tool with neither has noendand is still running.previewis up to 80 characters of the command, path or description. - An agent links to the
Agenttool call that started it by the id the call's result names (agentId: …). A fork's result names none, so a fork takes its parent's earliest unmatched spawn. Requests recorded before the proxy captured agent ids are onesubagentsagent. - A return is a subagent's result reaching another agent:
<agent-message from="…">or<task-notification>in a message that agent had not sent before. - A job is a background shell command, from the tool result that
acknowledged it (
Command running in background with ID: …) to the first<task-notification>naming that id. Its tool call ends at the acknowledgement, so the job is what shows it still running. A job with noendis one no notification named: still running, or never reported, as a server left running when its agent exited is. A job whose agent was cut off ends where its tools do: at the first failed dial to a model host after the agent's last call. Absent when the session started none.
Hosts are every outbound request paired with its response, every opaque
tunnel, and every denial. kind is llm, mcp, http or tunnel, and
h:443 shares h's row.
end absent means still in flight. A span that never got an answer but
provably stopped is lost and ends where it started: a turn or tool whose agent
moved on, a SubagentHandback (it ends the run), a subagent's tool still open
at a <task-notification> naming that agent, or a request more than ten
minutes old.
agentop's events pane uses this for its details search. The events list carries
view=summary, which drops the bodies, so agentop checks the summaries it holds, and this
route checks what the summary drops.
curl -s 'localhost:9094/v1/sessions/<id>/search?q=retry%20budget'{"seqs":[12,14,40],"lastSeq":980,"schemaVersion":1}seqsis ascending: every event of the session that matches, in memory and in the archive.lastSeqis the newest seq the pass read. A client that holds a newer event asks again.- The match is a case-insensitive substring, folded rune by rune with
unicode.ToLower, as agentop's/folds. - What it reads, only where the event detail view shows it for the event's phase
(
sessionapi.DetailKeys):- request:
inference.messages,inference.tools(a tool'sparametersas JSON),inference.toolResults, the history tool calls,mcp.params - response:
inference.toolCalls,mcp.result,a2a.artifact - the key names in those parts, as the view shows them
- where the archive holds a capture: its body blocks (keys and values when a block is JSON, its text when not), the keys and values between blocks, the headers, the query, and a response's status. Image and document blocks and binary bodies are skipped: the view shows them as labels.
- request:
- It does not read the fields the summary keeps (agentop checks those), the labels agentop
writes itself in the
rawpart (forwarded as sent,[image … KB]), a match that spans a key and its value, or the quotes and punctuation the view draws around the keys and values it reads. - Errors: an empty
qis400({"error":"q is required"}). An unknown session is404with{"error":"session not found"}. A proxy that predates the route answers the mux's plain-text404, which is how a client tells the two apart. An archive read that fails is500: an answer without part of the history could say "no match" where there is one. - Cost: one pass. A long string that many events share is checked one time. On a 10,767-event
laptop session, the pass took about 4 s, with a peak heap of 149 MB (95 MB with
GOGC=25). One plain read of the same archive peaks at about 130 MB. The pass keeps a result for each long string that the events of one segment share, and drops it at the next segment. It keeps none for a capture's text, which no two events share. The pass stops when the client closes the connection, and agentop closes it when its search is dropped.
Clears every session: the in-memory store, and the session archive where one
runs. The cost ledger is kept — spend totals, no content. Answers
{sessions, archivedSessions, bytes}; a failed disk half is a 500 adding
archiveError, with memory cleared regardless. The store clears under its
lock before the archive is waited on, for up to 10s.
This is the one state-changing route pair on an unauthenticated API, so both
are guarded against a browser: DELETE only (a cross-site DELETE needs a
preflight this server never approves), a loopback Host (localhost,
127.0.0.1 or ::1, which defeats DNS rebinding), no Origin header, and
the server must have been built with clearing allowed
(listener.bind_loopback_only on a local install). A refusal is a 403
{"error": ...}. Each guard is pinned by its own test in
core/sessionapi/clear_test.go. agentop's X drives it.
Erases one session: the store's entry and, where the archive runs, its files —
a session only the archive holds too. The same four guards as
DELETE /v1/sessions (pinned in core/sessionapi/delete_test.go) and the
same answer shape, each count 0 or 1. 404 when neither half held the id;
500 with archiveError when the disk half failed (memory is erased
regardless). The cost ledger, usage totals and inference-router pins are
kept. An agent still using the session brings it back with its next request.
agentop's x drives it, one request per session for an agent-wide erase —
see laptop-service.md for the user-facing story.
The agentop TUI handles port-forward + connection automatically — pick a
pod from the Namespaces → Pods picker. For raw HTTP exploration, set up your
own port-forward first:
POD=$(kubectl get pod -n team1 -l app.kubernetes.io/name=weather-agent \
-o jsonpath='{.items[0].metadata.name}')
kubectl port-forward -n team1 $POD 9094:9094 &
# List sessions
curl -s http://localhost:9094/v1/sessions | jq
# Snapshot the most recently updated session
SID=$(curl -s http://localhost:9094/v1/sessions | jq -r '.sessions[0].id')
curl -s "http://localhost:9094/v1/sessions/$SID" | jq
# Page backward: the oldest event this response carries is the next cursor.
# Repeat until the first event's seq equals oldestSeq — that is the beginning.
curl -s "http://localhost:9094/v1/sessions/$SID?limit=500" \
| jq '{cursor: .events[0].seq, oldestSeq, totalEvents}'
curl -s "http://localhost:9094/v1/sessions/$SID?limit=500&before=<cursor>" | jq '.events|length'
# Live tail every event
curl -N http://localhost:9094/v1/events
# Live tail a single session
curl -N "http://localhost:9094/v1/events?session=$SID"Each JSON response that carries events has one top-level schemaVersion field.
The events in it do not carry one each. The value is now 1
(SchemaVersion in core/sessionapi/server.go). These responses carry it:
GET /v1/sessions/{id}, besideidandevents.GET /v1/sessions/{id}/events/{seq}. This response is the event object, so the field is one more key in that object.
The rule for the number:
- It increases when a field of the response or of an event changes its meaning or is removed.
- It does not increase when a field is added. A client ignores keys it does not know.
- A response with no
schemaVersioncomes from a proxy older than the field. Read it as version 1.
/v1/events frames do not carry it. Each frame is one bare event, with no
envelope to hold the field. The golden file
core/pipeline/testdata/session-event.golden.json holds an event with every
field set, and a test fails when the event wire form changes.
Every event on /v1/sessions/{id} and /v1/events carries:
sessionId— the id of the session the store added the event to. The store sets it on every event, which includes outbound MCP and inference events. Their protocols have no session id of their own.?session=on/v1/eventsfilters on this field. A rekey writes the new id into every event the store holds for that session.at,direction,phase— when, which side, what stage.phaseis one of"request","response", or"denied"(terminal denial from a pipeline plugin — typically a jwt-validation failure).seq— the event's position in its session, counting from 1, and the cursor?before=takes. Gaps are normal: FIFO eviction drops a prefix, and a pinned intent can sit far ahead of the retained tail. Unique within one incarnation of a session, not forever: trimming never reuses numbers, but whole-session eviction deletes the counter with the entry, so a session re-created under the same id restarts at 1. A cursor does not identify the incarnation, and the store cannot tell a re-created session from a trimmed one. A cursor from before that point that is above every event held gets the tail. One that is not, such asbefore=10on a re-created session with 20 events, gets events below it (up to 1 to 9 here), and the client misses events 10 to 20. Within one incarnation, order byseq. Across pages that may span a re-creation, order byat, thenseq:atis not unique. For example, a bridged tunnel's open row takes the time of its first request. A session archive moves that boundary out to disk: numbering continues across restarts for as long as the archive holds the session, and a rekey into an id the archive already holds is refused (the session keeps its old id), because the two numberings would collide. A proxy that predates paging sends noseq, which is how a client tells it cannot page there.a2a/mcp/inference— protocol parser payloads (one at most).invocations— per-plugin invocation records for every plugin that ran on the pipeline pass, structured{inbound: [...], outbound: [...]}. Each entry carriesplugin,action(see the vocabulary below),reason(machine-stable code), and optional plugin-specific context.plugins— escape-hatch map for plugin-specific observability: keys are plugin names, values the raw JSON each plugin emitted. Seeplugin-reference.mdfor the producer contract.identity,host,statusCode,error,durationMs— request-level context. A 502 here is not always the upstream's answer: when the exchange with the upstream fails — timeout, refusal, DNS, TLS, a body that breaks off mid-read — the proxy synthesizes the 502 itself, witherror.kindsaying which (upstream_timeout,upstream_refused,upstream_dns,upstream_tls, elseupstream_error) anderror.messagenaming the address but never the request URL, whose query string can carry an API key.error.kind: "proxy_error"is the one that is the proxy's own: a response over the listener'smaxBodySize(1MB inbound, 10MB outbound), and on the streamed arms the 5-minute idle read cutoff and the per-frame cap. A streamed response that breaks off mid-body has no 502 to synthesize — the status is already on the wire. Both proxies abort the client's connection (h1 reset, h2RST_STREAM) rather than end it cleanly, so the agent cannot read a cut stream as complete. On the forward proxy's three relay arms (SSE relay, SSE passthrough, unbuffered copy) the row keeps the sent status, with the failure inerror(upstream_errorfor the upstream's break,proxy_errorfor the proxy's own cuts) and the sent status aserror.code. Such rows count as errors in/v1/usagealthough their status is 200, and the Finisher seesOutcomeError(classifyBrokenStreamresetspctx.StatusCodeto zero while the row keeps the sent status). Askip_hostsstream aborts the same way and records nothing. Where the upstream did answer,error.codekeeps the status it sent, whilestatusCodestays what the client got. A 499 witherror.kind: "client_canceled"is the client hanging up first — since neither proxy times out a slow upstream, that is how a hung one usually ends. It counts as an error in/v1/usagebut stays out of the 5xx series, and logs no WARN. On all of these the Finisher seesOutcomeErrorwithOutcome.StatusCode0: that zero marks the request as an error, so the event and the outcome disagree about the status on purpose. A 421 witherror.kind: "misdirected_request"is a bridged request whoseHostnames another host or port than its tunnel's CONNECT target: the forward proxy refuses it before the pipeline runs and forwards nothing, and both rows name the CONNECT target ashost, because policy keyed on the client'sHostwould judge a destination the bytes never reach. A 426 witherror.kind: "upgrade_declined"is the forward proxy's own answer to a WebSocket upgrade on OpenAI's Responses API (chatgpt.comorapi.openai.com) that it will not carry: every one underlistener.responses_websockets: decline, and under the default,parse, one it cannot (not decrypted, or a plugin that rewrites response bodies or redirects requests). Nothing is forwarded, and a client such as Codex reads it as "use HTTPS" and retries at once over HTTPS. (Underparse, an HTTP/2 client is refused 400 by Go's HTTP/2 server before the handler runs.) The 426 is not counted as an error in/v1/usage, and the Finisher sees an allow with status 426. v0.9.4'sdecline_responses_websocketsis still read when the new key is unset (true=decline,false=forward), with a startup warning; both set is refused. A carried socket's turn the client ends early (Codex's Esc) is a 200 witherror.kind: "turn_interrupted", as an HTTPS stream the client leaves part way keeps its 200, and is not counted as an error either. Every other WebSocket upgrade — and a Responses one underforward— is forwarded (#1488): the 101 the origin answers with is spliced onto the client's connection (the upgrade is normalized toConnection: Upgrade/Upgrade: websocket, which net/http pins to HTTP/1.1). An upgrade is both headers, on both sides: a request needsConnection: upgradeas well asUpgrade: websocketor it is stripped and takes the ordinary path, and a 101 that switches to another protocol than the one requested is refused 502 before the hijack. Either check missing turns a plain request into an opaque pipe no plugin inspects. Once spliced, the frames after it are relayed uninspected, and the session records the request row plus a 101 response row paired to it byrequestId, carryingbytesUpandbytesDownfor the whole WebSocket session — the one response row besides a tunnel close that carries both — withdurationMscovering the session andttfbMsthe handshake. Because thatdurationMsis a session lifetime rather than a request latency, the 101 row is excluded from/v1/usageexactly as a tunnel row is: folding it would drag the latency mean toward WebSocket lifetimes (one 10-minute session moved a host's mean from 500ms to 6.4s in testing), its frames are opaque so there is no model, token or cost to attribute, and counting it would add one Request whose latency is its whole lifetime. On the timeline the lifetime is the point, so the field stays. That exclusion is outbound only. The reverse proxy also records a 101, but at handshake time (ReverseProxyhands off the connection instead of copying a body, so a deferred record would never fire), so itsdurationMsis the handshake latency — an ordinary request figure. An inbound WebSocket handshake is therefore still counted as a Request. A relay that ended in an error carrieserror.kind: "proxy_error"on the 101 row, so a session that broke mid-stream is distinguishable from one that closed cleanly; an ordinary close is not marked. A 403 witherror.kind: "destination_private"is the egress guard's refusal (listener.egress_guard): the destination resolved into loopback, link-local, private or this host's own address space and no permit covered it. Also the proxy's own answer rather than the upstream's, and also not counted as an error — the guard did what it was configured to. The Finisher sees an allow with status 403, as for the 426. A 403destination_deniedis the egress allow list's refusal (listener.egress_allow_hosts, orMOCA_EGRESS_ALLOW): the destination was not on the deny-by-default list, and the proxy refused it by name — headerX-Moca-Egress-Error, text/plain bodydestination_denied: <host>\n— before the pipeline, before DNS and before any dial. Off-list CONNECT targets, absolute-form hosts and the target a plugin's redirect moved a request to all land this way, as aphase: "denied"row carrying anegress-allowlistinvocation whosedetails.hostnames the host; the row's ownhostis the full authority, as every outbound row's is. The two 403s compose: the allow list judges the name before DNS, the guard the address at the dial, so an allow-list entry is not a permit for the guard. A request whose body could not be buffered never reached the pipeline, so its request row carries no parse and no Finisher runs: over the forward proxy'smaxRequestBodySize(32 MiB) it is a 413proxy_errorwith an emptyerror.code; a client that hung up mid-upload gets a 499client_canceled, which here does log a WARN (request body unreadable); anything else unreadable is a 400request_unreadable. An opaque CONNECT that could not be dialed reports the same one layer down, astunnelReason: "dial-failed"witherror.kind: "dial_failed".ttfbMs— on a delivered response row, the wall time from request entry to the upstream's response headers arriving.durationMs−ttfbMsis then how long the body took — for a streamed completion, the stream time. The pair separates a slow-to-answer origin from one that answered and then trickled, whichdurationMsalone cannot (#1251). All three listeners setpctx.ResponseHeadersAtat their own headers-arrival moment, and every response-event builder derives the figure withpipeline.DurationBetween; a new listener or builder must do the same. The row is recorded when the listener is finished with the body — after the read on a buffered path, after the relay on a streamed or unbuffered one — so the difference really is the body's time. The one exception is the envoy-sidecar listener with a pipeline no plugin of which reads bodies: the row is recorded at the headers while Envoy relays the body unseen, and ttfb is left absent there rather than reported as a figure equal to the whole duration. Absent when the exchange failed before headers (the 502 cases above), on request rows, and on tunnel rows — an opaque tunnel has no headers to time. agentop's detail pane renders the split as atiming:line.requestedHost— present only when a plugin redirected the request: the host the client asked for.hostis where the request actually went, and usage, the cost ledger and pricing all followhost.inference.requestedModel— present only when a plugin changed the model: the model the client asked for.inference.modelis the model the request was sent for, which settlement prices.inference.thinkingType,inference.thinkingDisplay,inference.effort— what the request asked for:thinking.typeandthinking.display(for exampleadaptiveandsummarized), and the effort level from the field the dialect uses (output_config.effort,reasoning_effortorreasoning.effort). Absent when the request did not state them.inference.anthropicBeta— the request'santhropic-betaheader, every value joined with a comma.inference.historyThinking,inference.responseThinking— counts of the thinking blocks the client sent back and the model returned:blocks,redacted,textBytes,signatureBytes,redactedBytes. Absent when there were none. A history block withsignatureBytesand notextBytescame back as an encrypted signature only.inference.servedModel— on a response row, present only when the upstream named a model other thaninference.model: thex-litellm-model-nameresponse header (a LiteLLM gateway's deployment, for examplebedrock/us.anthropic.claude-opus-4-6-v1forclaude-opus-4-6), else the response body's ownmodelfield (a provider's dated version). Absent when the upstream answered under the requested name, as Ollama does. For display only: pricing still followsinference.model. agentop's detail pane shows it on aserved:line.inference.messages— the conversation the request sent, in order. Each item has aroleand acontent. An Anthropic request's top-level system prompt is the first item, with rolesystem.contentkeeps only the text blocks.contentBytesis the wire size of the content value as the client sent it, so a message with no text still shows its size. It is a byte count of the raw JSON, not a token count.inference.messages[].toolCallNames,toolUseIds,toolUseId— the tool blocks of a message.toolCallNamesnames the tools that an assistant message called, joined by,in call order.toolUseIdsgives the ids of the message's tool calls or tool results, joined by,in block order. They key intoinference.historyToolCallsandinference.toolResults. Only a chat-completionsrole: "tool"message has atoolUseId: the id of the call that the message answers.inference.historyToolCalls— the tool calls that earlier assistant turns made, as the client sent them back in this request:id,nameandarguments.inference.toolCallsis a different list: the calls that the model asks for in this response. No compiled plugin readshistoryToolCalls.inference.temperature,inference.maxTokens,inference.topP,inference.stream— the request's sampling settings and stream flag, as sent.maxTokensismax_tokens, ormax_output_tokenson the Responses API. Each is absent when the request did not state it.streamis also absent when it is false.inference.tools,inference.toolChoice— the tools that the request declared and itstool_choicevalue. Each tool has aname, adescriptionandparameters, the JSON schema as the client sent it.inference.streamedResponse— true when the response came as an SSE stream.inference.streamis what the request asked for. The two can differ: a gateway can answer a non-streaming request with a stream. Pricing readsstreamedResponseto know if a token count is final.inference.completion,inference.finishReason,inference.toolCalls— on a response row: the model's text, the provider's stop reason, and the tool calls that the model asks for. Each tool call has anid, anameandarguments, the raw JSON text from the model.inference.inputTokens,inference.cacheReadTokens,inference.cacheWriteTokens,inference.outputTokens,inference.reasoningTokens— on a response row: the token counts that the provider reported, by kind.inputTokensare the uncached prompt tokens.reasoningTokensare a part ofoutputTokens.inference.presentKinds— a bitmask of the token kinds that the provider reported: input 1, cache read 2, cache write 4, output 8, reasoning 16. A zero count with its bit set means that the provider reported zero. A zero count without its bit means that the provider did not report that kind.inference.promptTokens,inference.completionTokens,inference.totalTokens— older totals that the parser calculates from the split counts.promptTokensis input plus cache read plus cache write.completionTokensis output.totalTokensis the provider's own total when it sent one, else prompt plus output.inference.messageCount,inference.toolCount— set only on aview=summarycopy, which removesmessagesandtools: how many items each list had. Zero means "not stated". When the list is present, use its length.inference.isAction,mcp.isAction,a2a.isAction— the parser's classification. It is true on every inference row. It is true on the MCP methodstools/call,prompts/getandresources/read, and on the A2A methodsmessage/sendandmessage/stream. It is absent on protocol mechanics. See Classifying requests.mcp.method,mcp.rpcId,mcp.params— the JSON-RPC method, id and params of an MCP request. A notification has norpcId. The two methods that follow occur only on a configured MCP path. A body-lessDELETEwith anMcp-Session-Idheader has the method$transport/terminate. A body-lessGEThas the method$transport/stream.mcp.result,mcp.error— on a response row: the JSON-RPCresult, or theerrorobject withcode,messageanddata.a2a.method,a2a.rpcId— the JSON-RPC method and id of an A2A request.a2a.sessionId,a2a.taskId,a2a.messageId,a2a.role,a2a.parts— from the request's params.sessionIdiscontextId, else the oldersessionId, elseparams.message.contextId.taskIdisparams.message.taskId, elseparams.idfor atasks/*method with a task id param, else the olderparams.taskId. When the request has none, the response setssessionIdandtaskId. A Task result gives itsid.messageId,roleandpartscome fromparams.message. Each part has akindand acontent: the text of atextpart, thedataoruriof afilepart, or the JSON text of adatapart.a2a.finalStatus,a2a.artifact,a2a.errorMessage— on a response row: the last task state that the response carried (for examplecompletedorfailed) and the artifact text. A stream that ends with no final status event gives its last state, for exampleworking. When the state isfailed,errorMessageis the first text part of the status message. In a stream, the parser joins the text of each artifact event. The joined text has a maximum of 64 KiB. A Task event in a stream replaces the joinedartifact-updatetext with the last text part of the Task's artifacts.bytesUp,bytesDown— body bytes, one direction per row: a request row carriesbytesUp, the body as forwarded (so a rewrite is reflected); a response row carriesbytesDown, the body as it came back from the destination. Headers are not counted. A tunnel close and a forwarded WebSocket's 101 response row are the rows carrying both. Zero means the listener counted nothing, not that the body was empty — every listener buffers bodies only when a plugin asks, so an unbuffered request reports zero. A response relayed straight through is counted, not zero: both proxies record the row after they finish relaying it (#1251), so the bytes that crossed their own process are on the row. The one arm that still reports zero is the envoy-sidecar listener with a body-free pipeline — it asked Envoy not to send it the bytes, so zero there means "not asked to see", not "not counted" (core/listener/parity'sTestDivergence_InboundResponseBodyCountWhenNoPluginReadsItpins it). A response row'sbytesDownis the body as it came back, not as delivered, because extproc records the row before it emits a response mutator's replacement. Everywhere else the three listeners count the same figure for the same response;core/listener/internal/bodycountand the parity fixtures hold that line.tunnel,tunnelReason— an opaque CONNECT (or transparent-redirect) tunnel records two rows sharing arequestId: the open (phase: "request",tunnelReasonsaying why the bytes stayed opaque) and the close (phase: "response"), carrying the CONNECT's own status, the duration, and the bytes each way (up = client to destination; opaque bytes have no body/header split). The close's status is 200, or 502 with the dial error when the destination could not be reached and no TLS bridge took the connection (tunnelReason: "dial-failed"). Where the bridge is enabled for the port the CONNECT is answered 200 first, because a redirect can still apply, and a bridge that then declines closes with that 200 and the dial error. It is not the destination's status: that travels inside the client's end-to-end TLS. A bridged tunnel's open is recorded with its first decrypted request — in that request's session, directly before it, stamped with its time — and records no close, because the request carries its own response; a bridged tunnel that recorded no request gets its open and a close when it ends. Tunnel rows are kept out of/v1/usage: a tunnel's lifetime is not a request latency.tunnelReason: "websocket"is the one that is not opaque: a Responses API WebSocket the forward proxy carries (listener.responses_websockets). Its open is the handshakeGET, its close is status 101 with the whole socket's duration and bytes, and every turn on it records its own ordinaryPOSTrequest and response rows between the two, parsed and priced like an HTTPS turn. A turn runs on the pipeline it began on, so a hot reload never splits one.tunnelReason: "dest-private"is the egress guard's refusal: the close row carries 403 anderror.kind: "destination_private", and no tunnel was ever opened to the destination — the same refusal a plain request gets, one layer down.dialAddr— present on a tunnel's open row when the egress guard is on: the address the guard checked and the proxy dialed,"ip:port". Together withhostit is the audit trail against DNS rebinding — "asked for the name, dialed this address" — because under the guard the proxy never dials an address it did not check. Absent when the guard is off and on rows that dialed nothing.httpMethod,httpPath— the HTTP verb and query-stripped, percent-decoded path, so a request no parser recognized is still identifiable. On an opaque tunnelhttpMethodisCONNECTandhttpPathis absent. Note a secret in a path segment (a bot token, a webhook path) does survive on this unauthenticated surface — worth knowing before exporting events off-box.subagent— present on rows from a Claude Code subagent or agent-team teammate: theX-Claude-Code-Agent-Idthe request carried. For a subagent it is the same id that names its files under~/.claude/projects/<dir>/<session>/subagents/agent-<id>.*. Absent on the main conversation's rows and on every other client's, and absent too when the value was not usable (a control character, over 128 bytes), which is refused rather than cleaned up.Context.Subagentresolves it andResolveClientpins it, so no plugin can re-file a row. The row is still filed in its parent conversation's session, so the header must not be added tosession.id_headers. This key names an agent; it does not say the row is a subagent's.inference.agentRolesays that, and on a teammate's row it ismainwhilesubagentis set.parentSubagent— the agent that started the onesubagentnames, fromX-Claude-Code-Parent-Agent-Id, under the same rules. Claude Code sends it only when the spawner was itself an agent, so on a row that names asubagentan absentparentSubagentmeans the conversation started it. The two keys together give a row's place in the session's tree of agents.
Every plugin emits one of these 5 action values per invocation, so operators can scan a timeline without memorizing plugin-specific verbs:
action |
Meaning | Example |
|---|---|---|
allow |
Gate plugin permitted the request | jwt-validation on valid token |
deny |
Gate plugin rejected the request; pipeline stops | jwt-validation on bad token, token-exchange on IdP failure |
skip |
Plugin ran but didn't act on this message | jwt-validation on a bypass path; parser whose body didn't match |
modify |
Plugin mutated the message | token-exchange replaced the Authorization header |
observe |
Plugin attached diagnostic data without changing flow | a2a-parser, mcp-parser, inference-parser when they match |
Use reason to discriminate within an action — skip/path_bypass vs
skip/no_matching_route tell different stories at the detail-pane level but
both scan as "skip" in the at-a-glance timeline.
agentop's ACTION column adds two values that are rendering, not plugin output:
— when nothing acted, and tunnel for an opaque CONNECT. Neither is ever
emitted by a plugin, and neither is a verdict on the request.
Producer-side contract: the authoritative definition of the 5-value vocabulary, the
Invocationstruct fields, and which diagnostic fields each plugin type populates lives inplugin-reference.md. This table is the consumer-side summary.
-
Denied requests (401 / 503) land as
phase: "denied"events when at least one pipeline plugin appended an Invocation before rejecting. If you're debugging an unauthorized-access pattern, the default-session bucket (GET /v1/sessions/default) is where denial events aggregate. -
Which listener records a row. The listeners do not record the same rows:
- The forward proxy and the reverse proxy record a request row and a response row for every request that the pipeline sees. The forward proxy does not record an agent's polls of its own service.
- The envoy-sidecar listener (ext_proc) records a row only when the row has
content: a parser result (
a2a,mcporinference), an invocation, or a plugin payload. A request that no plugin marked leaves no row. - When a plugin rejects a response, only the forward proxy records a
phase: "denied"row. ext_proc and the reverse proxy record no row for that deny. The reverse proxy's missing row is a known gap, a sibling of #1045 (seeerrorHandlerincore/listener/reverseproxy/server.go).
core/listener/paritypins each difference (TestDivergence_*RowWhenNothingToRecord,TestDivergence_OutboundResponseDeny,TestParity_InboundResponseDenyRecordsNoRow). -
Disabling: set
session.enabled: falseto turn off the store (and implicitly the API). The session archive is separate —session.archive.enabled: falsestops it without touching the in-memory store, and turning the store off with the archive explicitly on is refused at load. Settinglistener.session_api_addr: ""alone is not supported as a selective disable — the preset refills it; if you need store-on-API-off, raise an issue. -
Memory: the store keeps every event a session produces by default —
session.max_eventsis unset andsession.ttldefaults to never. What bounds memory by default issession.max_sessions(100, oldest-used evicted first). Seelaptop-service.mdfor the retention story and the archive's bounds.