{"slug": "how-i-coordinate-claude-and-codex-sessions-with-a-pull-based-json-handoff-ledger", "title": "How I coordinate Claude and Codex sessions with a pull-based JSON handoff ledger", "summary": "A developer built a 120-line Node.js script using a JSON file in a GitHub repo to coordinate Claude and Codex agent sessions in a multi-session AI content pipeline. The pull-based handoff ledger, stored in `data/coordination/codex-handoffs.json`, tracks review status and persists across cron-driven workflow runs without external services. The system has handled 114 article review handoffs reliably.", "body_md": "Three months into running a multi-session AI content pipeline — Claude handling daily article and video generation, Codex reviewing the output — I ran into a coordination problem that took me a while to frame correctly.\n\nThe question is simple on the surface: how do two agent sessions, running independently in GitHub Actions, tell each other that a piece of work is ready, reviewed, and safe to publish? The sessions don't overlap. Claude generates an article and ends its turn. Codex reviews it in a separate workflow triggered by the commit. By the time Codex writes its verdict, Claude's session is gone.\n\nI built a 120-line Node.js script backed by a JSON file in the repo. It has tracked [114 article review handoffs](https://dev.to/articles/fail-closed-quality-gates-adsense-approval-bet) without a missed coordination. Here's what I built and what I'd change.\n\nThe obvious mental model is a push notification: Codex finishes → posts a message → Claude consumes it. That's correct for continuously-running services. It doesn't fit cron-driven agents that each run, do work, and terminate.\n\nWhen neither side is listening when the other finishes, you need the handoff to live somewhere persistent. The repo is already the shared state between every workflow step in this pipeline. A JSON file committed to `data/coordination/codex-handoffs.json`\n\nis readable by any session, persists across runs, and gives a free audit trail via `git log`\n\n.\n\nThe consumer polls instead of subscribing. Claude starts a task, commits the record, ends its turn. On the next scheduled run, Claude calls `check`\n\nto see if the record is `complete`\n\n. If yes, it publishes. If not, it waits or alerts. This is the same pattern as git itself: a commit is a record in shared storage, not a push to a subscriber.\n\nThe decision to make coordination visible in the repo also means no external service to maintain, no auth token to rotate, no network hop. The [CI pipeline](https://dev.to/articles/single-ci-pipeline-two-youtube-channels-three-seo-sites) already uses the repo as the source of truth for everything else. Putting the coordination ledger there keeps it consistent.\n\nEach task record in `codex-handoffs.json`\n\nlooks like this:\n\n```\n{\n  \"id\": \"article-114-review\",\n  \"owner\": \"codex\",\n  \"requester\": \"claude\",\n  \"stage\": \"article-review\",\n  \"status\": \"complete\",\n  \"artifact\": \"content/articles/114-2026-07-20-notable-this-week.md\",\n  \"started_at\": \"2026-07-20T22:08:36.527Z\",\n  \"updated_at\": \"2026-07-20T22:08:56.857Z\",\n  \"max_silence_minutes\": 30,\n  \"next_check_at\": null,\n  \"note\": \"\"\n}\n```\n\n`next_check_at`\n\nis the key field. When a task is in-flight, it holds an ISO timestamp — the earliest moment Claude should poll again. When the task completes, it's `null`\n\n. The `check`\n\ncommand exits with code `3`\n\nif any active task's `next_check_at`\n\nhas passed and the status is still `running`\n\n; `0`\n\nif all clear. A CI step can key off that exit code directly.\n\n`max_silence_minutes`\n\nsets the outer deadline. If `started_at + max_silence_minutes`\n\nhas elapsed and the task is still in-progress, `check`\n\nflags it regardless of `next_check_at`\n\n. This catches the case where the Codex workflow never ran at all — a [CI trigger misconfiguration](https://dev.to/articles/four-github-actions-cron-timing-bugs-daily-pipelines) I've hit before.\n\n```\n# Claude registers a new review task\nnode scripts/codex-handoff.mjs start \\\n  --id article-115-review \\\n  --stage article-review \\\n  --artifact content/articles/115-2026-07-21-....md \\\n  --check-in 10\n\n# Claude polls — exits 3 if any task is overdue, 0 if all clear\nnode scripts/codex-handoff.mjs check\n\n# Codex marks work done\nnode scripts/codex-handoff.mjs complete --id article-115-review\n```\n\n`--check-in 10`\n\nsets the initial `next_check_at`\n\nto 10 minutes from now. I use 10 for article review because Codex sessions are fast. I'd use 30-60 for heavier work like ETL audits or image generation batches.\n\nOne deliberate choice: `start`\n\nthrows if a task with the same `id`\n\nis already active. I considered making it idempotent — silently update if exists. I didn't, because starting and resuming are different operations. Silently updating hides the case where a previous run didn't clean up properly. I'd rather see the error and investigate. The [auto-tuner](https://dev.to/articles/three-archetype-signals-youtube-auto-tuner-two-weeks) had a different class of bug where stale state from a previous run silently colored the next run's output. Explicit errors are easier to catch than silent overwrites.\n\n`owner`\n\nand `requester`\n\naren't enforced by the script — nothing stops Claude from marking a task `complete`\n\nitself. They're documentation fields that capture the intent: this task was started by Claude, assigned to Codex, and Codex is responsible for the `complete`\n\ncall.\n\nIn practice, the `complete`\n\ncall lives at the end of the Codex review workflow step. If Codex crashes before reaching it, the task stays `running`\n\nuntil `max_silence_minutes`\n\nelapses and the next `check`\n\ncall returns exit code `3`\n\n. This surfaces the problem rather than hiding it — a property I care about more than smooth silent recovery. The [pipeline health monitor](https://dev.to/articles/what-i-learned-building-pipeline-health-monitor-github-issues) handles the actual alerting when that happens.\n\nThe ledger grows without bound. After 114 tasks, the file is about 18 KB — negligible today. When it crosses 100 KB I'll add a trim pass that moves entries older than 30 days to `codex-handoffs-archive.json`\n\n. I haven't built that because it isn't a problem yet and I don't want to optimize prematurely.\n\nThe other gap: `check`\n\noutput goes to stdout, which disappears into the CI log unless I grep for it. Adding a `$GITHUB_STEP_SUMMARY`\n\nwrite when overdue tasks are found would surface the alert in the Actions tab without requiring a log dive. The [four content QC scripts](https://dev.to/articles/four-content-qc-scripts-before-directory-pages-go-live) have a similar blind spot — good exit codes, hard to scan output.\n\nI'd also consider SQLite instead of flat JSON if the task volume grew. SQLite handles concurrent writes natively, which matters if two Claude sessions ever tried to write the ledger simultaneously. Right now they don't: each workflow runs in a dedicated job and tasks are committed sequentially. But the assumption is implicit, and a future parallel job would break it silently.\n\nThe simplest thing is often right. A committed JSON file, read and written by a single Node.js process per session, with three subcommands covering every coordination scenario I've encountered. No external dependencies beyond the Node.js standard library. No auth tokens. No eventual-consistency gotchas.\n\nEvery handoff is auditable: `git log -p -- data/coordination/codex-handoffs.json`\n\nshows exactly when each task was registered and when it was marked complete. When I'm debugging why an article didn't publish, the first thing I check is the ledger history — it tells me whether the Codex review step ran, when, and what the outcome was.\n\nKeeping coordination state in the repo also means the pattern composes naturally with the [shared Claude Haiku ETL client](https://dev.to/articles/shared-claude-haiku-client-prompt-caching): every session gets the full state by cloning the repo, and every state change is a commit. The ledger doesn't require a separate sync step.\n\n**Does Codex update the ledger automatically?**\n\nNo. The Codex session calls `node scripts/codex-handoff.mjs complete --id <id>`\n\nas its final step. That requires Codex to know the task ID ahead of time — Claude includes it in the article commit message so the Codex workflow can extract it.\n\n**What if the JSON file gets a concurrent write from two sessions?**\n\nBoth agents commit through a single process per session, so simultaneous writes don't happen in the current setup. If I ever parallelize CI jobs writing to the same file, I'd move to SQLite or add an exclusive lock file before writing.\n\n**Why not use GitHub Actions outputs or artifacts?**\n\nArtifacts don't persist across workflow runs the way a committed file does. The handoff record needs to survive from the Claude run through the Codex run through the next Claude run — sometimes spanning 24 hours. A committed file handles that without any extra machinery.\n\n**What stage types does the ledger support?**\n\n`stage`\n\nis a free string. I currently only use `article-review`\n\n, but the same script could track ETL handoffs, image generation, or any work where one session depends on another's output. The `owner`\n\n/`requester`\n\nfields document who's responsible for each side.\n\n*Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.*", "url": "https://wpnews.pro/news/how-i-coordinate-claude-and-codex-sessions-with-a-pull-based-json-handoff-ledger", "canonical_source": "https://dev.to/morinaga/how-i-coordinate-claude-and-codex-sessions-with-a-pull-based-json-handoff-ledger-5h93", "published_at": "2026-07-22 08:05:30+00:00", "updated_at": "2026-07-22 08:30:17.444397+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "artificial-intelligence"], "entities": ["Claude", "Codex", "GitHub Actions", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/how-i-coordinate-claude-and-codex-sessions-with-a-pull-based-json-handoff-ledger", "markdown": "https://wpnews.pro/news/how-i-coordinate-claude-and-codex-sessions-with-a-pull-based-json-handoff-ledger.md", "text": "https://wpnews.pro/news/how-i-coordinate-claude-and-codex-sessions-with-a-pull-based-json-handoff-ledger.txt", "jsonld": "https://wpnews.pro/news/how-i-coordinate-claude-and-codex-sessions-with-a-pull-based-json-handoff-ledger.jsonld"}}