Session handoff: Claude Code ⇄ Codex
The flagship use of the canonical message format: point at one provider's session file, get back the other provider's session/rollout file, and continue the conversation with claude --resume or codex resume. The loop is bidirectional — switch providers mid-task in either direction, e.g. "I ran out of Codex credits, finish this in Claude", or "hand this Claude session to Codex for a second opinion, then bring the review comments back."
Codex → Claude
- Parse the Codex rollout into canonical form:
parseCodexRollout(lines) -> { thread, messages, report }. - Emit a Claude Code session from the canonical thread + messages:
emitClaudeSession(thread, messages, { cwd }) -> { sessionId, records, lines, report }— a chain of Claude session lines with synthesized envelopes (uuid/parentUuidchain,sessionId, timestamps preserved from the source). - Write the lines to
~/.claude/projects/<claudeProjectDirName(cwd)>/<sessionId>.jsonland runclaude --resume <sessionId>(or the SDK'sresumeoption).
import { parseCodexRollout } from "@techsavvyash/clanker";
import { emitClaudeSession, claudeProjectDirName } from "@techsavvyash/clanker";
const { thread, messages } = parseCodexRollout(rolloutText);
const emitted = emitClaudeSession(thread, messages, { cwd: process.cwd() });
// write emitted.lines.join("\n") to
// ~/.claude/projects/<claudeProjectDirName(cwd)>/<emitted.sessionId>.jsonlFidelity stance
Nothing is silently transformed or dropped; every emit returns a report of what changed:
- Tool history keeps vendor names. Historical
tool_useentries stay namedexec_command,apply_patch, etc. — the Anthropic API accepts arbitrary historical tool names, and renaming them to Claude's own tools (Bash,Edit) would fabricate history and mislead the model about what tools are actually available to it now. - Reasoning is dropped on emit, and reported. Codex reasoning is usually
encrypted_content-only (opaque to us), and Claudethinkingblocks require a cryptographicsignaturethat can't be fabricated. Every dropped reasoning block is counted in the emit report rather than silently vanishing. - Unanswered tool calls are settled with placeholders. If a rollout ends mid-turn with a
function_callthat never got itsfunction_call_output, emission synthesizes a placeholdertool_resultso the transcript stays API-valid — again, counted and reported, never silent. - System/developer messages become user-role context. Claude sessions have no system conversation lines, so canonical
systemmessages (Codex'sdeveloperrole) are wrapped in<context-from-previous-session>tags on a user-role line by default (systemAs: "user-context"), or dropped entirely withsystemAs: "skip".
This has been verified against the real claude CLI: a fixture Codex rollout ending mid-turn was emitted, dropped into ~/.claude/projects/, and resumed — the resumed model recalled the completed work, the pending request, and the wrapped developer instructions. One gotcha: claude resolves the cwd's realpath before deriving the project directory name, so the emitted session's directory must match that, not a symlinked path.
Claude → Codex, and back: the round trip
The reverse direction closes the loop: emitCodexRollout(thread, messages, options) turns a canonical thread (typically ingested from a live Claude Code session via parseClaudeSession) into Codex rollout lines, ready to drop into ~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<timestamp>-<sessionId>.jsonl — exactly the layout codex resume <sessionId> expects. emittedLines, fileName, and dateDir on the return value are computed to match that layout so callers never have to re-derive it.
import { parseClaudeSession, emitCodexRollout } from "@techsavvyash/clanker";
const { thread, messages } = parseClaudeSession(claudeSessionText);
const emitted = emitCodexRollout(thread, messages, {
cwd: process.cwd(),
link: { sourceSessionId: thread.metadata?.sourceSessionId, sourceProvider: "claude" }
});
// write emitted.lines.join("\n") to
// ~/.codex/sessions/<emitted.dateDir>/<emitted.fileName>Why hand a session to Codex mid-task at all: get a second model's code review on work Claude just did, without re-explaining the whole task — Codex resumes the rollout with the full history already in context, does its review as new turns appended to the same file, and then those review turns can be merged straight back into the original Claude session.
The sesh link marker and the emittedLines boundary
emitCodexRollout's link option is what makes the merge-back possible. It's stamped onto the session_meta record as payload.sesh, alongside emittedLines — the total number of rollout lines this emitter wrote. Codex tolerates the extra field (verified live on codex-cli 0.139.0) and simply appends its own turns after it:
{
"timestamp": "2026-07-04T10:00:00.000Z",
"type": "session_meta",
"payload": {
"id": "b2b1b4b0-...",
"cwd": "/Users/you/project",
"originator": "sesh",
"cli_version": "0.0.0",
"model_provider": "openai",
"sesh": {
"sourceSessionId": "b2b1b4b0-...claude-session-id...",
"sourceProvider": "claude",
"emittedLines": 42
}
}
}Because the marker is self-describing, a later pull doesn't need any external bookkeeping to find where the handoff happened: it opens the rollout, finds session_meta.payload.sesh, and slices everything at index emittedLines and beyond — that slice is, by construction, exactly what Codex appended after the handoff and nothing the emitter itself wrote.
Merge-back fidelity
Merging Codex's new turns into the original Claude session is deliberately conservative:
- The original session file is never modified. Merge-back works on a copy — the source
.jsonlis byte-preserved, sothinkingblocks and their signatures (which can't be reconstructed) survive untouched in the original. - New lines are chained onto the copy with
emitClaudeSession'sinitialParentUuid. The copy gets a freshsessionId, and the Codex turns are emitted as a new chain whose first record'sparentUuidpoints at the last line of the copied original — so the tree reads as one continuous conversation. - Codex boilerplate is filtered before merging, not after: the developer preamble and
<environment_context>pseudo-prompts that codex injects at the start of every turn are Codex-internal scaffolding, not part of the conversation, and are dropped rather than replayed into Claude as if a user had typed them.
Fidelity / caveats reference (Claude → Codex direction)
| Concern | Behavior |
|---|---|
Reasoning / thinking blocks | Dropped on emit — Codex's reasoning items require encrypted_content that can't be fabricated. Counted in report.dropped.reasoning / reasoning-redacted, never silent. |
| Sidechain / subagent threads | Not exported. Only the primary thread's messages become rollout lines; sidechains ingested by parseClaudeSession as child threads are left out of emitCodexRollout's input entirely. |
| Images in tool results | Flattened to their text parts (or dropped) when a tool_result.output isn't a plain string — Codex's function_call_output.output is string-only. Non-text parts are counted, not silently discarded. |
| Tool call names / ids | Kept verbatim (Bash, Read, toolu_… call ids) — verified live that the OpenAI API accepts replayed history with foreign tool names and call-id formats. |
| Unanswered tool calls | Settled with a synthesized placeholder function_call_output so the rollout stays API-valid; counted in report.notes. |
Canonical system messages | Emitted as Codex developer-role messages (the inverse of the Codex → Claude direction, where developer becomes wrapped user context). |
The sesh CLI
packages/sesh (@techsavvyash/sesh) wraps both directions into a handful of commands. It is not yet published to npm — run it from the repo, or build and link it locally.
Codex → Claude
npx @techsavvyash/seshWith no arguments, it finds the newest Codex session whose cwd matches your current directory (scanning ~/.codex/sessions), converts it, and prints the claude --resume <sessionId> command to run. Other options:
sesh --list # interactively pick from discovered Codex sessions
sesh --all # don't filter discovery to the current directory
sesh --turns 3 # keep only the last 3 turns — for big sessions, to control cost
sesh --dry-run # do everything except write the session file
sesh --launch # after writing, exec `claude --resume <sessionId>` directly
sesh rollout.jsonl # convert an explicit rollout path instead of discovering oneClaude → Codex, and back
sesh to-codex [session.jsonl] [--list] [--all] [--turns <n>] [--dry-run] [--launch]
sesh pull [rollout.jsonl] [--all] [--dry-run] [--launch]sesh to-codex discovers the newest Claude session for the current directory under $CLAUDE_CONFIG_DIR/projects/, converts it with emitCodexRollout, writes the rollout into $CODEX_HOME/sessions/, and prints codex resume <id>. The flags mirror the Codex → Claude direction: --list to pick interactively, --all to search beyond the current directory, --turns <n> to cap history, --dry-run to skip the write, --launch to exec codex resume immediately.
sesh pull closes the loop: it finds the newest rollout carrying a sesh marker for the current directory, slices everything past emittedLines (the turns Codex added — e.g. its code review), filters the Codex boilerplate described above, and merges those turns into a copy of the original Claude session — original lines byte-preserved, only the sessionId rewritten, new turns chained onto it via parentUuid. It prints claude --resume <newId>. The original files, on both sides, are never modified.
A typical loop:
# working in Claude, ask it to implement a feature
sesh to-codex --launch # hand the session to Codex
# in codex: "review this diff for bugs"
sesh pull --launch # bring the review back into a fresh Claude session
# fix the review comments in Claude
sesh to-codex --launch # hand it back for a re-review, repeat as neededOutput reports what happened, in the same spirit as the underlying library — messages ingested vs. emitted, anything dropped (reasoning is called out as "opaque"), how many unanswered tool calls were settled with placeholders, and the resulting session's approximate token size (with a warning past ~60k tokens suggesting --turns).
Discovery mechanics
sesh scans $CODEX_HOME/sessions (default ~/.codex/sessions) for rollout-*.jsonl files, reads each one's session_meta for its cwd and first prompt, and — without --list or --all — picks the most recent session whose cwd matches your current working directory's realpath. With --turns <n>, only the last n turns are kept before emitting (dropped messages and orphaned tool results are counted and reported), which keeps large sessions cheap to resume. sesh to-codex and sesh pull apply the same realpath-matching discovery, scoped to $CLAUDE_CONFIG_DIR/projects/ and to rollouts carrying a sesh marker, respectively.