Skip to content

Instantly share code, notes, and snippets.

@niquola
Last active March 13, 2026 16:32
Show Gist options
  • Select an option

  • Save niquola/8a8cb68379ca56cfcc67a98f5c8947c7 to your computer and use it in GitHub Desktop.

Select an option

Save niquola/8a8cb68379ca56cfcc67a98f5c8947c7 to your computer and use it in GitHub Desktop.
Claude Code vs Codex CLI: Session JSONL Format Comparison
#!/usr/bin/env bun
/**
* Convert session JSONL between Claude Code and Codex CLI formats.
*
* Usage:
* bun c2c.ts claude2codex <input.jsonl> [output.jsonl]
* bun c2c.ts codex2claude <input.jsonl> [output.jsonl]
* bun c2c.ts claude2codex # interactive session picker
* bun c2c.ts codex2claude # interactive session picker
*/
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from "fs";
import { join, basename } from "path";
import { homedir } from "os";
// ── Helpers ──────────────────────────────────────────────────────────
function parseJsonl(text: string): any[] {
return text.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
}
function toJsonl(records: any[]): string {
return records.map((r) => JSON.stringify(r)).join("\n") + "\n";
}
function asContentArray(content: any): any[] {
if (Array.isArray(content)) return content;
if (content == null) return [];
return [content];
}
let callIdCounter = 0;
const nextCallId = () => `call_conv_${(++callIdCounter).toString(36).padStart(6, "0")}`;
let toolUseCounter = 0;
const nextToolUseId = () => `toolu_conv_${(++toolUseCounter).toString(36).padStart(6, "0")}`;
// ── Session discovery ───────────────────────────────────────────────
interface SessionInfo {
path: string;
id: string;
timestamp: Date;
cwd: string;
model: string;
firstMessage: string;
size: number;
}
function listCodexSessionPaths(): string[] {
const base = join(homedir(), ".codex", "sessions");
if (!existsSync(base)) return [];
const files: string[] = [];
function walk(dir: string) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
try {
const stat = statSync(full);
if (stat.isDirectory()) {
walk(full);
} else if (entry.endsWith(".jsonl")) {
files.push(full);
}
} catch {}
}
}
walk(base);
return files.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
}
function findClaudeSessions(): SessionInfo[] {
const base = join(homedir(), ".claude", "projects");
if (!existsSync(base)) return [];
const sessions: SessionInfo[] = [];
for (const project of readdirSync(base)) {
const projectDir = join(base, project);
try {
if (!statSync(projectDir).isDirectory()) continue;
} catch { continue; }
for (const file of readdirSync(projectDir)) {
if (!file.endsWith(".jsonl")) continue;
const fullPath = join(projectDir, file);
const stat = statSync(fullPath);
try {
const firstLines = readFileSync(fullPath, "utf-8").split("\n").slice(0, 20);
let id = file.replace(".jsonl", "");
let cwd = project.replace(/-/g, "/").replace(/^\//, "");
let model = "";
let firstMessage = "";
let timestamp = stat.mtime;
for (const line of firstLines) {
if (!line.trim()) continue;
const obj = JSON.parse(line);
if (obj.type === "user" && obj.message?.content) {
if (!firstMessage) {
for (const b of obj.message.content) {
const text = typeof b === "string" ? b : b?.text;
if (text && text.length > 5 && !text.startsWith("[Time:") && !text.startsWith("[Request") && !text.startsWith("<") && !text.startsWith("#")) {
firstMessage = text.slice(0, 80).replace(/\n/g, " ");
break;
}
}
}
if (obj.cwd) cwd = obj.cwd;
if (obj.sessionId) id = obj.sessionId;
if (obj.timestamp) timestamp = new Date(obj.timestamp);
}
if (obj.type === "assistant" && obj.message?.model) {
model = obj.message.model;
}
}
sessions.push({ path: fullPath, id, timestamp, cwd, model, firstMessage, size: stat.size });
} catch { continue; }
}
}
return sessions.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}
function findCodexSessions(): SessionInfo[] {
const sessions: SessionInfo[] = [];
for (const full of listCodexSessionPaths()) {
try {
const stat = statSync(full);
const firstLines = readFileSync(full, "utf-8").split("\n").slice(0, 20);
let id = "";
let cwd = "";
let model = "";
let firstMessage = "";
let timestamp = stat.mtime;
for (const line of firstLines) {
if (!line.trim()) continue;
const obj = JSON.parse(line);
if (obj.type === "session_meta") {
id = obj.payload?.id ?? basename(full, ".jsonl");
cwd = obj.payload?.cwd ?? "";
model = obj.payload?.model ?? obj.payload?.model_provider ?? "";
timestamp = new Date(obj.payload?.timestamp ?? obj.timestamp);
}
if (obj.type === "response_item" && obj.payload?.role === "user" && obj.payload?.type === "message") {
for (const c of obj.payload?.content ?? []) {
if ((c.type === "input_text") && c.text?.length > 5 && !c.text.startsWith("#") && !c.text.startsWith("<")) {
if (!firstMessage) firstMessage = c.text.slice(0, 80).replace(/\n/g, " ");
break;
}
}
}
}
if (!id) id = basename(full, ".jsonl");
sessions.push({ path: full, id, timestamp, cwd, model, firstMessage, size: stat.size });
} catch { continue; }
}
return sessions.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}
function buildCodexScaffold(sessionId: string, ts: string, cwd: string) {
const templatePath = listCodexSessionPaths().find((p) => {
try {
const records = parseJsonl(readFileSync(p, "utf-8")).slice(0, 8);
return records.some((r) => r.type === "response_item" && r.payload?.type === "message" && r.payload?.role === "developer")
&& records.some((r) => r.type === "turn_context");
} catch {
return false;
}
});
const turnId = crypto.randomUUID();
const out: any[] = [];
if (!templatePath) {
out.push({
timestamp: ts,
type: "session_meta",
payload: {
id: sessionId,
timestamp: ts,
cwd,
originator: "codex_cli_rs",
cli_version: "0.114.0",
source: "cli",
model_provider: "openai",
},
});
out.push({
timestamp: ts,
type: "turn_context",
payload: {
turn_id: turnId,
cwd,
current_date: ts.slice(0, 10),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
approval_policy: "on-request",
sandbox_policy: {
type: "workspace-write",
writable_roots: [join(homedir(), ".codex", "memories")],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
model: "gpt-5.4",
personality: "pragmatic",
collaboration_mode: { mode: "default", settings: { model: "gpt-5.4", reasoning_effort: "high", developer_instructions: null } },
realtime_active: false,
effort: "high",
summary: "none",
},
});
return out;
}
const template = parseJsonl(readFileSync(templatePath, "utf-8")).slice(0, 8);
for (const rec of template) {
const cloned = JSON.parse(JSON.stringify(rec));
cloned.timestamp = ts;
if (cloned.type === "session_meta") {
cloned.payload.id = sessionId;
cloned.payload.timestamp = ts;
cloned.payload.cwd = cwd;
}
if (cloned.type === "event_msg" && cloned.payload?.turn_id) {
cloned.payload.turn_id = turnId;
}
if (cloned.type === "turn_context") {
cloned.payload.turn_id = turnId;
cloned.payload.cwd = cwd;
cloned.payload.current_date = ts.slice(0, 10);
cloned.payload.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (cloned.payload.collaboration_mode?.settings) {
cloned.payload.collaboration_mode.settings.developer_instructions =
cloned.payload.collaboration_mode.settings.developer_instructions ?? null;
}
}
if (cloned.type === "response_item" && cloned.payload?.type === "message" && cloned.payload?.role === "user") {
continue;
}
if (cloned.type === "event_msg" && cloned.payload?.type === "user_message") {
continue;
}
out.push(cloned);
}
return out;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
}
function formatDate(d: Date): string {
return d.toISOString().slice(0, 16).replace("T", " ");
}
async function pickSession(sessions: SessionInfo[]): Promise<SessionInfo | null> {
if (sessions.length === 0) {
console.error("No sessions found.");
return null;
}
const show = sessions.slice(0, 20);
console.error("\nAvailable sessions:\n");
for (let i = 0; i < show.length; i++) {
const s = show[i];
const date = formatDate(s.timestamp);
const size = formatSize(s.size).padStart(6);
const msg = s.firstMessage || "(no message)";
const cwdShort = s.cwd.replace(homedir(), "~").split("/").slice(-2).join("/");
console.error(` ${String(i + 1).padStart(3)}) ${date} ${size} ${cwdShort.padEnd(30).slice(0, 30)} ${msg.slice(0, 50)}`);
}
if (sessions.length > 20) console.error(` ... and ${sessions.length - 20} more`);
process.stderr.write("\nSelect session number: ");
const input = await new Promise<string>((resolve) => {
let buf = "";
process.stdin.setEncoding("utf-8");
process.stdin.once("data", (data) => { buf += data; resolve(buf.trim()); });
process.stdin.resume();
});
const idx = parseInt(input, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= show.length) {
console.error("Invalid selection.");
return null;
}
return show[idx];
}
// ── Claude → Codex ──────────────────────────────────────────────────
function claude2codex(records: any[]): any[] {
const out: any[] = [];
const callIdMap = new Map<string, string>();
const firstMsg = records.find((r) => r.type === "user" || r.type === "assistant");
const sessionId = firstMsg?.sessionId ?? crypto.randomUUID();
const ts0 = firstMsg?.timestamp ?? new Date().toISOString();
out.push(...buildCodexScaffold(sessionId, ts0, firstMsg?.cwd ?? process.cwd()));
for (const rec of records) {
if (rec.type === "queue-operation" || !rec.message) continue;
const ts = rec.timestamp ?? new Date().toISOString();
const content = asContentArray(rec.message.content);
if (rec.type === "user") {
const role = rec.__originalRole === "developer" ? "developer" : "user";
const customToolResultIds = new Set(rec.__customToolResultIds ?? []);
const textParts: any[] = [];
for (const block of content) {
if (typeof block === "string") {
textParts.push({ type: "input_text", text: block });
} else if (block.type === "text") {
textParts.push({ type: "input_text", text: block.text });
} else if (block.type === "tool_result") {
if (textParts.length > 0) {
out.push({ timestamp: ts, type: "response_item", payload: { type: "message", role, content: [...textParts] } });
textParts.length = 0;
}
const callId = callIdMap.get(block.tool_use_id) ?? block.tool_use_id;
const isCustom = customToolResultIds.has(block.tool_use_id);
out.push({
timestamp: ts,
type: "response_item",
payload: {
type: isCustom ? "custom_tool_call_output" : "function_call_output",
call_id: callId,
output: serializeToolResultContent(block.content),
},
});
}
}
if (textParts.length > 0) {
out.push({ timestamp: ts, type: "response_item", payload: { type: "message", role, content: textParts } });
}
} else if (rec.type === "assistant") {
const customToolUseIds = new Set(rec.__customToolUseIds ?? []);
const textBuf: any[] = [];
const flushText = () => {
if (textBuf.length > 0) {
out.push({ timestamp: ts, type: "response_item", payload: { type: "message", role: "assistant", content: [...textBuf] } });
textBuf.length = 0;
}
};
for (const block of content) {
if (typeof block === "string") {
textBuf.push({ type: "output_text", text: block });
continue;
}
if (block.type === "text") {
textBuf.push({ type: "output_text", text: block.text });
} else if (block.type === "thinking") {
flushText();
out.push({ timestamp: ts, type: "response_item", payload: { type: "reasoning", content: block.thinking, summary: [] } });
} else if (block.type === "tool_use") {
flushText();
const callId = nextCallId();
callIdMap.set(block.id, callId);
const isCustom = customToolUseIds.has(block.id);
const mappedInput = isCustom ? unwrapCustomToolInput(block.input) : JSON.stringify(block.input);
out.push({
timestamp: ts,
type: "response_item",
payload: {
type: isCustom ? "custom_tool_call" : "function_call",
name: block.name,
[isCustom ? "input" : "arguments"]: mappedInput,
call_id: callId,
},
});
}
}
flushText();
}
}
return out;
}
function serializeToolResultContent(content: any): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) return JSON.stringify({ __structured: true, blocks: content });
return JSON.stringify(content);
}
function deserializeToolResultContent(output: string): any {
try {
const parsed = JSON.parse(output);
if (parsed?.__structured && Array.isArray(parsed.blocks)) return parsed.blocks;
return output;
} catch {
return output;
}
}
function wrapCustomToolInput(input: any): Record<string, any> {
if (input && typeof input === "object" && !Array.isArray(input)) {
return { ...input, __codex_custom_input_wrapped: false };
}
return {
__codex_custom_input_wrapped: true,
raw_input: typeof input === "string" ? input : JSON.stringify(input),
};
}
function unwrapCustomToolInput(input: any): any {
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
if (input.__codex_custom_input_wrapped) {
return input.raw_input ?? "";
}
const { __codex_custom_input_wrapped, ...rest } = input;
return rest;
}
// ── Codex → Claude ──────────────────────────────────────────────────
function codex2claude(records: any[]): any[] {
const out: any[] = [];
const toolIdMap = new Map<string, string>();
const meta = records.find((r) => r.type === "session_meta");
const sessionId = meta?.payload?.id ?? crypto.randomUUID();
const cwd = meta?.payload?.cwd ?? process.cwd();
const model = meta?.payload?.model ?? "unknown";
let prevUuid: string | null = null;
function pushClaude(type: "user" | "assistant", content: any[], ts: string, extra: Record<string, any> = {}) {
const uuid = crypto.randomUUID();
out.push({
type,
message: { role: type, content, ...(type === "assistant" ? { model } : {}) },
uuid,
parentUuid: prevUuid,
sessionId,
cwd,
timestamp: ts,
...extra,
});
prevUuid = uuid;
}
let assistantContent: any[] = [];
let assistantCustomToolUseIds: string[] = [];
let pendingToolResults: any[] = [];
let pendingCustomToolResultIds: string[] = [];
let lastTs = new Date().toISOString();
function flushAssistant() {
if (assistantContent.length > 0) {
pushClaude(
"assistant",
assistantContent,
lastTs,
assistantCustomToolUseIds.length > 0 ? { __customToolUseIds: assistantCustomToolUseIds } : {},
);
assistantContent = [];
assistantCustomToolUseIds = [];
}
}
function flushToolResults() {
if (pendingToolResults.length > 0) {
for (let i = 0; i < pendingToolResults.length; i++) {
const block = pendingToolResults[i];
const toolUseId = block?.tool_use_id;
const extra =
pendingCustomToolResultIds.includes(toolUseId)
? { __customToolResultIds: [toolUseId] }
: {};
pushClaude("user", [block], lastTs, extra);
}
pendingToolResults = [];
pendingCustomToolResultIds = [];
}
}
for (const rec of records) {
const ts = rec.timestamp ?? lastTs;
lastTs = ts;
const p = rec.payload;
if (!p) continue;
if (rec.type === "session_meta" || rec.type === "turn_context" || rec.type === "event_msg" || rec.type === "compacted") continue;
if (rec.type !== "response_item") continue;
if (p.type === "message") {
if (p.role === "user" || p.role === "developer") {
flushAssistant();
flushToolResults();
const content = (p.content ?? []).map((c: any) =>
c.type === "input_text" ? { type: "text", text: c.text } : c
);
pushClaude("user", content, ts, p.role === "developer" ? { __originalRole: "developer" } : {});
} else if (p.role === "assistant") {
flushToolResults();
for (const c of p.content ?? []) {
if (c.type === "output_text") assistantContent.push({ type: "text", text: c.text });
}
}
} else if (p.type === "reasoning") {
const summaryText = (p.summary ?? []).map((s: any) => s.text).filter(Boolean).join("\n");
const reasoningText = p.content ?? summaryText;
if (reasoningText) {
assistantContent.push({ type: "text", text: `[Codex reasoning]\n${reasoningText}` });
} else if (p.encrypted_content) {
assistantContent.push({ type: "text", text: "[Codex reasoning omitted: encrypted]" });
}
} else if (p.type === "function_call" || p.type === "custom_tool_call") {
flushToolResults();
const toolId = nextToolUseId();
toolIdMap.set(p.call_id, toolId);
const isCustom = p.type === "custom_tool_call";
let input: any;
if (isCustom) { input = wrapCustomToolInput(p.input); }
else { try { input = JSON.parse(p.arguments); } catch { input = { raw: p.arguments }; } }
assistantContent.push({ type: "tool_use", id: toolId, name: p.name, input });
if (isCustom) assistantCustomToolUseIds.push(toolId);
} else if (p.type === "function_call_output" || p.type === "custom_tool_call_output") {
flushAssistant();
const toolId = toolIdMap.get(p.call_id) ?? p.call_id;
const isCustom = p.type === "custom_tool_call_output";
pendingToolResults.push({
type: "tool_result", tool_use_id: toolId,
content: deserializeToolResultContent(p.output),
});
if (isCustom) pendingCustomToolResultIds.push(toolId);
}
}
flushAssistant();
flushToolResults();
return out;
}
// ── CLI ──────────────────────────────────────────────────────────────
async function main() {
const [direction, inputFile, outputFile] = process.argv.slice(2);
if (!direction) {
console.error(`Usage:
bun c2c.ts claude2codex [input.jsonl] [output.jsonl]
bun c2c.ts codex2claude [input.jsonl] [output.jsonl]
Without input file — interactive session picker.`);
process.exit(1);
}
if (direction !== "claude2codex" && direction !== "codex2claude") {
console.error(`Unknown direction: ${direction}. Use "claude2codex" or "codex2claude".`);
process.exit(1);
}
let srcPath: string;
let dstPath: string | undefined = outputFile;
if (inputFile) {
srcPath = inputFile;
} else {
// Interactive picker
const sessions = direction === "claude2codex" ? findClaudeSessions() : findCodexSessions();
const picked = await pickSession(sessions);
if (!picked) process.exit(1);
srcPath = picked.path;
console.error(`\nSelected: ${picked.path}`);
// Generate output path
if (direction === "claude2codex") {
const now = picked.timestamp;
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
const ts = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
const outDir = join(homedir(), ".codex", "sessions", String(y), m, d);
const { mkdirSync } = await import("fs");
mkdirSync(outDir, { recursive: true });
const uuid = picked.id.length === 36 ? picked.id : crypto.randomUUID();
dstPath = join(outDir, `rollout-${ts}-${uuid}.jsonl`);
} else {
// Find appropriate Claude project dir from session cwd
const cwdEncoded = "-" + picked.cwd.replace(/\//g, "-").replace(/^-/, "");
const outDir = join(homedir(), ".claude", "projects", cwdEncoded);
const { mkdirSync } = await import("fs");
mkdirSync(outDir, { recursive: true });
// File name must match sessionId for claude -r to find it
const sid = picked.id.length === 36 ? picked.id : crypto.randomUUID();
dstPath = join(outDir, `${sid}.jsonl`);
}
}
const text = readFileSync(srcPath, "utf-8");
const records = parseJsonl(text);
const result = direction === "claude2codex" ? claude2codex(records) : codex2claude(records);
const output = toJsonl(result);
if (dstPath) {
writeFileSync(dstPath, output);
console.error(`\nWrote ${result.length} records to ${dstPath}`);
// Print resume command
if (direction === "claude2codex") {
const meta = result.find((r: any) => r.type === "session_meta");
const id = meta?.payload?.id ?? basename(dstPath, ".jsonl");
console.error(`\nResume with:\n codex resume ${id}`);
} else {
const sessionId = result.find((r: any) => r.sessionId)?.sessionId;
if (sessionId) {
console.error(`\nResume with:\n claude -r ${sessionId}`);
}
}
} else {
process.stdout.write(output);
}
}
main();

Claude Code vs Codex CLI: Session JSONL Format Comparison

Both Claude Code (Anthropic) and Codex CLI (OpenAI) persist conversation sessions as JSONL files — one JSON object per line. Despite serving the same purpose, the formats are incompatible due to different API conventions and metadata structures.

Converter: c2c.ts

A bidirectional converter is included in this gist. Usage:

bun c2c.ts claude2codex <input.jsonl> [output.jsonl]
bun c2c.ts codex2claude <input.jsonl> [output.jsonl]

Conversion Mapping

Claude Code Direction Codex CLI
queue-operation (skipped)
user message response_item/message role=user
user message + __originalRole: "developer" response_item/message role=developer
assistant message response_item/message role=assistant
thinking content block response_item/reasoning
tool_use content block response_item/function_call
tool_result content block response_item/function_call_output
tool_use + __customTool response_item/custom_tool_call
tool_result + __customTool response_item/custom_tool_call_output
(not stored) session_meta
(not stored) turn_context
(not stored) event_msg/* (token_count, agent_message, etc.)
(not stored) compacted
uuid/parentUuid tree (linear sequence, generated)
sessionId, cwd, version session_meta.payload
message.usage (tokens) (dropped)
message.model, message.id (dropped)
permissionMode, gitBranch (dropped)

Round-Trip Test Results

Tested on real sessions (85K+ items Claude, 878 items Codex):

Test Items Mismatches Match
Claude → Codex → Claude 85,961 0 100%
Claude idempotency (step1 = step3) 85,961 0 100%
Codex → Claude → Codex 878 1 99.9%
Codex idempotency (step1 = step3) 878 0 100%

What Gets Lost

Codex → Claude (lossy):

  • developer role → stored as user with __originalRole: "developer" marker (recoverable in round-trip, but not native Claude)
  • reasoning is not restored as Claude thinking blocks, because Claude requires thinking.signature and those signatures cannot be generated locally
  • plain reasoning.content is downgraded to regular assistant text prefixed with [Codex reasoning]
  • encrypted reasoning becomes [Codex reasoning omitted: encrypted]
  • custom_tool_call.input may be a raw string in Codex; when converting to Claude it is wrapped into an object marker so tool_use.input remains valid, then unwrapped back to the original string on claude2codex
  • turn_context, event_msg, compacted, session_meta → dropped (operational metadata, not conversation content)
  • JSON whitespace in arguments → normalized by JSON.parse/JSON.stringify (1 mismatch in 878 items)

Claude → Codex (lossy):

  • uuid/parentUuid message tree → flattened to linear sequence (regenerated on round-trip)
  • message.usage (token counts, cache stats) → dropped
  • message.model, message.id, requestId → dropped
  • permissionMode, gitBranch, version → dropped
  • queue-operation records → dropped

Claude thinking.signature

Claude thinking blocks are not plain text. They require a model-generated signature field. Without that signature, Claude rejects the restored session with an error like:

messages.N.content.M.thinking.signature: Field required

Because those signatures cannot be recreated offline, the converter never emits synthetic Claude thinking blocks during codex2claude.

Claude Content Block Schema

Claude validates content blocks strictly during restore/resume:

  • tool_use only accepts type, id, name, input
  • tool_use.input must be an object
  • tool_result only accepts type, tool_use_id, content
  • extra fields like __customTool are rejected
  • for parallel tool use, restore is safer when each tool_result is emitted as its own user message, matching normal Claude session structure

This is why custom Codex tool metadata is stored on the surrounding message record, not inside Claude content blocks.

Preserved in both directions:

  • All text messages (user + assistant) with correct roles
  • All thinking/reasoning blocks
  • All tool calls (function_call + custom_tool_call) with arguments
  • All tool results with content
  • Original block order within messages
  • Timestamps

Storage Layout

Claude Code Codex CLI
Location ~/.claude/projects/<encoded-path>/<uuid>.jsonl ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
Subagents <session-dir>/subagents/agent-*.jsonl N/A
Organization By project path By date
Resume claude --resume <id> / claude --continue N/A

Top-Level Record Types

Claude Code

Every line has a top-level type field:

type Description
queue-operation Session lifecycle (dequeue/enqueue)
user User message
assistant Assistant response

Codex CLI

Every line has type + payload.type:

type / payload.type Description
session_meta Session metadata (cwd, model, instructions)
response_item / message User, developer, or assistant message
response_item / reasoning Model reasoning trace
response_item / function_call Tool invocation
response_item / function_call_output Tool result
response_item / custom_tool_call Custom tool invocation (e.g. apply_patch)
response_item / custom_tool_call_output Custom tool result
turn_context Turn boundary marker (model, cwd, policies)
compacted Context compaction marker
event_msg / agent_message Agent status events
event_msg / agent_reasoning Agent reasoning events
event_msg / token_count Token usage tracking
event_msg / task_started / task_complete Task lifecycle
event_msg / turn_aborted Turn cancellation

Message Format

Claude Code (Anthropic Messages API)

{
  "type": "user",
  "message": {
    "role": "user",
    "content": [
      {"type": "text", "text": "Hello"}
    ]
  },
  "uuid": "5e5afa2f-...",
  "parentUuid": null,
  "sessionId": "2472ff89-...",
  "version": "2.1.29",
  "cwd": "/path/to/project",
  "gitBranch": "master",
  "timestamp": "2026-02-12T13:12:02.822Z",
  "permissionMode": "bypassPermissions"
}
{
  "type": "assistant",
  "message": {
    "model": "claude-opus-4-20250514",
    "id": "msg_01Ro9p...",
    "role": "assistant",
    "content": [
      {"type": "text", "text": "Response here"}
    ],
    "usage": {
      "input_tokens": 3,
      "output_tokens": 3,
      "cache_creation_input_tokens": 3193,
      "cache_read_input_tokens": 16268
    }
  },
  "uuid": "5c135d9b-...",
  "parentUuid": "5e5afa2f-...",
  "requestId": "req_011CY4..."
}

Codex CLI (OpenAI Responses API)

{
  "timestamp": "2026-03-05T19:24:33.466Z",
  "type": "response_item",
  "payload": {
    "type": "message",
    "role": "user",
    "content": [
      {"type": "input_text", "text": "Hello"}
    ]
  }
}
{
  "timestamp": "2026-03-05T19:24:40.134Z",
  "type": "response_item",
  "payload": {
    "type": "message",
    "role": "assistant",
    "content": [
      {"type": "output_text", "text": "Response here"}
    ]
  }
}

Tool Calls

Claude Code

Tool call is a content block inside the assistant message:

// Inside assistant message content array:
{
  "type": "tool_use",
  "id": "toolu_01CEFhz...",
  "name": "Read",
  "input": {"file_path": "/path/to/file.ts"}
}

Tool result is a content block inside the next user message:

// Inside user message content array:
{
  "type": "tool_result",
  "tool_use_id": "toolu_01CEFhz...",
  "content": "file contents here..."
}

Codex CLI

Tool calls and results are separate top-level records:

{
  "type": "response_item",
  "payload": {
    "type": "function_call",
    "name": "exec_command",
    "arguments": "{\"cmd\":\"ls -la\"}",
    "call_id": "call_s6FT7hQ4..."
  }
}
{
  "type": "response_item",
  "payload": {
    "type": "function_call_output",
    "call_id": "call_s6FT7hQ4...",
    "output": "total 56\ndrwxr-xr-x  9 ..."
  }
}

Codex also has custom_tool_call / custom_tool_call_output for tools like apply_patch:

{
  "type": "response_item",
  "payload": {
    "type": "custom_tool_call",
    "name": "apply_patch",
    "input": "*** Begin Patch\n*** Update File: file.ts\n...",
    "call_id": "call_y6GZ..."
  }
}

Key Differences Summary

Aspect Claude Code Codex CLI
API origin Anthropic Messages API OpenAI Responses API
Text content type "type": "text" "type": "input_text" / "output_text"
Roles user, assistant user, assistant, developer
Tool call model Inline in message content Separate function_call records
Tool result model Inline in next user message Separate function_call_output records
Tool call ID toolu_* prefix call_* prefix
Tool arguments Native JSON object JSON-encoded string
Custom tools Same as regular tool_use Separate custom_tool_call type, input is raw string
Message linking uuid / parentUuid tree Linear sequence
Reasoning/thinking thinking block in assistant content Separate reasoning record (often encrypted)
Session metadata Spread across message fields Dedicated session_meta record
Token usage Inside assistant message usage Separate token_count events
Model info In assistant message.model In session_meta + turn_context
Context compaction Transparent (compressed in memory) compacted record with replacement history
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment