Skip to content

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

json
{
  "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

  1. Re-read the canvas with canvas_get_state to get the current scene and revision (43 in the example above, not the stale 41).
  2. Recompute your intended change against what's actually there now, not against the scene you started from.
  3. 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:

OutcomeCauseFix
conflictexpectedRevision is staleRe-read, recompute, resubmit — see steps above
rejectedRole too low, or a malformed reasonGet 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

Built with purpose.