agent-coherence. Contact us

Shipped: Handing a File Off Without Abandoning It

A drafting agent hands plans/rollout.md at version 4 to a named reviewing agent, and the drafter's own later write to the file is refused as handed_off until the handoff ends

Agents that split a job pass files to each other all day. Getting the first agent to actually let go of one is the hard part.

Take two Claude Code sessions in one repository. A drafting agent writes plans/rollout.md up to version 4, and a reviewing agent takes it from there. Until this release the drafter’s only way to say it was done was to release its grant, which the Claude Code plugin does on its own at the end of every turn.

Twenty minutes later the drafter’s user asks for one more change to the canary percentages, and the drafter makes it. To the coordinator that is an ordinary edit: a fresh read, a fresh grant, a commit at version 5. The reviewer’s next read gets a stale warning, which is invalidation doing its job. What no warning can say is that the drafter was supposed to be finished, because nothing recorded it. A release looks exactly like a session that wandered off.

The key idea: a handoff names who comes next

The drafter hands plans/rollout.md to the reviewer by name, and the coordinator fences the drafter’s writes to it until the handoff ends.

The transfer stores a record per path: the giver, the successor, the version at transfer, the kind of claim the giver gave up and a status that starts as pending. The state log records the giver’s move under a new trigger, handoff, rather than as a release. The reviewer is granted nothing. It reads and acquires the file by the ordinary rules, and its acquire, its optimistic commit or an explicit accept marks the record completed.

In Claude Code the drafter runs the transfer in the same turn as its edit, because a session’s write grant ends with its turn. It names the reviewer by its session-level agent id, which the guide shows how to print from the reviewer’s shell:

agent-coherence-transfer --successor 6e271ee6-5a71-5508-bedf-ffd46d8898ce plans/rollout.md

The Python package’s agent-coherence-status then lists plans/rollout.md: bd35b34c → 6e271ee6 at version 4 (pending) under Handoffs.

What happens when the drafter tries to write again?

The coordinator refuses it. While the record is live, every write route answers the drafter’s session handed_off, naming the successor and the version at transfer, and lands nothing. The fence is keyed on the session, so the drafter’s subagents and fresh attempts are refused alike, and no retry or reacquire clears it. No timer lifts it. Exactly three things do: a commit moves the version, the successor declines, or the giver withdraws.

What happens next Record status Can the drafter write?
The reviewer reads the file pending No
The reviewer accepts, or starts editing completed No, until a commit moves the version
The reviewer commits version 5 completed, ended Yes, by the ordinary rules
The reviewer declines declined Yes, by the ordinary rules
The drafter withdraws, on its user’s instruction withdrawn Yes, by the ordinary rules
A third session edits first overtaken No, until that session’s commit moves the version

Accepting leaves the fence up on purpose, so a late drafter write cannot land at version 4 ahead of a reviewer that has not written yet. It is a different fence from the token that stops a zombie write, which asks whether ownership changed since you read. This one asks whether you gave the file away.

In Claude Code the refusal is a deny on the drafter’s next Edit or Write, in warn mode as well as strict, opening with “Edit denied: you handed plans/rollout.md to agent 6e271ee6 at v4, so this session can no longer write it.” It tells the session to stop and report, and names no command, because a refusal that hands a model the command that lifts it invites the model to run it. Recognized shell writes, such as a redirection, tee, sed -i or a cp onto the file, get the same deny.

We measured it with Haiku 4.5, Sonnet 5.5 and Opus 5.5 on Claude Code 2.1.291. Every session that met the deny stopped after that one refusal and told its user the file had been handed off. Without the shell deny, one warn-mode setup saw Sonnet append to the file through the shell in 4 of 10 runs. With it, that setup changed the file in none of 10.

Four ways to hand a file on

Surface Hand a path on The giver’s next write gets
Claude Code session agent-coherence-transfer a deny on Edit, Write and recognized shell writes
CoherentVolume vol.transfer(paths, successor=...) GiverFenced, never retried
stale-write-guard-fs MCP server swg_transfer handed_off, with recover: stop_and_report
HTTP POST /handoff/transfer {"ok": false, "reason": "handed_off"}

Each surface has matching accept, decline and withdraw verbs, and a third session is told its edits overtake the handoff. The routes are Python coordinator only, so the Claude Code plugin’s Node coordinator, the default for a fresh plugin workspace, answers them 404. And a model using both the plugin’s hooks and the MCP server is two sessions, so a handoff fences only the one that gave it.

A caller principal, and what it is not

The coordinator authenticates the workspace, not the caller, and the session a request acts as is a field in its body. A copied request or a stale session id could release another writer’s grants or commit under its name.

A caller principal binds a value to a session on its first POST /principal/claim, and every bundled client claims and sends one automatically. From then on, the hook routes that take, release or commit that session’s grants, the effect fence, the checkpoint and restore routes and the four handoff routes answer HTTP 400 to a request naming the session without it. The snapshot-session routes are the exception. /session/commit and /session/commit_all still admit a request with no principal and commit as the session it names. The coordinator admits sessions that never claimed a principal as before.

A long-lived client such as CoherentVolume or the MCP server presents only its own principal, so the coordinator refuses a request it sends naming another session. Between the drafter and the reviewer, two Claude Code sessions, the principal does not tell one from the other. The hook client and the handoff commands find a principal in .coherence/ by session id, so the reviewer’s session could still run agent-coherence-withdraw --session with the drafter’s session id and lift the fence. On that surface it catches only a client that never claimed a principal or presents the wrong one. Principals come from the Python coordinator only, and the plugin’s Node coordinator issues none.

It is not a security boundary. The bearer secret still grants full authority, and every principal sits in .coherence/state.db, readable by any process running as your OS user.

The effect fence, over plain HTTP

Once the reviewer approves the plan, it opens the rollout PR, an effect that cannot be called back. Until now only gate() in Python and swg_gate over MCP could ask the fence whether the input or its grant moved first. POST /hooks/effect-fence lets any HTTP client ask, behind the same loopback check and bearer token as every route. It takes one artifact with the session_id, path, expected_version, expected_generation and content_hash the caller captured, and answers {"verdict": "proceed"} or a hold with a published reason. A missing field is a 400, not a hold. Anything else that is not a recognized verdict counts as a hold, the Node coordinator’s 404 included, since it does not serve the route.

It also holds a case the other surfaces cannot see, an artifact whose content the coordinator never recorded, under an eighth reason, content_claim_absent, which a coordinated re-read clears. The fence orders an effect and never rolls one back. It narrows the window between the check and the effect without closing it, and it holds only effects that ask.

Who may restore a checkpoint?

Say the drafter checkpointed plans/rollout.md before the handoff. A restore’s registration used to check only that the manifest existed. Now the coordinator checks it against the checkpoint’s members and captured fingerprints, with typed refusals that change nothing. A checkpoint can also name a receiver, the one controller allowed to register it, through WorkspaceVersioner.checkpoint(name, receiver=...) or receiver_session_id over HTTP. The first controller to register claims the checkpoint for good, so one that stops partway leaves it registrable by no one else. Like the principal, this separates writers that follow the protocol and is not a security boundary.

A restore also reports what it overwrote, with observed_differs naming the discarded version and a digest of its content, though it is still a forward commit, not a merge. And WorkspaceVersioner.release_checkpoint drops the S3 legal holds a checkpoint placed. It keeps a hold only while another checkpoint in the same registry still uses it, so use one registry per bucket.

What can an operator read from /status now?

The operator view, ?detail=full with the Coherence-Local-Operator: true header, now shows which grants the sweep reclaimed and when, each write grant’s start time and each session’s last heartbeat beside the sweep’s thresholds, and who last wrote each artifact. That last field is attribution, not authentication: it names the session the committing request named. Every view reports registry_schema_version, 10 in this release. The default view carries each path’s handoff record and no longer publishes raw session ids or the absolute root, and a busy registry answers "degraded": true with null lists, which mean “cannot tell”, never “nothing tracked”. This is the Python coordinator’s /status. The plugin’s Node coordinator answers the operator view with 501 and serves the other views in a shape of its own.

Also in this release

  • Strict mode no longer lets a session write a file it was just refused a read of, on both coordinator backends.
  • A first read that a peer’s commit overtook no longer lets write_cas win over that peer’s update with no exception.
  • The coordinator writes policy files as quoted YAML, so tracking a glob such as *.md no longer empties the policy file while the request answers ok.
  • On the Python coordinator, untracking a strict path is a deliberate HTTP 409, untrack_strict_path. Remove its entry from .coherence/strict_mode.yaml and restart the coordinator instead. The plugin’s Node coordinator does not refuse it yet, so there an untrack still takes the path out of strict mode.
  • Coordinator requests no longer go through a configured HTTP proxy. If one carried your bearer token under an earlier release, replace the workspace’s bearer as the release notes describe.
  • A new offline demo, python -m examples.session_handoff.main, passes work between two session processes on one host through a shared file and a checkpoint restore, without the transfer verb. Run it from the root of a repository clone after pip install -e ., since the examples do not ship in the wheel.

Upgrading from 0.14.1

The registry moves from schema 7 to 10 on first open, the workspace CLI’s workspace.db included, and 0.14.1 refuses a store this release has opened. Back up first: stop every session, volume and MCP server, run agent-coherence-coordinator --prepare-for-migration, and copy .coherence/ with its permissions. Without that copy, the only way back is to move state.db aside and lose its retained content, fence state, principals, handoff records and checkpoint claims.

Upgrade the coordinator, its clients and the plugin together, with Claude Code plugin 0.6.0 or later. A plugin 0.5.0 hook client still works but claims no principal. Once a 0.15.0 client such as agent-coherence-transfer claims one for its session, the coordinator refuses that hook client’s edits, commits and releases with HTTP 400, and its edits go ahead uncoordinated. The guide’s Upgrading from 0.14.1 section lists every changed answer.

Scope, stated plainly

Single host, one coordinator, writers that go through it. That has not moved, and production cross-host fencing still has not shipped.

A handoff fences the giver and reserves nothing. It cannot stop a write that bypasses the coordinator, such as an editor or a script outside the hooks, and it denies a Claude Code giver’s shell write only in the forms the Bash hook recognizes. A write to a path built from a variable, a write through a writer tool the hook does not know, and an edit already in flight at the transfer all still land on disk, without a version. The known limits you are most likely to meet:

  • When all eight of the Python coordinator’s handler slots are busy it answers 503, which both hook clients turn into an empty answer. A stale warning is lost, a strict-mode deny becomes an allow, and so does the handoff giver’s deny, which lets the giver’s edit land without a version.
  • The Bash hook resolves relative paths from the coordinator root, so after a cd in an earlier Bash call, and in every claude --worktree session, the giver’s shell-write refusal can miss a relative path.
  • A CoherentVolume or MCP session’s handoff outlives its process, and nothing can withdraw it after the process exits.
  • After a decline or withdraw, the coordinator refuses a new transfer of the same path to the same successor as handoff_ended until the version moves or it evicts the ended record, 24 hours later by default.
  • A Claude Code session whose principal is refused and cannot be claimed again fails open, and its later edits run uncoordinated.

The full list is under Known limitations in the v0.15.0 release notes, and the taxonomy covers which failures are the runtime’s to prevent at all.

Get it running

A release says “I am done.” A handoff also says who is next, to the giver, the successor, every bystander and /status, until the work moves on. That record turns a file passed between agents from an assumption into something the coordinator enforces on a single host.

pip install agent-coherence

For Claude Code, use plugin 0.6.0 or later on a workspace the Python coordinator serves. A workspace stays on the backend that created its store, and no tool converts one, so do not switch a workspace the Node coordinator already serves. A new workspace with no store yet can select Python before the first session:

mkdir -p .coherence && printf 'python\n' > .coherence/coordinator_backend

The guide’s Targeted grant handoff section walks two Claude Code sessions through it step by step. Start on one host, with the file your agents already pass between them.

Frequently asked

How do I hand a file from one Claude Code session to another?

From the giving session, in the same turn as its last edit, run agent-coherence-transfer --successor with the other session's session-level agent id and the path, on a workspace the agent-coherence Python coordinator serves. On a single host, the coordinator then denies the giving session's Edit and Write calls on that file until the version moves, the successor declines, or the giver withdraws. Use Claude Code plugin 0.6.0 or later, and note that the plugin's Node coordinator does not serve handoff.

What is the difference between releasing a file and handing it off to another agent?

A release gives up the claim and records nothing about who comes next, so the coordinator cannot tell it from an abandoned claim. A handoff names one successor and records the giver, the successor and the version at transfer, and on a single host the Python coordinator refuses the giver's writes to that path while the record is live. Neither one reserves the file, so other sessions can still read and write it.

Is the agent-coherence caller principal a security boundary?

No. On a single host, the Python coordinator uses it to keep long-lived clients that follow the protocol, such as CoherentVolume and the MCP server, from acting as each other's sessions, but it does not tell Claude Code sessions apart, and the plugin's Node coordinator issues no principals at all. The bearer secret still grants full authority, and the coordinator stores every principal in .coherence/state.db, which any process running as the same OS user can read.

Can I downgrade from agent-coherence 0.15.0 to 0.14.1?

Not in place. 0.15.0 moves the registry to schema 10 the first time it opens a workspace, workspace.db included, and 0.14.1 refuses a store at that schema. Back up .coherence/ before upgrading, because restoring that copy loses only what was recorded since, while without it the only way back is to move state.db aside and lose its retained content, fence state, principals, handoff records and checkpoint claims.