Skip to content

Checkpoints & review gates

Two mechanisms let you see and undo what an agent did, and they share one diff surface. Checkpoints snapshot the workspace around every run and every chat turn, so any of them has a diff — nothing to switch on; click the ± beside a run in the Runs sidebar. Review gates are a node type you drop into a graph to make the run stop mid-flight and wait for your approve or reject.

A shadow git repository (<latchHome>/runs/checkpoints.git, with the workspace as its work-tree) snapshots around every run: pre-run <runId> before it starts, run <runId> after it finishes. Two consequences follow from the “shadow” part — your own repositories are never touched by LatchAI’s undo history, and the workspace doesn’t have to be a git repository at all for this to work. Commits are authored as latchai <latchai@local>, so they are obvious for what they are.

Nothing about this is opt-in. Every workflow run and every chat turn begins with a pre-run commit of whatever was outstanding and ends with a run <id> commit of what changed, so “what did that do?” always has an answer. Because work items live at workspace/work/ and agent memory at workspace/memory/, a card an agent moved and a note it rewrote are in the same diff as the files it edited.

Run diffs surface as checkpoint.created events carrying a short sha, a --stat summary and the diff itself (capped at 100,000 characters), and the Runs sidebar grows a ± per run that opens it.

A run’s diff, opened from the Runs sidebar

Every run has a before and after.

A few boundaries are deliberate:

  • Workspace only. Mounted project folders are not in the shadow repo — they usually have their own git, and the git panel is the surface for those. What gets checkpointed is what agents write into workspace/: drafts, briefs, chat notes, dashboard and atlas exports, and agent memory.
  • Dependency trees and symlinked project roots are excludednode_modules/, *.log, .DS_Store and projects — because a diff drowning in ten thousand vendored files is not a review, and git’s output buffer agrees.
  • A run that changed nothing produces no checkpoint and no event, so the sidebar’s ± simply doesn’t appear.

Two failure modes are worth knowing about, because both are quiet by design.

If the shadow repo cannot be created at all — no git on the path, an unwritable home — checkpoints are disabled for that session. The daemon logs checkpoints disabled: … once at boot and everything else keeps working; you simply get no ± and no gate diffs.

Git in the shadow repo runs synchronously on the daemon’s only thread, so it is bounded at 60 seconds. A git add -A over a very large workspace on a cold cache is legitimately slow, and the generous limit is deliberate — but if it does expire, that run loses its checkpoint rather than the engine losing its thread.

The overlay’s ⟲ Revert changes button applies an inverse commit in the shadow repo (git revert --no-edit <sha>), which undoes that run’s file changes without rewriting history.

The button only appears when the overlay was opened with a revertable sha, and today that means chat turns — the ± Review changes affordance in the chat panel. A run diff opened from the Runs sidebar is read-only for the same reason: it is opened without one.

The endpoint itself is not chat-specific. POST /api/checkpoints/revert with {"sha": "…"} reverts any checkpoint:

Terminal window
curl -X POST http://127.0.0.1:7777/api/checkpoints/revert \
-H 'content-type: application/json' \
-d '{"sha":"9f2c4d81aa"}'

The review_gate node type pauses a run where you put it. It snapshots the mid-run diff, emits a gate.waiting event carrying the --stat and the diff (the same 100,000-character cap), and blocks until the gate is resolved. The workbench auto-opens a modal review overlay — a colored unified diff, an optional note, and Approve/Reject.

Approving continues the run, and the note becomes the node’s output, so downstream nodes can read what the reviewer said. Rejecting fails the node, and by default that fails the run.

A review gate waiting for approval

The run is blocked until someone decides.

A pending gate always outranks any other diff you have open, and — unlike a browse-only diff — the gate overlay has no close button and does not dismiss on a click outside. That is on purpose: a paused run wants a decision, and burying the question behind a diff somebody opened to read is how a pipeline stalls until morning.

A gate is an ordinary node with no config of its own — everything it needs it gets from where you put it in the graph:

{
"formatVersion": 2,
"id": "release-notes",
"name": "Release notes",
"nodes": [
{ "id": "trigger", "type": "manual_trigger", "position": { "x": 60, "y": 160 }, "config": {} },
{ "id": "draft", "type": "agent", "position": { "x": 320, "y": 160 },
"config": { "agent": "release-writer", "prompt": "Draft the notes into out/notes.md." } },
{ "id": "review", "type": "review_gate", "position": { "x": 580, "y": 160 }, "config": {} },
{ "id": "publish", "type": "agent", "position": { "x": 840, "y": 160 },
"config": { "agent": "publisher",
"prompt": "Publish out/notes.md. The reviewer said: {{review}}" } }
],
"edges": [
{ "from": "trigger", "to": "draft" },
{ "from": "draft", "to": "review" },
{ "from": "review", "to": "publish" }
]
}

The diff the reviewer sees is everything the run has changed so far — from the run’s starting commit to the working tree at this instant — not just the immediately preceding node. That is usually what you want: the question a gate asks is “is this run’s work acceptable”, not “did that one step behave”.

{{review}} resolves to approved when the note is empty, and approved: <your note> when it isn’t. That makes the note a real input rather than an audit comment: “approved: tighten the headline before publishing” is something the next agent can act on.

Rejecting fails the node with review rejected: <note>, which by default fails the run — the run.failed event carries the reason, so the log records why a nightly job stopped. Either way a gate.resolved event records the decision and the note, so the log holds the answer as well as the question.

review_gate takes no config, but it is an ordinary node, so the per-node error policy applies to it like any other:

  • retry re-runs the gate node, which means it asks again — a fresh gate.waiting with a fresh diff.
  • timeoutMs bounds how long one attempt may block. Without it a gate waits as long as the engine does, which is the default and usually what you want for an overnight approval.
  • onError: "continue" turns a rejection into a handled outcome: the node’s output becomes { ok: false, error: "review rejected: …", attempts: 1 } and the run carries on.
  • onError: "branch" does the same and routes the run down the node’s when: "error" edges — which is how you build “if the reviewer says no, file a work item instead of dying”.

A stop always wins over all of it: pressing ⏹ on a run blocked at a gate unblocks the wait with run stopped while waiting for review, and the run’s terminal event is run.stopped. Without that a stopped run would sit forever on a question nobody was going to answer.

Under concurrency above 1, a gate blocks only its own branch: other branches keep running while the reviewer decides.

Gates are resolved over HTTP, which is what the overlay calls:

Terminal window
curl -X POST http://127.0.0.1:7777/api/gates/resolve \
-H 'content-type: application/json' \
-d '{"runId":"run-1757390512-3","nodeId":"review","approved":true,"note":"ship it"}'

GET /api/gates answers with two lists:

{
"pending": ["run-1757390512-3:review"],
"orphaned": [{ "key": "run-1757301004-8:review", "runId": "run-1757301004-8",
"nodeId": "review", "at": 1757301180000 }]
}

pending is the keys with a live waiter — the ones resolve can actually answer. A key that isn’t there comes back 404 (no such pending gate), so a double-approve is a clear error rather than a silent no-op.

orphaned is the other half, and the reason gates survive a restart.

Chat turns are checkpointed too, and working-changes review points the identical overlay at your git diff. Whether the change came from a scheduled run, a chat turn, or your own uncommitted work, “show me the diff” looks the same.