# How I coordinate Claude and Codex sessions with a pull-based JSON handoff ledger

> Source: <https://dev.to/morinaga/how-i-coordinate-claude-and-codex-sessions-with-a-pull-based-json-handoff-ledger-5h93>
> Published: 2026-07-22 08:05:30+00:00

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](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.

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](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.

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](https://dev.to/articles/four-github-actions-cron-timing-bugs-daily-pipelines) I've hit before.

```
# 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](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.

`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](https://dev.to/articles/what-i-learned-building-pipeline-health-monitor-github-issues) handles the actual alerting when that happens.

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](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.

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.

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](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.

**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.*
