# Three JSON routes made my review board agentic

> Source: <https://dev.to/prodbymarcu/three-json-routes-made-my-review-board-agentic-3kbd>
> Published: 2026-10-29 22:10:45+00:00

An agent handoff is a route that answers three questions: which version is current, what feedback is still open on it, and what files produced it. Write those three answers as JSON and most of the plumbing disappears, along with any client library, state machine or ceremony sitting between the model and the data.

The build was one evening, October 28, 2026. A video-review workspace shaped after Flask (flask.do), their feature list read off the public product tour, code entirely mine, dependencies limited to the standard library. A `ThreadingHTTPServer` from `http.server` bound to `127.0.0.1:8791`, one Python file for the API, one HTML file for the UI. Their button says "Reply to Claude / Opus 5.5", mine says "Reply with Hermes". What I dropped was teams, permissions, share links, pricing tiers and cloud storage, sold to companies and useless at my own desk. Single-user loopback.

One GET carries a job description.

``` php
GET /api/agent/<pid> -> {latest_version, unresolved_feedback[], context_bundle}
```

Nine lines, and the line doing the thinking is this filter:

```
open_fb = [f for f in pr.get("feedback", []) if latest and f.get("version") == latest["label"] and not f.get("resolved")]
```

Two clauses decide what a model may act on. Feedback filed against v1 stops being work the moment v2 exists, which is the behavior you want when someone re-watches a new cut and leaves fresh thoughts on that one. An agent never redoes a note the previous version already shipped, and it never wonders whether its own attempt counted, because the resolve POST came back `{"ok": true}`.

The board prints the command. Under the video sits a `<pre>` block holding the curl call plus the goal sentence for whatever project is open, so the human step is copy-paste, then paste. Nothing for the operator to keep current while versions roll.

Markers on the playbar need time as a share of duration, `left = t_ms / dur * 100`, clamped between 1 and 99 percent. The client knows duration only after metadata arrives, so my first lane waited 4000 ms for `loadedmetadata` and gave up at `(el.duration || 10) * 1000`. A note filed at 2000 ms on a 6 second clip belongs at 33% across the bar. On that fallback it lands at 20%, dragging the dot 0.8 seconds early in real footage whenever the network loses the race.

The fix probed the file server-side once, at upload.

```
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", ...], timeout=8)
dur = int(float(out.stdout.strip()) * 1000) if out.stdout.strip() else None
```

Two clips went through that probe and stored `dur_ms: 6000` and `dur_ms: 7500`. Every marker placement downstream reads from those integers, so geometry gets fixed at write time and no browser state can disagree with it later. Ask the thing that owns the media for its length before you let a renderer estimate it.

Four kinds of notes, each anchored to milliseconds plus an author, each keeping its own payload shape. A typed note carries text and a priority color. A draw carries strokes as point arrays plus hex. A voice take carries a path served off `/media/`. A reference carries an image or clip path. Strokes stay raw geometry so they scale with the frame, which is how a circle sketched over one render still sits right after the next re-render changes resolution.

That round carried three open items on v1. A note marked important at t=2000, "make it 1.5s longer, the hold reads short on the board". A closed loop of six points drawn at t=3500 in `#e5484d`. A voice take at t=5200 arriving as 17,314 bytes of libopus webm. Ids came out `launch-film_0`, `_1`, `_2`, assigned by array position at write time.

The first pass had two JS bugs. Nested point arrays hit the same map-join branch as flat ones, fixed by branching on `typeof f.strokes[0][0] === "number"`, and the boot wrapper got a catch that paints the error into the page. A blank dark board tells you nothing, a stack printed in the viewport at least names the failure.

Every version carries its own context: source files with sizes, plus a `CHANGES.md` string written for the next reader.

```
{"name": "easing-curves.html", "kb": 8}
"changes_md": "v2 of v1, from the 2 notes on v1: +1.5s hold and warmer grade per draw circle"
```

That sentence is the durability mechanism. An agent reading `/api/agent/<pid>` later gets the latest label, open feedback, and this bundle in one response, so it knows what changed without re-reading the repo or asking a human what shipped. Mine keeps it a plain string in a JSON file I own.

State lives in one atomic write, tmp then replace under a threading lock:

```
tmp = STORE.with_suffix(".json.tmp"); tmp.write_text(json.dumps(d, indent=1)); os.replace(tmp, STORE)
```

Two uploads landing together can't interleave into half a project, which matters because the agent posts versions while a person drags scrub bars.

Real commands, real output, verified October 28 before anything went public.

``` php
POST /api/project/new       {"id": "launch-film"}   -> {"ok": true}
POST .../version            v1.mp4                  -> {'ok': True, 'version': 'v1'}
POST .../feedback           note, draw, voice       -> ids _0, _1, _2
GET  /api/agent/launch-film                         -> latest=v1, open=[note,draw,voice]
POST .../version            v2.mp4 by the agent     -> {'ok': True, 'version': 'v2'}
POST /api/feedback/<fid>/resolve                    x3 all {'ok': true}
GET  /api/agent/launch-film                         -> latest=v2, unresolved=0
```

The clips were ffmpeg color overlays: v1 a blue box crossing left to right over 6 seconds (10,765 bytes), v2 a warmer box held 1.5 seconds longer at 7.5 seconds (12,467 bytes). The board rendered in headless Chrome screenshots at :8791, marker click seeks video via `currentTime = t_ms / 1000`, compare mode runs both cuts side by side off one slider at `?p=<proj>&a=v1&b=v2`.

Media reads fully into memory through `f.read_bytes()`, so a cut bigger than free RAM is out of scope for this shape. The resolve step is manual and an agent that forgets it leaves notes open forever, the filter keeps handing them back. Nothing enforces that `CHANGES.md` describes what really changed, nobody signs that string, so a sloppy agent can claim work it didn't do. Durations cost up to 8 seconds per upload when `dur_ms` isn't supplied, fine at two versions and annoying at two hundred. Teams, permissions and share links are gone, meaning this serves one person on one machine and stops there.

The bar that makes it worth keeping is the loop closing without human intervention: three feedback kinds pinned at timecodes, one GET read by the model, one version posted, three resolves, unresolved count back to zero.

Would you wire an agent through a plain JSON route like this, or through a framework's client library? My server fits in about 110 lines, I shipped it anyway, and I'd like to know whether anyone gets the same result with less code underneath.
