ccxray Normalization Map

September 5, 2026 · View on GitHub

How ccxray maps wire protocol fields to its internal model. Read Wire Protocol Reference first for what each agent sends on the wire. This document covers what ccxray does with those fields.

Version baseline: ccxray 1.10.0 · 2026-06-02


Dispatch Architecture

Two-layer dispatch: WIRE_PARSERS (server) and RENDERERS (client).

Wire traffic → config.getUpstreamForRequestAndHeaders()
             → upstream.provider ("anthropic" | "openai")
             → server/wire-parsers/{provider}.js    ← server-side normalization
             → public/renderers/{provider}.js        ← client-side event rendering
LayerRegistrySourceDispatch key
Serverserver/wire-parsers/index.js{ anthropic, openai }upstream.provider
Clientpublic/renderers/index.js{ anthropic, openai, fallback }entry.provider

WIRE_PARSERS interface

Every provider module exports:

MethodSignaturePurpose
isNoiseRequest(url, headers, body) → boolFilter startup/platform noise
normalizeListMeta(entry) → ThinCanonicalRaw stored entry → list-layer metadata
extractUsage(resData) → usage objResponse data → canonical token counts
extractAgentType(systemBlob, headers) → {key, label}Agent classification
detectSession(req, headers, body) → {sessionId, isNewSession, inferred}Session extraction
preprocessBody(body, headers) → bodyInject header metadata before storage (OpenAI only)

1. Session Detection

Anthropic

Single source — body.metadata.session_id. Delegates to store.detectSession(parsedBody).

OpenAI

Priority chain (wire-parsers/openai.js:getCodexSessionId):

1. header "session_id" or "x-openai-session-id"
2. header "x-codex-turn-metadata" → JSON parse → .session_id
3. body.metadata.session_id
4. header "x-codex-turn-metadata" → JSON parse → .thread_id
5. body.metadata.thread_id
6. fallback → "codex-raw" (synthetic bucket)

preprocessBody (withCodexMetadata) merges derived session_id, agent_type, and cwd into body.metadata so downstream code treats both providers uniformly. When only thread_id is present, ccxray copies it into metadata.session_id for internal grouping.

Subagent detection

ProviderSourceLogic
AnthropicSystem prompt heuristicAbsence of cwd metadata → likely subagent. store.isLikelySubagent() adds temporal heuristic (inflight + timing)
OpenAIHeaders/bodyx-openai-subagent (truthy, checked first) → body.metadata.is_subagent/isSubagent (fallback)
OpenAIAgent typeexplorer/worker → subagent; default → main

2. Working Directory (CWD)

Anthropic

Regex extraction from system prompt content (store.extractCwd).

OpenAI — shared extraction

wire-parsers/openai.js:getCodexCwd(headers, parsedBody, fallback) checks:

1. parsedBody.metadata.cwd
2. parsedBody.metadata.workspaces
3. x-codex-turn-metadata.cwd
4. x-codex-turn-metadata.workspaces
5. parsedBody.instructions CWD: line
6. caller fallback (HTTP: hub client CWD or process.cwd)

Workspace extraction uses this 5-strategy fallback:

1. workspaces.cwd        (string)
2. workspaces.current    (string)
3. First string value in workspaces
4. Nested object with .cwd field
5. First key starting with "/"  ← Codex format: key IS the path

Step 5 is the workaround for Codex's { "/path/to/project": { metadata } } where the key itself is the cwd.

OpenAI — HTTP

server/index.js calls getCodexCwd(headers, parsedBody, hub/process fallback) after preprocessBody, so HTTP/SSE entries persist the same normalized cwd as WebSocket entries.

OpenAI — WebSocket

server/ws-proxy.js first tries x-codex-turn-metadata at upgrade time. If the socket initially has no session/cwd metadata, the first real response.create frame can promote the session out of the codex-raw bucket using response.create.metadata.thread_id/session_id and .workspaces.


3. Usage & Cost

pricing.js:calculateCost(usage, model, provider) — same call for both wire families; provider is the upstream key (anthropic, openai, xai — grok resolves through describeAgentModule(agent).upstreamKey) and selects a LiteLLM provider/model row before the model-only lookup (#568).

Usage extraction

ProviderSourceExtractor
AnthropicSSE events message_start + message_deltawire-parsers/anthropic.js:extractUsage
OpenAI (HTTP)response.usage from SSE events or bodywire-parsers/openai.js:extractUsage
OpenAI (WS)ctx.lastUsage captured before WS_SKIP_EVENTS filterSame extractUsage

Field mapping (→ canonical)

Wire fieldAnthropicOpenAICanonical field
Input tokensusage.input_tokensusage.input_tokens or prompt_tokensinput_tokens
Output tokensmessage_delta.usage.output_tokensusage.output_tokens or completion_tokensoutput_tokens
Cache creationusage.cache_creation_input_tokensN/A (hardcoded 0)cache_creation_input_tokens
Cache creation detailusage.cache_creation.ephemeral_{5m,1h}_input_tokensN/Acache_creation (nested)
Cache readusage.cache_read_input_tokensusage.input_tokens_details.cached_tokenscache_read_input_tokens

OpenAI's extractUsage also preserves native input_tokens_details and output_tokens_details for provider-specific display.


4. Token Breakdown

helpers.js:tokenizeRequest(body) produces { system, tools, messages, perMessage[], total }.

Both providers share the same function. It branches on body.messages (Anthropic) vs body.input (OpenAI) vs body.instructions (OpenAI system prompt).

perMessage mapping (OpenAI input items)

Input item typeMapped block typeContent source
function_call_outputtool_resultitem.output
{content: "string"}textitem.content
{content: [{text}]}text (per block)b.text
Items with no .typetextitem.content (string fallback)

5. Tool Call Extraction

Anthropic

helpers.js:extractToolCalls(messages) — scans messages[].content[] for type:"tool_use" blocks, counts by name (e.g. {Skill: 3, Bash: 1}). Skill/Workflow are not expanded to per-name keys here — the key stays the plain tool name so toolCalls remains a stable contract for the dashboard (tc['Skill'], tool chips, tool-utilization).

helpers.js:extractSkillCalls(messages) — companion that counts only the model-initiated Skill tool, keyed by the invoked skill name (e.g. { "superpowers:brainstorming": 2 }). Persisted as the separate skillCalls index field and read by ccxray usage for per-skill stats. (Workflow has no skill input, so it is excluded.)

Why two fields instead of one: see ADR 0001 — toolCalls vs skillCalls. Short version: toolCalls is a dashboard contract, so per-skill detail lives in a separate index — don't merge them back.

OpenAI

helpers.js:extractOpenAIToolCalls(responseEventsOrOutput) — scans:

  • WS events: response.output_item.done / .added with item.type:"function_call"
  • HTTP output[]: flat items { type: "function_call", name, ... }

Dedup by item.call_id or item.id (avoids double-counting .added + .done).

Alias maps

Both server and client maintain identical maps:

{ exec_command: 'Bash', shell: 'Bash', read_mcp_resource: 'Read', apply_patch: 'Edit' }
  • Server: helpers.js:OPENAI_TOOL_ALIASES
  • Client: messages.js:CODEX_TOOL_ALIASES

Guard: meta-tools (tool_search, web_search, image_generation) have no .name — all t.name access sites guard with t.name &&.


6. Timeline Rendering (Client)

messages.js:buildMergedSteps(messages, resEvents, provider) builds the unified timeline.

Auto-detection + normalization

messages[].type has "message" | "function_call" | "function_call_output"?
  → yes: normalizeOpenAIInput(messages) → Anthropic-shaped messages
  → no:  pass through as-is (already Anthropic format)

normalizeOpenAIInput conversion

OpenAI input item→ Anthropic message
{type:"message", role:"developer"}Skipped
{type:"message", role:"user"|"assistant"}{role, content:[{type:"text", text}]}
{type:"function_call", call_id, name, arguments}{role:"assistant", content:[{type:"tool_use", id:call_id, name, input:JSON.parse(arguments)}]}
{type:"function_call_output", call_id, output}{role:"user", content:[{type:"tool_result", tool_use_id:call_id, content:output}]}

Pipeline

PhaseInputAction
1aUser messagesBuild tool_use_id → tool_result map
2All messagesEmit human, assistant-text, tool-group steps
3resEventsDispatch to RENDERERS[provider].processEvent() for current-turn events

7. WS Frame Capture

ws-proxy.js captures Codex WebSocket content on the client→upstream path.

Capture logic

clientWs.on('message') → JSON.parse → dispatch on parsed.type:
  "response.create" (generate !== false)  → first-wins capture of model/instructions/input/tools
  "session.update"                        → update instructions (forward compat)

generate: false frames are warm-up pings — skipped. Without this guard, the warm-up (which has input: []) would shadow the real request.

Stored as _req.json

When ctx.clientRequest is populated: full reqLog with { provider, model, instructions, input, tools, ... }. When absent (non-JSON frames, binary): transport-only fallback with { provider: 'openai', transport: 'websocket', ... }.

Response events

WS_SKIP_EVENTS filters large envelope events from storage:

EventStored?Why
response.createdNo~35KB, redundant
response.in_progressNo~35KB, status-only
response.completedNoUsage/model extracted before skip filter
response.doneNoAlias for .completed
codex.rate_limitsNoNon-standard metadata
All othersYesTool calls, text deltas, content parts

Usage and model are extracted from envelope events before the skip filter (ws-proxy.js:488-489), so cost data is never lost.


8. Restore-Time Normalization

restore.js:loadEntryReqRes handles lazy-loading from disk.

Anthropic path

  1. Read _req.json (may be delta format)
  2. If prevId + msgOffset: follow chain recursively, splice prevMessages[0..offset] + delta
  3. Rehydrate sys_${hash}.jsonentry.req.system, tools_${hash}.jsonentry.req.tools
  4. Read _res.json → event array → entry.res

OpenAI path

  1. Read _req.json — store as-is (no dedup/delta for OpenAI)
  2. Read _res.jsonnormalizeOpenAIResponseSummary:
    • Extract response object from events
    • Populate model, usage, stopReason, title on entry
    • Build responseMetadata object

Provider dispatch

entry.provider (set by addEntry during live capture or inferred from stripped.provider at restore) determines which path.


9. Noise Filtering

OpenAI

wire-parsers/openai.js:isNoiseRequest matches Codex 0.133+ platform pings:

/v1/plugins/*       /v1/ps/plugins/*     /v1/connectors/*
/v1/api/codex/apps/*                     /v1/api/codex/usage/*

Forwarded with skipEntry: true — response reaches Codex, no dashboard entry.

Forwarding uses path-scoped ChatGPT profiles: plugin catalogs and connectors go under /backend-api, Responses stay under /backend-api/codex, and /v1/codex/* keeps its existing codex segment. The launcher-derived Apps MCP path /v1/api/codex/ps/mcp is normalized to /backend-api/ps/mcp.

/v1/codex/analytics-events/events (telemetry) is also filtered and forwarded as /backend-api/codex/analytics-events/events.

Anthropic

isNoiseRequest always returns false (no known startup noise).


10. System Prompt Display (Client)

miller-columns.js renders the System section:

if (req.system || req.instructions) {
  renderSystemBlockViewer(req.system || req.instructions)
}
ProviderSourceFormat
Anthropicreq.systemArray of {type:"text", text, cache_control?} blocks. B2 splitting extracts sections
OpenAIreq.instructionsSingle string. Rendered as-is (no B2 splitting)

Server-side version tracking (system-prompt.js:registerPromptVersion) uses sysHash (Anthropic) or instructions hash (OpenAI) for diff comparison.