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.
The 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.
I built a 120-line Node.js script backed by a JSON file in the repo. It has tracked 114 article review handoffs without a missed coordination. Here's what I built and what I'd change.
Why pull, not push
The 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.
When 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 is readable by any session, persists across runs, and gives a free audit trail via git log.
The consumer polls instead of subscribing. Claude starts a task, commits the record, ends its turn. On the next scheduled run, Claude calls check to see if the record is complete. 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.
The 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 already uses the repo as the source of truth for everything else. Putting the coordination ledger there keeps it consistent.
The ledger format
Each task record in codex-handoffs.json looks like this:
{
"id": "article-114-review",
"owner": "codex",
"requester": "claude",
"stage": "article-review",
"status": "complete",
"artifact": "content/articles/114-2026-07-20-notable-this-week.md",
"started_at": "2026-07-20T22:08:36.527Z",
"updated_at": "2026-07-20T22:08:56.857Z",
"max_silence_minutes": 30,
"next_check_at": null,
"note": ""
}
next_check_at is 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. The check command exits with code 3 if any active task's next_check_at has passed and the status is still running; 0 if all clear. A CI step can key off that exit code directly.
max_silence_minutes sets the outer deadline. If started_at + max_silence_minutes has elapsed and the task is still in-progress, check flags it regardless of next_check_at. This catches the case where the Codex workflow never ran at all — a CI trigger misconfiguration I've hit before.
Three commands
# Claude registers a new review task
node scripts/codex-handoff.mjs start \
--id article-115-review \
--stage article-review \
--artifact content/articles/115-2026-07-21-....md \
--check-in 10
# Claude polls — exits 3 if any task is overdue, 0 if all clear
node scripts/codex-handoff.mjs check
# Codex marks work done
node scripts/codex-handoff.mjs complete --id article-115-review
--check-in 10 sets the initial next_check_at to 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.
One deliberate choice: start throws if a task with the same id is 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 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.
The ownership fields
owner and requester aren't enforced by the script — nothing stops Claude from marking a task complete itself. They're documentation fields that capture the intent: this task was started by Claude, assigned to Codex, and Codex is responsible for the complete call.
In practice, the complete call lives at the end of the Codex review workflow step. If Codex crashes before reaching it, the task stays running until max_silence_minutes elapses and the next check call returns exit code 3. This surfaces the problem rather than hiding it — a property I care about more than smooth silent recovery. The pipeline health monitor handles the actual alerting when that happens.
What I'd do differently
The 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. I haven't built that because it isn't a problem yet and I don't want to optimize prematurely.
The other gap: check output goes to stdout, which disappears into the CI log unless I grep for it. Adding a $GITHUB_STEP_SUMMARY write when overdue tasks are found would surface the alert in the Actions tab without requiring a log dive. The four content QC scripts have a similar blind spot — good exit codes, hard to scan output.
I'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.
What worked
The 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.
Every handoff is auditable: git log -p -- data/coordination/codex-handoffs.json shows 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.
Keeping coordination state in the repo also means the pattern composes naturally with the shared Claude Haiku ETL client: 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.
FAQ
Does Codex update the ledger automatically?
No. The Codex session calls node scripts/codex-handoff.mjs complete --id <id> as 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.
What if the JSON file gets a concurrent write from two sessions?
Both 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.
Why not use GitHub Actions outputs or artifacts?
Artifacts 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.
What stage types does the ledger support?
stage is a free string. I currently only use article-review, but the same script could track ETL handoffs, image generation, or any work where one session depends on another's output. The owner/requester fields document who's responsible for each side.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (2)
The pull-based polling over a JSON file in the repo is exactly how I have been coordinating Claude Code sessions. I added a similar pattern: one agent drops a browser task into a handoff file, the next session picks it up and drives Chrome via browser-act CLI. git log gives you a free audit trail. npx skills add browser-act/skills --skill browser-act
The JSON ledger approach is elegant. I have been doing something similar with a file-per-task model -- each agent session writes its own status file and the next one reads it. What I did not think of was the next_check_at timeout pattern. That single field would have saved me from a few frustrating debugging sessions where tasks silently stalled.