Revisions & Conflicts
Why every write states a revision
More than one agent — and usually a human too — can be looking at and changing the same canvas at once. Every write tool call carries an expectedRevision: the revision the caller believes is current. If it doesn't match the canvas's actual current revision, the write does not partially apply, does not merge, and does not guess at a resolution. It comes back as conflict. See Explanation: Canvas Model for why merging was ruled out rather than attempted — a visual scene doesn't have the same well-defined merge semantics a text file does.
What a conflict looks like on the wire
{
"jsonrpc": "2.0",
"id": 20,
"result": {
"resultType": "tools/call",
"content": [{
"type": "text",
"text": "{\"outcome\":\"conflict\",\"expectedRevision\":41,\"currentRevision\":43}"
}],
"isError": false
}
}Both revisions are always present — the one you expected and the one that's actually current — so recovering never requires a second call just to find out what changed underneath you.
Recovering correctly
- Re-read the canvas with
canvas_get_stateto get the current scene and revision (43in the example above, not the stale41). - Recompute your intended change against what's actually there now, not against the scene you started from.
- Resubmit with the new revision.
The failure mode this guards against is resubmitting the same write with only the revision number bumped — that silently discards whatever changed the canvas out from under you in the meantime. See How to handle canvas conflicts for this as a step-by-step checklist, with a second wire example.
conflict is never success, and it's never the same as rejected
Code that only checks for a thrown error will mishandle a conflict — it's returned as a normal, well-formed result (isError: false), not an exception. Check the outcome field explicitly rather than treating "no error was thrown" as "the write applied."
conflict and rejected look similar (neither is applied) but need opposite fixes:
| Outcome | Cause | Fix |
|---|---|---|
conflict | expectedRevision is stale | Re-read, recompute, resubmit — see steps above |
rejected | Role too low, or a malformed reason | Get a higher role, or send a clean single-line reason — re-reading changes nothing |
See Roles & Permissions for the rejected side of this table in more depth.
Repeated conflicts are contention, not a bug
If a caller keeps losing the race against another writer, that's what active, concurrent use of the same canvas looks like — not evidence of a broken client. Back off and retry rather than looping tightly against the same stale assumption.
The reason field travels with every write, including a retry
A reason containing a newline or a control character is rejected outright, not sanitized — this is deliberate, since the field is shown to humans in the audit trail and can be fed back into another agent's context. See Canvas Roles for the exact rejection payload this produces. When you resubmit after a conflict, remember the reason requirement applies to the retry exactly as it did to the original call.
Where to go next
- Canvas Roles — the complete outcome contract.
- How to handle canvas conflicts — the imperative, step-by-step version of this page.
- Driving the Canvas from an Agent — where revision tracking fits into a multi-step drawing session.