Christian TorresFull stack developer
← All work

Butter Studio · Full Stack Developer · 2024 to now

Plot

AI video editing for storytellers: a web app and a native DaVinci Resolve plugin that works beside Claude over MCP. The architecture, the decisions behind it, the measurements, and how Claude runs in the product.

  • Claude API
  • MCP
  • OpenAI API
  • Next.js
  • TypeScript
  • React
  • Python
  • Electron
  • Postgres
  • Stripe
  1. Claude Desktopeditor's own subscription

    MCP only. No chat in the panel and no API key in Plot. Anthropic doesn't allow a third-party product to offer claude.ai login, so the key and subscription stay with the editor.

  2. Resolve MCPdoes the cutting

    Its main tool is run_script: untyped Python strings, slower edits, no access to our engine or its tests. Claude uses it to cut. The panel doesn't.

  3. Plot MCPwrites the story back

    Hand-written stdlib JSON-RPC on Python 3.9, about 150 lines at first, so no SDK and no pip on the editor's machine. Claude writes sections, sub-categories and the score back, and works through the editor's tasks.

  4. DaVinci Resolvethe real timeline

    No move call in 21.1, and GetSourceStartFrame reads a frame low at 23.976, so source frames come from GetLeftOffset at the exact NTSC rate.

  5. Python enginepy3.9 · stdlib · typed JSON

    Reads the cut back and maps every clip to the transcript. A reorder is DeleteClips plus one AppendToTimeline: 73 ms for 37 clips, two undo entries. It refuses what it can't move safely.

  6. Story sidecarone JSON per timeline

    Keyed on clip numbers with a fingerprint of the cut, so a changed cut marks the story stale. The story follows the footage, not the position, so labels survive trims and reorders.

  7. React panelone committed renderer.js

    Strict CSP, so no inline styles. dnd-kit and Radix inject style tags, so their rules live in a stylesheet with a test that fails on upgrade, and a CSP gate scans the built bundle.

Sheet
1 of 1 · Plot
Client
Butter Studio
Role · period
Full Stack Developer · 2024 to now
Stack
Claude API · MCP · OpenAI API · Next.js
Status
Active

The product

Plot finds the story in interview footage and builds the cut. Hours of people talking go in, a story comes out. It ships as plot.video, a web app that edits in the browser, and Plot for DaVinci Resolve, a native plugin that sits next to Claude.

This page is the engineering view: the decisions, why I made them, what broke, and where Claude fits in. If you want the pitch, it's the paragraph above. If you want to know why I deleted a chat box, keep going.

How it fits together

Claude does the judgment and the cutting. Plot does the part Claude shouldn't be trusted with: keeping the story attached to the footage, checking every edit, and showing the editor what happened. The editor works with Claude in Claude Desktop, and the Plot panel inside Resolve shows the result.

Decisions I'd defend in a review

1. Claude cuts from the words, and Plot checks the cut. A video editor that invents quotes is a lawsuit with a timeline. So Claude works from the real transcript, the words the speaker actually said, and makes the cut in Resolve. Plot then reads the finished timeline back, traces every clip to the words it plays, and refuses any story label that doesn't land on a real clip. The general rule: let the model do the creative work, make the output checkable, then check it.

2. MCP only. No chat inside the panel, no API key. The first Resolve plugin had its own chat agent and asked editors for an API key. I turned both off. Anthropic doesn't let a third-party product offer claude.ai login, including through the Agent SDK, so "chat in Plot on your subscription" isn't available. The sanctioned route is MCP into Claude Desktop. The cost is that the chat lives in another window, and I wrote that trade-off down plainly instead of hoping nobody noticed. The panel is now a visualiser with a task list, not a chatbot.

3. The panel talks to Resolve through the engine, not through Resolve's MCP. Resolve 21.1 ships an MCP server, and it's tempting to point the React panel at it. I wrote down why not: its main tool is run_script, which means untyped Python strings, slower edits, and no access to our engine or its tests. Claude uses Resolve's MCP to cut. The panel uses a typed JSON protocol over stdin and stdout, validated with zod on the panel side. Two clients, two jobs.

4. Stay on Python 3.9, hand-write the MCP server. The Mac ships Python 3.9, Resolve's scripting uses it, and the official MCP SDK wants 3.10 or newer. A stdlib JSON-RPC server was enough, so the answer to "which SDK" was "none", and installs need no pip and no network. It's the software equivalent of cooking dinner in a hotel bathroom, but the kitchen is the Mac we have.

5. Rewrite as v2 in a new repo, freeze v1. The v1 panel was 3,000 lines of plain JavaScript. v2 is a new monorepo on the web app's stack, so I only have to be clever in one place. v1 stays frozen as the reference and keeps shipping until v2 replaces it. They install side by side, and only the engine modules that are still used (with their tests) were carried over.

6. The AI critic is opt-in, because I measured it. I built a critic pass that rewrites weak sections. The editor's verdict was that its extra minute removed more good lines than it added, so it's offered and never run unasked. That's a feature I built and then demoted on evidence.

Technical design

Constraints chose the stack

Resolve loads the panel in a sandboxed Electron with a strict CSP: default-src 'none'; style-src 'self'; script-src 'self'. No inline styles, no innerHTML, no ES modules from a file:// page. So every dependency was checked against the CSP before it was picked, and a few were rejected:

  • @dnd-kit injects a <style> tag. I copied its rules into a stylesheet, with a test that fails when an upgrade changes them.
  • Radix Dialog's scroll lock injects styles too, so the dialog is our own overlay.
  • react-hot-toast injects styles. Toasts are our own.
  • Vite builds one IIFE renderer.js that is committed, because the Mac has no Node.

Enforcement is mechanical, not a note in a README. ESLint bans innerHTML, a csp-gate script scans the built bundle, and the Playwright suite runs against a mock-bridge harness served with the real CSP, so any violation fails the test.

Moving a clip when Resolve has no move call

Dragging a section on the story arc has to reorder the real timeline, and Resolve 21.1 has no API for moving a clip. So a reorder is a DeleteClips plus one AppendToTimeline at each piece's own source frames: 73 ms for 37 clips, frame exact, two undo entries. Two details cost me real time:

  • A sound clip's linked picture moves with it only if the append carries no mediaType, which re-links the pair.
  • GetSourceStartFrame reads a frame low at 23.976, so source frames come from GetLeftOffset at the exact NTSC rate.

Anything the engine can't move safely (b-roll or music over the cut, a picture on another track, split edits) is refused with a reason instead of being moved wrongly.

The story follows the footage

Claude writes sections and labels back through Plot's MCP. Those are stored in a sidecar keyed on clip numbers in play order, with a fingerprint of the cut so Plot knows when the story has gone stale. The store also records what each clip shows, so after a trim, split, removal or reorder the story is re-labelled by footage and not by position. Tasks use the same idea: keyed on media id and source range, so a task stays attached to the right moment after the cut changes underneath it.

Undo that doesn't trust the host

Resolve has no undo grouping, so the panel keeps its own history: 60 snapshots of the cut before and after each step. set_cut(list, expect_hash) diff-applies a stored cut and rebuilds only what differs, and the hash guards against applying an undo to a timeline that changed under us. The acceptance test for word editing is blunt: every operation followed by undo must come back to the starting cut hash.

Checking the cut

A cut is only trusted once it has been read back. Plot reads the timeline, traces every clip to the transcript, and compares. In the first end-to-end spike, Claude Desktop built a word-level cut of 42 clips, and Plot's reader matched Claude's own account of it to the frame, with no unknown clips. Story labels work the same way: a write-back that doesn't land on a real clip of the current cut is rejected, so the story can't describe footage that isn't there.

Measured, not vibed

  • Spike before architecture: the riskiest assumption in the pivot was that one Claude conversation could drive Resolve's MCP and a Plot MCP together. I tested it first. It worked in 16 turns and about two minutes, and the result changed the plan: the transcript tool had to be paged because a long interview is over the client's per-tool output cap, and Plot never needs to expose word timings because Claude gets them from Resolve.
  • Claude alone against Claude with Plot: same 81 minute interview, nearly the same prompt, one run each. Plain Claude plus Resolve produced a 12.9 minute cut where about ten clips opened on filler or the interviewer. With Plot it was 6.4 minutes with about one, and trims inside sentences went from 2 to 20, meaning cuts to the line that matters. A repeated story point in the first run was gone in the second.
  • Story score: the panel scores a cut on six checks. A test edit went from 50 to 83 with Plot in the loop.
  • What is still unsolved: run-to-run variance. Haiku kept 58, 179 and 213 lines across three runs of the same prompt on the same transcript, and it got worse under measurement instead of better. I'd rather say that here than let you find it.

plot.video, the web app

Before there was a plugin there was StoryCut, now plot.video: the browser version of the same idea. I led it from an empty folder in 2024, and it's still the product most editors meet first.

How it works

Footage is uploaded and transcribed, then an AI pass sorts the transcript into a story. The story model is a tree: story elements (hook, character, problem, guide, action, result, conclusion), sub-categories inside them, and segments, which are clips with a start and an end. The editor reorders that tree, trims to the word, and exports FCPXML for Final Cut Pro or Premiere. Editing happens in the browser on a composition engine we run as our own fork, so there's no server render farm to pay for.

The story pass on the web started on OpenAI, with function calls for the structure and embeddings for finding segments. The team has since added Claude through the editor's own API key, checked before every story run so a dead key fails before the pipeline starts and not halfway through it.

Design notes

  • Drag that can be cancelled. Dragging a segment across story elements updates only a visual map during the drag. Nothing is persisted until the drop, so cancelling a drag can't leave half a move in the database. The v2 Resolve panel ports this board and the same store pattern.
  • Guests first. I built the guest flow: upload footage and build a story without an account, with a request allowance enforced by a server-side IP rate limit, not a counter in the browser. On sign-up the guest's story tree and transcript are relinked to the new account so nothing is rebuilt, and an end-to-end test walks the whole chain from the try page to sign-up to the restored story.
  • Pricing in the data model. Plans are gated on exports, and re-exporting a project you already paid to export is always free. AI usage has a per-plan daily turn cap, checked and consumed in one server call.
  • One backend for two products. The web app is also the plugin's licence server: activation with an emailed code, a licence cache with an offline grace period, a machines page, and checkout. A feature flag keeps the plugin API off in production until launch.

Fixing the cause, not the symptom

The in-browser composition lives in memory, so a page refresh lost the edit and asked the editor to re-pick every file. The quick fix was running Stripe checkout in a new tab so the editor tab never unloads. Then I wrote up the real fix, and the finding changed the plan: the editing library can already rebuild a composition from a URL, so we don't need to replace it. The real blocker is that we don't own durable, browser-fetchable media URLs. So the recommendation is to own the media first. Swapping the library would have been weeks spent on the wrong problem.

The outage that returned 200

One August morning the DNS provider switched on its CDN for the whole account, opt-out instead of opt-in. That quietly pointed the domain at an old marketing site. Real traffic to the app was zero for 13 days, and nobody noticed, because the site kept returning HTTP 200 and certificate renewal was failing silently every night in the background.

Nothing on the server could have caught it, because the server was fine. So the monitor runs off the box, as an hourly GitHub Action with seven checks across production and staging. It checks that the domain resolves to our server and only our server, asking public resolvers and not the runner's. It checks the certificate has more than 21 days left, and that the pages serve our app and not some edge's idea of it: no foreign CDN headers, no marketing-site markers, and the app's own static assets in the body. Hourly fits the free Actions budget next to the deploy and PR workflows. Every 15 minutes wouldn't.

Shipping

Production runs on a droplet and staging deploys to Vercel. The team moved from a three-stage promotion flow to test plus main, with feature flags separating merge from release. A feature merges to main once review passes, off by default. The client's approval gates flipping the flag, not the merge. Reviews are AI for the heavy pass and a human for the requirement check, and tickets stay small enough to go from todo to done in about a week, so branches stop drifting and piling up conflicts.

How Claude runs in the product today

Claude is used in two places, and they don't share a key or a path.

In the web app (plot.video). Editors connect their own Claude API key. It's encrypted at rest with AES-256-GCM. The editor's AI actions, from chat to transcript tools, check the key first, so a bad key fails with a clear message instead of halfway through. Plans carry a daily cap on AI turns, enforced on the server in the AI proxy, which checks and consumes the turn in one call.

In the Resolve plugin, through MCP. The plugin never calls Claude and never asks for a key. The editor talks to Claude in Claude Desktop, which connects to two servers:

  • Resolve's MCP does the cutting. Claude edits the timeline with it.
  • Plot's MCP holds the story. Claude writes sections, sub-categories and the score back through it, and reads the open tasks Plot has collected from the editor. When a task is done, Claude marks it complete.

Plot reads the cut back after every edit and maps each clip to the transcript, so a cut Claude describes is checked against what is actually on the timeline. The panel shows the result: the arc, the score, the flags, and the task list. It has no chat box.

The reason for the split is policy as much as design. Anthropic doesn't allow a third-party product to offer claude.ai login or rate limits, so a product-owned chat would have needed an API key from every editor. MCP into the editor's own Claude Desktop keeps that key and subscription with the editor, and Plot only has to be a good tool for the model to use.

Where it is now

v2 is being built milestone by milestone. The foundation (panel opens in Resolve on the new stack, four risky spikes answered yes or no, CI and the CSP gate) and the live read-only Story tab are done. The story arc, with drag to reorder the real timeline, is in progress. After that: transcript drags and labels, word editing with undo and redo, the Claude task loop with flags and score, packaging and the switch from v1, then the full transcript, media pool and story health. v1 keeps shipping until then.