Contents

M15: Reelwright Runner, the heavy work on your own machine

Status: all six phases built, 2026-10-06 (see "What was built" and "Proof"). Builds on the service (m12-web-service.md), the plan bridge (m12-plan-bridge.md), device sign-in and show uploads (m13-shared-shows.md, "The installed Studio") and the render pipeline (m1-compiler.md, "Render orchestration").

The question

The owner, in his words: "can you build it so a user can configure it to run off a bridge running on their own box/machine so that for instance we could properly run whatever on the dev box HERE (this one)?! And or for that should we just run the installable version?"

Today the web Studio at reelwright.diy/app is a Godot WebAssembly page. Everything heavy runs in one Fly machine (performance-2x: two vCPUs, 4 GB, no GPU), where a daemon per show renders with headless Godot on Mesa's lavapipe and encodes with ffmpeg. The dev box has 24 cores, 125 GB and an RX 6700 XT. A 28-second preview of the Lighthouse cold open there takes 45 s to draw in four parallel Godot jobs on the GPU; the service draws it in one job on two CPUs in software. The person should keep the web Studio, from any browser anywhere, and have that work done on their own machine.

The answer, in one paragraph

A runner: reelwright runner, a headless process of the same core that a person runs on a machine of theirs (a terminal, a systemd unit, or one switch in the installed Studio). It signs in once with a pairing code, then keeps one WebSocket open to the service, outward: no port opened, no tunnel, no account anywhere else. It says what the machine can do (cores, memory, GPU, Godot, ffmpeg, voices, Claude Code on a plan), and the service routes that person's work to it. Two kinds of work: jobs for shows held on the service (renders, voicing, chat on the plan), whose files go to the runner and whose results come back, both ways as content-addressed chunks that resume; and shows that live on the runner's own disk, opened from the web Studio, the session relayed through the service to a daemon on the runner, so the files never rest anywhere else. The person picks where work runs (Reelwright, "my computer when it is on", or "always my computer"), per show and per job; when the runner is off, work falls back to the service or waits for it. The plan bridge becomes one capability of the runner, and old bridges keep working.

So: not "just run the installable version". The installed Studio is a runner too (the switch), but a runner is what makes the dev box useful from a browser elsewhere.

What exists

  • A render on the service. Studio's renders.start goes through the host's pipe (server.ts #pipe), where quotas may refuse it, to the show's daemon (daemons.ts), whose Renders (studio/renders.ts) runs renderEpisode (render/pipeline.ts): voice missing lines (Kokoro), compile, write RenderJobs, run Godot (render/engine.ts GodotEngine), verify every frame, mix, encode with libx264 (render/encode.ts, eight threads, pinned for bit-exact output), and write renders/<slug>/ with render.json. render.event notifications say each stage and the frames drawn; the host hears them on a socket of its own (jobs.ts RenderWatch) for the console, the quotas and the "render finished" notification. The pipeline already splits a render into chunks (--chunks N) and can farm them out (--farm dir), but only to workers that share a folder.
  • The plan bridge (claude/bridge.ts, host/bridge.ts): the prototype of this. A process on the person's computer signs in with a personal token, keeps a WebSocket open outward to <base>/bridge, says what Claude Code there is, and takes the chat turns of that person; the host's pipe answers chat.send itself and streams the turn's events back. The installed Studio runs it with one switch (RwPlanBridge, RwAccount).
  • Device tokens (host/devices.ts): how the installed Studio signs in: 32 random bytes, hashed, named, revocable, 180 days unused.
  • Uploads (host/upload.ts): a show put on the service as one checksummed zip; no incremental sync exists.

The options, compared

Web Studio from anywhere Nothing to open or configure Shows stay on the machine Service shows use the machine Sharing, MCP, accounts as today
Installed Studio only no yes yes no yes (signed in)
Self-host the whole service on the box only with a tunnel no yes n/a a second, separate service
Browser straight to localhost no (same machine only) mostly yes no yes
A tunnel (Tailscale, Cloudflare) yes no yes no partly
The outward runner yes yes yes (mode b) yes (mode a) yes

The installed Studio only. It already renders on the machine it runs on, with its GPU, and the installed Studio signed in to Reelwright opens service shows. But it is a desktop app: it needs the machine's screen and keyboard. From a laptop at a café, the dev box at home does nothing for you, and a show on the service still renders in Fly's two CPUs whoever opens it. Remote-desktop to the box (Parsec, below) works, at the cost of streaming a whole desktop to use one app. The installed Studio is the right home for the runner's switch, not a substitute for it.

Self-host the whole service. reelwright host runs anywhere: on the dev box it would render on the GPU. But the person then runs a second Reelwright with its own accounts, its own copy of every show and no reelwright.diy: sharing with someone on reelwright.diy, the MCP endpoint Claude apps signed in to, the emailed codes, all of it is a separate world, and from outside the house it still needs a public address or a tunnel, a TLS certificate and an operator's care (backups, updates, the mail sender). Right for a studio that wants its own service; wrong for "use my box for the heavy parts".

The browser straight to localhost. The web Studio could open ws://127.0.0.1:<port> to a daemon on the same machine. What browsers do with that from an https page, checked 2026-10-05:

  • Mixed content. Secure Contexts counts 127.0.0.0/8, ::1 and a localhost that always resolves to loopback as potentially trustworthy, so Chrome (since 2016) and Firefox (since 84, and ws:// since 2019) do not block it. Safari does: WebKit bugs 171934 and 218980 are still open, and in April 2026 bug 281149 was reopened because its mixed-content checker still treats http://localhost as insecure. A Safari user simply cannot do this.
  • Local Network Access. Chrome replaced the Private Network Access preflights with a permission the person grants: since Chrome 142 a public page's fetch to loopback asks ("Look for and connect to any device on your local network"), split into loopback-network in 145, and WebSockets are covered since 147 (April 2026). Firefox asks too from 153 (July 2026, "Device apps and services" for loopback), WebSockets from 154. So every person would meet a permission prompt that reads like a warning, and an iframe would need allow="loopback-network" as well.
  • Files. Under the page's cross-origin isolation, every image or sound loaded from the daemon needs CORS, and CORP for a no-cors load.

The decisive problem is simpler: it works only when the browser is on the machine itself, which is the installed Studio's case, already served better by the installed Studio. From a café, the dev box at home is not on "localhost".

A tunnel. Each makes a daemon on the box reachable from anywhere, at a price:

  • Tailscale Serve shares a port only with devices on the person's tailnet: the laptop at the café must run Tailscale and be signed in to it, since MagicDNS names resolve nowhere else. Funnel publishes the port to the internet at https://<machine>.<tailnet>.ts.net (ports 443, 8443 and 10000, with a policy attribute, MagicDNS and HTTPS certificates turned on, whose names then sit in the public Certificate Transparency logs).
  • Cloudflare Tunnel: cloudflared connects outward (QUIC on 7844, or HTTP/2), and a public hostname points at it; that needs a domain whose DNS is on Cloudflare, and Access in front of it for a sign-in, which the origin must then check. Quick Tunnels need no account but change their name each run and are for development only.

Either way it is a third-party account, an install and configuration on the box (and often on every browsing device), and then an endpoint on the box that answers whoever reaches it, with an authentication Reelwright's daemon (a token, on loopback) was never designed to be exposed with. And a tunnel serves only shows on the box: a show on the service, shared with others, still renders in Fly. The runner's own socket is the tunnel, outward, signed in to the service the person already uses.

The outward runner. One process, one outward socket, signed in to the account the person already has. It serves both cases: jobs for shows on the service (mode a) and the person's own shows on the box (mode b). Its costs, faced rather than hidden:

  • Bandwidth back. A render's result has to come back to the service for a service-held show: the Lighthouse preview's MP4 is 110 MB (CRF 16, slow), the mix and stems 40 MB more. On a home uplink of 20 Mbit/s that is about a minute, which can eat what the GPU saved. Content addressing means unchanged files (stems, captions, the mix, takes) never go up twice, and mode b avoids the transfer entirely: a show on the box keeps its renders there, and the browser streams the video through the relay only when it is watched.
  • Two machines, two versions. The runner may be older or newer than the service. The protocol has its own version, jobs say what they need, and a render on the runner records its engine and version in render.json as any render does.
  • Trust in both directions. The service hands work to a machine it does not control, and the machine runs work handed to it by a service. The threat model below takes both seriously.
  • A relay in the path for mode b: every frame of a session crosses the service. Fine for Studio's JSON-RPC traffic; a video is streamed with byte ranges, never stored.

Prior art, and what we take from it

  • GitHub Actions self-hosted runners. The runner registers with a registration token that lasts an hour (or a single-use just-in-time config), makes an RSA key pair whose public half GitHub keeps, and from then on long-polls GitHub over HTTPS, outward only on 443; each job carries its own token, scoped to it and good for its timeout plus ten minutes, scrubbed from logs. Jobs go to runners whose labels all match (self-hosted, the OS, the architecture, your own), within runner groups. GitHub says self-hosted runners "should almost never be used for public repositories", since they "can be persistently compromised by untrusted code". Taken: register once with a short-lived code; outward only; a credential of the runner's own; a credential per job, scoped to it (our chat's MCP credential per show); capabilities for routing. Not taken: arbitrary code. A Reelwright job is data, the show's documents, in a closed set of kinds; the runner never runs a command the service sends.
  • Blender Flamenco (v3). A Manager with an OpenAPI interface and Workers on a LAN, found by UPnP; Workers poll for tasks (a job's frames split into chunks, "Chunk Size" frames each) and authenticate with a worker id and a secret; it "won't offer much in terms of user authentication". Every machine needs the shared storage the files live on (Syncthing or Dropbox are not supported), or Shaman: files keyed by SHA-256 and size, only new ones uploaded, each job checked out as a tree of symlinks into the file store, unreferenced files collected after 31 days. Worker tags route jobs. Taken: content-addressed upload of only what is missing, a checkout made from the store, frames in chunks. Not taken: shared storage (a person's box and Fly share nothing), a LAN, symlinks (a checkout here is plain files, so nothing in it points out of it), and its trust model.
  • Unreal Pixel Streaming. The engine renders on a GPU machine and streams every frame and its sound to the browser over WebRTC (H.264, VP8, VP9, AV1, hardware-encoded), with a signalling server and STUN/TURN; input goes back. The page is only a video, so nothing runs on the viewer's device. Not taken: Studio already runs in the browser; only its heavy jobs move. Kept in mind for a device too weak to run the web export.
  • Parsec. A host app and a client, each connected outward to Parsec's signalling over TCP 443, then a peer-to-peer stream made by UPnP or UDP hole punching through Parsec's STUN; a relay is an enterprise product. Taken: both ends dial out to a service they already trust. Not taken: a whole desktop on the wire, and peer-to-peer that fails behind carrier NAT.
  • Coder. A workspace's agent dials the Coder server outward; the dashboard, its terminal and workspace apps are relayed by the server (Tailscale's DERP over WireGuard, direct when STUN allows), and "direct connections via the web browser are not supported". Apps on a subdomain are kept apart from the server's own origin because a path app "is a security risk": its JavaScript would run with the user's session. Taken for mode b: the agent dials out and the server relays a session to a local port. And the warning: a file relayed from the runner is served on the service's origin, so it goes out only as a type Studio loads (images, sound, video, captions, JSON), never HTML or script, its type the service's own by the name asked for, with nosniff, and text sandboxed by CSP.
  • VS Code Remote Tunnels. code tunnel signs in with GitHub or Microsoft, connects outward to Microsoft's dev tunnels relay and opens no listener; vscode.dev reaches the machine through it, both ends signed in to the same account, the session SSH over a WebSocket, end-to-end encrypted (AES-256-CTR first), with relay tokens scoped and good for 24 hours. Taken for mode b: exactly this shape, signed in with the account the person already has. Not yet: end-to-end encryption of the relayed session (see the threat model).

The design

The runner

browser (web Studio) ──wss──▶ reelwright host ◀──wss── reelwright runner          (the person's
   renders.start, chat.send   │  (runners.ts)          │   (runner/*.ts)           own machine)
   render.event, chat.event   │                         ├─ render: renderEpisode, GodotEngine,
                              │  ◀──https── blobs ──────┤   N chunks at once, ffmpeg
                              │  (content-addressed     ├─ voice: Kokoro here
                              │   chunks, both ways)    ├─ chat: claude -p on the plan
                              │                         └─ shows: reelwright serve here,
   /ws?ticket (a show on the  │  ══ relay ═════════════▶    relayed (mode b)
   runner)                    │
  • Run it with reelwright runner --service https://reelwright.diy/app. The first run pairs: --pair ABCD-EFGH, a code the person makes in Studio (Your computers, Add a computer), good for ten minutes, once. The runner keeps what pairing gave it, its own token (rwm_…, hashed on the service) and the service's signing key, in ~/.config/reelwright/runner.json, mode

    1. After that it starts with nothing but --service, so a systemd user unit is one line:
    [Service]
    ExecStart=/usr/bin/reelwright runner --service https://reelwright.diy/app
    Restart=on-failure
    NoNewPrivileges=yes
    PrivateTmp=yes
    ProtectSystem=strict
    ReadWritePaths=%h/.cache/reelwright %h/.config/reelwright
    

    (With --shows, add those folders to ReadWritePaths: the runner's daemons write there.)

  • In the installed Studio it is one switch, "Let this computer run my Reelwright work", in Your computers. Studio, signed in, asks for the runner's token through its own device session (POST /api/runners, nothing to paste), keeps it with the account (0600, as the bridge's), and runs reelwright runner --token-stdin --json --name "Studio on Linux" while it is open, the token on its input, saying each state as a JSON line, as RwPlanBridge does for the bridge.

  • It signs in like a device, but is not one. A runner token opens the runner's socket and its blob routes, nothing else: not the API, not the MCP endpoint, not the console. A device token would let a box in a closet act as the person in everything. Revoking a runner (in Studio, or in the console) closes its socket at once and refuses the token after.

  • It says what the machine can do, on connecting: the OS, CPU cores, memory, the GPU Godot renders with (the engine's own version: its adapter and renderer), the Godot and ffmpeg versions, the voices it can make (Kokoro), Claude Code (installed, signed in, on a plan: claude auth status, never the account), what it accepts (render, voice, chat, shows) and how many of each at once. Studio shows it; the service routes by it.

  • Work goes to a folder of its own, ~/.cache/reelwright/runner (--work): the chunk store, a checkout per show, and nothing outside it.

Where work runs

  • The person's choice, in Your computers: "Run my heavy work on" Reelwright (the default), my computer when it is on (else Reelwright), or always my computer (it waits for it). Per show, the same three or "as I chose for all shows". Per job, renders.start and episode.voice take on: "service" | "runner", which wins over both.
  • Which runner. The person's runners that accept the kind of work and are online; with several, the one with a free slot, else the shortest queue. A runner never takes anybody else's work.
  • A shared show. Bob, an editor of Ada's show, may send its work to his own runner only when Ada has said so for that show ("People in this show may render it on their own computers"): its files go to his machine. Without it, his work runs on Reelwright and Studio says why.
  • Fallback and queueing. With "when it is on" and no runner online, the job runs on the service as before, and its progress says so ("Wil's box is offline, so Reelwright renders this"). With "always", it queues for the runner and says it waits; Cancel works while it waits. A runner takes one render at a time by default (--renders N); the rest queue in order. A runner that drops mid-job has two minutes to come back and carry on; after that the job fails saying so (with "when it is on", the next one falls back).
  • Quotas. A job on the person's own machine costs the service nothing to run: it does not count against their renders at once or render minutes. Its results land in the show's owner's storage, so the storage quota still holds.

Moving files: content-addressed chunks

  • Down. A job carries the show's manifest: every file the work reads (the show's documents, assets, audio and the take store, .reelwright/takes/; never .git, renders/ or the other caches), each with its size, its SHA-256 and, past 4 MiB, the SHA-256 of each 4 MiB chunk. The service keeps each file's hashes by its size and time, so a manifest of a show that did not change costs no reading. The runner asks only for the chunks its store lacks (GET <files>/<sha256>, four at a time), checks each against its hash, and lays the show out in its checkout from the store, removing what the manifest no longer has. A second render of the same show moves only what changed; a dropped connection loses at most the chunk in flight.
  • Up. Done, the runner offers its results: the render's folder (the MP4, captions, mix, stems, render.json) and any takes it made. The service answers with the chunks it lacks; the runner sends those (PUT, each checked on arrival); then it commits, and the service assembles the files into the show's folder, render.json last, with the runner's paths in it made the service's. Chunks already received survive a dropped connection, so an upload resumes. A render whose audio did not change sends its stems, mix and captions zero times.
  • Scoped. A chunk is served only to the runner the job was given, only while it holds the job, and only if it is in that job's manifest; an upload is taken only for a job of that runner, only for paths the job may write (its render's own folder, the take store), and only if every byte matches its hash. A show that changes while it is sent fails the chunk's check; the job is planned again from the new manifest, once.

The jobs

  • Render (phase 2): the whole pipeline on the runner, voicing included, at the quality asked, in chunks Godot jobs at once (a quarter of its cores, at most eight, by default; one GPU serves them all). Its stages and frames come back as the service's own render.event, with runner naming the machine and stage: "queued" while it waits, to every Studio in the show (the asker sees the machine's name; others see "Ada's computer"). Cancel reaches the runner, which stops Godot. Done, the render is in renders/<slug>/ on the service, listed by renders.list, played and downloaded as any render, and its maker is told it finished.
  • Voicing (phase 2): episode.voice on the runner, with its Kokoro; the takes come back to the show's take store.
  • Chat on the plan: the plan bridge's turns (below).
  • Previews (phase 4): model sheets, line-ups, set views, style swatches, stills, storyboards, prop and effect sheets of a show on the service, drawn on the person's runner. The show's daemon still decides everything a preview is (which documents, the proposal it draws, its cache key, the restyle sheet it composes, the canon a model sheet becomes when its proposal is approved); only drawing moves. For each preview it would draw for someone, and has not drawn already, it asks the service (below, "A daemon asks the service"), which decides as for a render. Previews are many and small, so they go in batches: the previews of one show asked while the runner's preview slot is busy, or within 25 ms of each other, go as one job, one sync of the show's files; the same preview asked twice is drawn once. The runner keeps each show's checkout between jobs (and a second one for the proposal last previewed, laid out again from its chunk store as each proposal differs from the last), so a show it has seen moves only what changed, and its own preview cache answers a preview it has drawn before at once. It draws as many at once as its render's Godot jobs, and sends back each preview's cache entry, under the name the service's own cache gives it (into), so the daemon finds it there next time without asking whatever the runner's engine is called. The files land in the show's preview folder (or the proposal's), and the daemon says where it was drawn in its preview.event: runner (to others in the show, "Ada's computer"), or fallback, why the service drew it. A preview the runner could not draw for a reason of the show's (a document that does not validate, an engine that wrote no image) comes back as that preview's error; a runner that could not draw it at all (it stopped, it went away for 10 s, it sent back something else) leaves it to the service, saying why, with "when it is on", and fails it with "always". A sound's picture (ffmpeg alone) and the restyle sheet's composing stay on the service.
  • The director's and memory's runs on the plan (phase 5): a direction with the model (direct_episode without heuristic) or a memory build's model pass, for a person whose runner has Claude Code signed in with a plan and whose Studio chat is on their plan. Claude Code is the client, as everywhere Reelwright meets a plan (below, "Runs on the plan").
  • A farm (phase 6): a render given to one of the person's runners while others of theirs are online is spread over them (below, "A farm of one's own machines").

A farm of one's own machines

  • Who leads. Of the person's online runners that take the work, the least busy, and of those the one that draws the most at once (below), then the one seen last: a render never goes to the old laptop because it connected last while the tower is idle.
  • Who helps. When a render is handed to a runner (the lead) and other runners of the same person are online that take renders, say in their hello that they draw parts (parts) and have Godot, each is a helper, unless it draws in software (llvmpipe, lavapipe, SwiftShader) and the lead on a GPU, or it would send a frame back slower than the lead draws one (below, "Measured, then chosen"). Nobody else's runner ever is; a shared show's render reaches them only as its owner's consent already allowed the lead.
  • Measured, then chosen. The service measures what it can (host/runner-measures.ts): each runner's uplink, from the chunks it uploads (bytes over the time from its first chunk's start to its last's end, a running mean); how long a frame takes in one of each runner's Godot jobs, from the render.json of its renders that were not farms; and how many bytes a part's frame comes back as, encoded, by quality. A helper whose measured uplink would take longer to send a frame back than its lead takes to draw one is left out, and the service's log says why; until all three are measured, a helper is tried, and so measured. Your computers says each runner's uplink ("sends 20.5 Mbit/s").
  • The split, by what each machine can do. Each machine counts for as many chunks as it draws at once: on a real GPU what its hello says it draws a render in (chunks, its --chunks; else a quarter of its cores, at most eight), in software one. The render is planned in the sum (at most 32) and the lead draws its own share; the job tells it so (farm: {chunks, here}). The chunks are all as long, so the render ends when the slowest does: one chunk in software takes several times as long as a GPU takes for all of its own (measured below), which is why a machine in software helps only another in software.
  • The lead voices and compiles as for any render, so every chunk draws the same Timeline with the same takes; plans the chunks as the pipeline's farm does (one engine job each, runner/farm.ts FarmEngine), draws its share, and sends each other chunk away: the chunk's folder (its job.json, the Timeline and the documents in it, and the files it names) by content to the service (farm.part, then the chunks asked of it, then farm.sent).
  • A helper gets a part as a signed job of its own, kind frames, whose files are that folder (the service sends what the show already holds from the show, the rest from what the lead sent): no show to sync, nothing to compile. It runs that one engine job with its own output folder (drawPart), checks its frames as the pipeline does (every frame's hash, the range, the job), encodes them as the chunk's segment (render/encode.ts encodeSegment: the whole render's picture settings, video only, MPEG-TS) and sends back the segment and the engine's result, which names the lead's job and lists every frame; its folder is removed after. Helpers take parts as they have room, the best first (a real GPU, then the most room).
  • Back to the lead. The service keeps a helper's files for the lead (farm.ready), which fetches them by content into the chunk's own folder (placePart). The lead renders a farm in segments (renderEpisode's segments): it encodes each of its own chunks the same way, checks every segment (codec, BT.709 limited range, size, the chunk's exact frame count, the rate) and the result beside a helper's (the job, every frame listed once), then joins the segments with the concat demuxer, the picture copied and the mix muxed in, and offers the render as any render. A part of 75 frames at 1280x720 came back as 97 MB of PNGs; as a segment it is a few megabytes, and the encode is spread with the draw. A helper that stops, fails or is away 10 s gives its part to another helper, once, else back to the lead (farm.back), which draws it itself. The lead ending (done, failed, cancelled) cancels the parts still drawn.
  • A late part. Once the lead has drawn its own chunks, a part still away that has been away LATE_MS (30 s) longer than the lead took for as many frames is drawn by the lead too, as many at once as it draws its own. The first to finish is kept: the lead's draw stops if the helper's segment comes first; if the lead's comes first it drops the part (farm.drop) and the service stops its helper. So a helper slower than it says (a GPU busy with other work) holds a render up by at most that margin.
  • In the render's profile. A part is encoded as its render is: a preview's segments in the preview profile (CRF 28, veryfast, its bitrate capped by its size), a full render's as a master. The service tells each helper (frames params' profile), the lead encodes its own chunks and joins them the same way, and a preview from a farm is as small as one from a single machine.
  • Not what needs every frame. A full render also delivers what its show asks (format.deliverables). A ProRes master, PNG frames and OpenEXR frames are made from every frame (render/deliver.ts FROM_FRAMES), and a farm's helpers send their parts back encoded, so the service never spreads such a render: it is drawn on the lead alone, and its job says why ("Drawn on this computer alone: the show delivers png_frames, made from every frame, which other computers would send back encoded."). A render in segments asked for one anyway refuses before drawing anything. The MP4, the stems, the captions and the OTIO edit are made from the joined render and the mix, so a farm delivers those.
  • Seen. The render's progress names the lead and its helpers ("This box, with The second box"); each part is a job of its helper's in Your computers ("Drawing frames 112:168 of ep.lighthouse_cold_open"), and the installed Studio's switch line says "part of a render".

The plan bridge folds in

  • A runner that accepts chat takes the person's plan-chat turns exactly as a bridge does: the same turn, cancel and event messages, the same PlanChat over the service's MCP endpoint. The difference is the credential: a bridge gives Claude Code its own personal token (rwp_…, a year, every show); a runner gets one for each show it chats on, minted for that runner's connection (rwj_…), working at /mcp?show=<that show> only (choose_show refused), and gone when the connection closes. Nothing long-lived is written for Claude Code.
  • Old bridges keep working. /bridge, its personal tokens and its messages stay as they are; reelwright claude bridge still runs; an installed Studio of 0.2 still keeps its bridge. When a person has both, a runner online with chat takes the turns; else the bridge. GET /api/mcp's bridge says which is online, and Connect Claude names it.
  • In the installed Studio, the runner's switch includes chat on the plan when "Let this computer run my web chat on my Claude plan" is on: one process, not two.

Runs on the plan

What Anthropic allows (claude-plan.md) decides the shape: the service never holds a plan's sign-in, never makes a request of the model on one, and is never a proxy for one. So the director stays where the show is, on the service, and holds the work to account as ever (its prompts, its two stages per scene, the schema, the names, the assembly, the provisional compiles, film grammar, the repair rounds, the heuristic fallback, the proposal); and Claude Code on the person's own computer, unmodified and signed in through its own sign-in, does the model's part as a client of the show's tools.

  • Who takes it. When a direction or a memory build needs the model, the show's daemon asks the service (plan.ready) whether the person who asked has a runner for it: their Studio chat on their plan (Connect Claude), the show theirs to change (an owner or an editor), and a runner of theirs online whose hello says it takes plan runs and whose Claude Code is signed in with a plan. If so the run is theirs (plan.start); else their own API key answers, as before, and with none the heuristic director does, saying why for each scene.
  • The run's turn. The runner is sent {"type":"plan","turnId","run","kind","show","text", "mcp":{"token"}}: a turn of its own, in a Claude Code of its own (a folder under the runner's work folder, a fresh session), whose MCP server is the service's endpoint at /mcp?show=<the show>&run=<the run> with the runner's credential for that show (the same rwj_ the chat uses, minted for its connection and gone with it). Its prompt says what the run is; its only tools are plan_task and plan_submit (--allowedTools those two, every other tool of the show in --disallowedTools). Its events come back as the chat's do and become the run's lines on the runner's job, "direct on the plan" or "remember on the plan", in Your computers and the console.
  • Tasks. Each call the director would make of the model becomes a task: plan_task gives it, plan_submit answers it (the submit tool the task names, submit_performance, submit_coverage or submit_memory, and that tool's whole input) and gives the next one: what to fix in it, the next scene, or that the run is done. The first task carries the director's instructions (the brief and the show context, about 70,000 characters for the demo show, given whole) and each tool's input schema, once a run. The director reads the answer as the model's reply (a tool_use, no tokens, no cost): no budget holds a plan's calls back (LlmProvider.free), and the proposal says no cost.
  • Only that run's session. The MCP endpoint offers the two tools only to a session opened with ?run= by the credential of the runner the run was given, for that show; to any other it is a 403, and no other session lists them. In the daemon they are hidden tools of the show's registry (hidden: never listed, called by name), and they answer only the person the run is for: someone else in the show, knowing its id, gets plan.no_run.
  • Ends. When the direction or the build ends, the run does (LlmProvider.close): the call Claude Code is waiting on answers "The run is done", and its turn ends; one that does not end within two minutes is stopped. The runner going away, Claude Code failing, or a task unanswered for 15 minutes (3 for the first) fails the director's call: the director goes on with the heuristic for the scenes left, saying why, and proposes what it made.

A daemon asks the service

A show's daemon on the service asks the service that started it, and hears back, over the two pipes it already has (session/service-link.ts): a line of its stdout asks, {"notice": {"ask": {"id", "method", "params"}}}, after the line with its port; the answer comes on the pipe its grants come on, {"answer": {"id", "result" | "error"}}; an ask whose signal fires says {"notice": {"ask": {"id", "cancel": true}}}. The service answers each for that daemon's own show only, and checks what it is told (the person must be in the show now): runner.preview (the answer comes when the preview is drawn), plan.ready and plan.start (answered when the run's turn ends). A desktop's daemon has no link, and draws and directs itself.

Shows on the runner's own disk (mode b)

  • reelwright runner --shows ~/Shows (repeatable) offers the shows in those folders. Studio's show list on the web gains "On Wil's box" with each of them while that runner is online (and greyed, "offline", when not).
  • Opening one (POST /api/shows/rn_<runner>:<show>/open): the service asks the runner, which starts reelwright serve on that show, on its own loopback, as the desktop does. The browser's socket (a ticket, as for any show) is relayed frame for frame over the runner's socket to that daemon; the runner puts its daemon's token in the hello, which the browser never sees. The daemon is a desktop's: renders and previews run there on the GPU, its chat on the machine's own key or plan.
  • Files Studio loads (previews, sounds, the render's video) are asked of the runner through the same socket, from inside that show's folder only, with byte ranges so a video seeks; the service passes them on and keeps nothing. Nothing of the show is ever stored on the service.

Seeing it

  • Studio, both editions: Your computers (the palette, the account menu): each runner with online or when last seen, its machine, what it accepts, the job it runs with its progress and last lines, its queue, Revoke; Add a computer (the pairing code and the command); "Run my heavy work on"; this show's choice and, for an owner, the consent for shared shows. In the installed Studio, the switch for this computer, saying how it is.
  • The render's progress in Episodes says where: "Drawing frames on Wil's box · 312 / 674", "Waiting for Wil's box…", or the service's fallback words.
  • The console (/admin/runners, owners only): every runner of every account, online or not, its machine and version, its jobs now and the last ones with their outcome, time and bytes each way, each job's last lines, and Revoke; one person's alone (?user=<id>), and their runners on their page. Settings › Runners is the owners' switch for the whole service, applied at once: off, every runner is let go (1013) and its jobs end saying so, none connects or pairs (503, runners_off), everyone's work runs on the service with "Runners are turned off on this Reelwright service", and Your computers says so; on again, each runner comes back by itself.

The protocol

Runner protocol version 1. JSON text frames on the runner's socket; chunks over HTTPS.

Pairing and discovery

  • POST /api/runners/pairing (signed in) → {code, expiresAt, command}: a code of eight letters, ten minutes, single use, hashed.
  • POST <base>/runner/pair {code, name} (no sign-in; ten tries a minute per address) → {runnerId, token, name, serviceKey}.
  • POST /api/runners {name} (the installed Studio's device session only) → the same.
  • GET <base>/runner with Authorization: Bearer rwm_… → {ws, files, serviceKey} on the service's own origin (the proxy carries no sockets), or 401 {reason}: revoked, inactive or unknown, after which the runner stops for a new pairing.

Runner → service

Message
{"type":"hello","protocol":1,"version","name","machine":{os,arch,cores,memoryMb,gpu?,godot?,ffmpeg?},"voices":[…],"claude"?,"accepts":[…],"slots":{render,voice,preview},"planRuns"?,"parts"?,"chunks"?,"jobs":[{id,state}]} on connecting; jobs are those it still holds after a drop; accepts may say preview (phase 4); planRuns (with chat): it takes the runs on the plan (phase 5); parts (with render): it draws parts of a farm render, and chunks how many Godot jobs it draws a render in at once (phase 6)
{"type":"job.progress","jobId","stage","frame"?,"frames"?} a render's stage and frames; a preview job's drawing
{"type":"job.log","jobId","line"} at most 300 characters, keys redacted
{"type":"job.offer","jobId","root","files":[{path,size,hash,chunks?}]} its results
{"type":"job.commit","jobId","summary"} every chunk sent; a preview job's summary is {previews:[{id, refs:[{kind,file,caption?,docId?}]} or {id, error:{code,message}}]}, each file one of its results
{"type":"job.failed","jobId","code","error"}, {"type":"job.cancelled","jobId"}
{"type":"farm.part","jobId","start","end","files"}, {"type":"farm.sent","jobId","part"}, {"type":"farm.drop","jobId","part"} a farm's lead (phase 6): a chunk's folder offered by content, all of it sent; a part it no longer needs (it drew it first), whose helper the service stops
{"type":"event","turnId","event"} a chat event, as the bridge's; of a plan run's turn, the run's line or its end
{"type":"shows","shows":[{id,title}]} mode b: what it offers
{"type":"stream.opened","stream"}, {"type":"stream.frame","stream","data"}, {"type":"stream.closed","stream","code","reason"} mode b: a relayed session
{"type":"file.head","req","status","size"?,"start"?,"end"?,"contentType"?}, binary frames (the request's number in 4 bytes, then the bytes), {"type":"file.end","req"} mode b: a file asked for

Service → runner

Message
{"type":"welcome","runnerId","name"} its hello taken
{"type":"job","job":"<JSON>","sig":"<Ed25519>"} job: {id, kind, runner, user, show:{key,title}, params, manifest, writes, issuedAt, expiresAt}; kind render, voice or preview, whose params are {proposal, previews:[{id, kind, docId, frames?, episode?, into?}]} and writes [".reelwright/previews/"]; frames (phase 6), {lead, start, end, episode}, its manifest a chunk's folder and writes ["out/"]; a render's params.farm {chunks, here}
{"type":"plan","turnId","run","kind","show":{key,title},"text","mcp":{"token"}} a run on the plan (phase 5): kind direct or remember
{"type":"farm.need","jobId","start","part","chunks"} or {…,"back":true}, {"type":"farm.ready","jobId","part","files"}, {"type":"farm.back","jobId","part"} to a farm's lead (phase 6): what of a part's folder the service lacks (or "draw it yourself"), a part's frames ready to fetch, a part back to it
{"type":"job.need","jobId","chunks":[…]} the chunks of its offer the service lacks
{"type":"job.cancel","jobId"}, {"type":"job.ack","jobId"}
{"type":"turn",…,"mcp":{"url","token"}}, {"type":"cancel","turnId"} chat, as the bridge's, with the turn's MCP credential
{"type":"stream.open","stream","show"}, {"type":"stream.frame",…}, {"type":"stream.close",…} mode b
{"type":"file.get","req","show","path","range"?} mode b

Chunks: GET and PUT <base>/runner/blobs/<sha256>, Authorization: Bearer rwm_…, X-Reelwright-Job: <job id>; 4 MiB at most each.

Session protocol (additive): RendersStartParams.on and EpisodeVoiceParams.on ("service" | "runner"); RenderEventParams.runner ({id, name}), .fallback (why the service renders it) and stage queued; EpisodeVoiceResult.runner; PreviewEventParams.runner and .fallback (phase 4).

MCP (phase 5): /mcp?show=<key>&run=<run id> with a runner's rwj_ credential for that show: a session that also offers plan_task (no input) and plan_submit ({tool, input}), the daemon's hidden tools of those names with the run's id added (tools.schema.json PlanTaskInput, PlanSubmitInput). Any other run, or another credential, is a 403.

Daemon asks (phases 4 and 5, session/service-link.ts): runner.preview {userId, request: {kind, docId, frames?, episode?, at, into}} → {refs, runner}, {error, runner} or {fallback?} (draw it here); plan.ready {userId} → {runner?}; plan.start {userId, run, kind} → {none} at once, or {ended: "done" | "error", message?} when the run's turn ends.

Threat model

What is at stake: the person's shows (their writing, their voices, unreleased work), their machine, their account, and other people's shows on the service.

Threat Mitigation
Someone else's work, or someone else's show, reaches my runner Jobs are routed by the asker's own runners only; a shared show's files go to an editor's runner only with its owner's consent; the service checks both when it plans the job and again for every chunk.
A stolen runner token It opens only the runner protocol: it can receive the person's routed jobs (their show files) and nothing else (no API, MCP or console). Revocable in Studio and in the console, at once; Sign out everywhere revokes runners too; unused for 180 days it ends.
The service, or something posing as it, sends the runner a harmful job The runner talks to the address it paired with, over TLS (http only to loopback), and checks each job's Ed25519 signature against the key it pinned when it paired, that the job names it, and that it has not expired. Jobs are a closed set of kinds with typed parameters; the runner never runs a command or a script from the service.
A job writes outside its folder Every path in a manifest is checked (relative, no .., no .git, no absolute, no drive letter, no backslash, at most 512 characters) and written only under the job's checkout, through no symlink; results are collected from inside it. The systemd unit above adds the OS's own walls.
Hostile show content (a crafted model or image for Godot's importers) Godot reads it with the runner user's rights, inside the work folder, as the service's own Godot does today; run the runner as its own user, or under the unit's protections, for a further wall.
A runner sends back something harmful Results are accepted only for the job's own output paths, every chunk checked against its hash, render.json parsed and its paths rewritten; a render is listed only through render.json and an MP4 the service probes as it probes its own.
A runner floods the service Frames at most 8 MiB, chunks 4 MiB, results at most 2 GB a job and within the owner's storage, logs at most 20 lines a second; the service pings every 30 s and drops a silent runner.
Something on the machine reaches the runner The runner listens on nothing. In mode b its daemons listen on loopback with their own tokens, as the desktop's do; the relay reaches only the shows in the folders it was given.
A file on the runner runs as a page of the service (mode b) A relayed file goes out only as a type Studio loads (images, sound, video, captions, JSON), never HTML or script, its type set by the service from the name asked for (never what the runner says), with X-Content-Type-Options: nosniff, text with Content-Security-Policy: sandbox (not a picture, sound or video: the browser's own page for one would then lose the cookie), and only from inside the show's folder.
The service reads a show relayed in mode b It can, in transit, as it can read every show it holds; it stores nothing of it. End-to-end encryption between the web Studio and the runner would stop a passive reader, but the service serves Studio's own code, so it cannot stop the service itself; it waits until there is a Studio not served by the service to make it worth it.
A plan's sign-in Never leaves the machine, as with the bridge; the runner's MCP credential for Claude Code is per show and lives only as long as its connection.
A runner's preview makes the daemon read another file of the show, or one outside it (phase 4) A preview job writes only .reelwright/previews/ of its show (or of the proposal's folder, never through a link); the service answers with a preview only the files it took from that job, every byte checked; and the daemon takes a preview only from the show's own preview folder, whatever the service says.
A daemon of the service asks for something it should not It asks only for its own show, and the service checks the person it names is in that show now, as an owner or an editor, before a runner hears of it; a viewer's or a reviewer's previews and runs stay on the service.
A farm's part reaches someone else's machine, or a helper's frames reach the show (phase 6) Helpers are the asker's own runners only, as the lead is; a part's files are only its chunk's folder, served only to the helper holding that part; a helper's results are taken only under out/, every byte checked, and never land in the show: only the lead fetches them, and its pipeline checks every frame's hash and range before encoding, as it checks its own.
A run on the plan is taken by someone else, or used for something else (phase 5) The run's two tools are offered only to the MCP session its runner's credential opens for that show with ?run=; they answer only the person the run is for; and that session's Claude Code is given those two tools alone. The service never makes a model request on a plan: Claude Code is the client, and what it sends is checked by the director as a model's reply is.

Phases

  1. The runner, registered. reelwright runner, pairing and the Studio-made token, the capability report, its socket, status in Your computers in both editions and in the console, revocation, and the plan bridge folded in with old bridges kept.
  2. Jobs for service shows. Renders and voicing on the runner, content-addressed sync both ways with resume, results in the show, the choice of where (person, show, job), fallback, queueing, shared-show consent, and the live progress in Episodes.
  3. Shows on the runner's disk. --shows, "On " in the web Studio, the relayed session and files.
  4. Stills and previews on the runner.
  5. The director's and memory's runs on the plan, through Claude Code on the runner.
  6. A farm: one render's chunks over several runners, frames assembled where the encode runs.

What was built

Phase 1, the runner registered.

  • reelwright runner (core/src/cli/runner.ts, core/src/runner/client.ts runRunner): --pair once, then --service alone; the credentials file at 0600; --token-stdin (one JSON line, {"token","runnerId","serviceKey"}) and --json ({"runner": state} lines: connecting, online, idle, job with its stage and frames, offline, refused, stopped) for the installed Studio. machine.ts reports the machine: the GPU comes from the engine's own version (the adapter Godot really draws with), so a box whose Vulkan falls back to software says so.
  • The service (core/src/host/runners.ts RunnerHub, runner-store.ts): pairing codes (eight letters with no look-alikes, ten minutes, once; ten wrong ones a minute from an address and it waits), runner tokens (rwm_, hashed, 180 days unused), the socket at <base>/runner with its hello checked field by field, pings, a newer connection of the same runner replacing the older (4000), revocation closing it at once (1008) and the next GET <base>/runner saying revoked. During maintenance only the owners' runners connect.
  • GET /api/runners gives each runner with its machine, what it takes, its jobs (with their last 20 lines) and its last ten outcomes, and when it was last seen in seconds by the service's clock (Studio reads none). The console's Runners page (console-runners.ts) lists every account's, and Revoke; the console's Sign out everywhere revokes them, and disabling an account closes them.
  • Studio: Your computers (RwRunnersDialog) in both editions, from the palette; in the installed Studio, the switch for this computer (engine/app/rw_studio_computers.gd), which makes the runner through the device session (POST /api/runners, refused to a browser) and keeps it with the account (RwAccount.start_runner, runner_credentials, stop_runner). RwPlanBridge keeps either process: a said_key of "runner" reads the runner's lines.
  • The plan bridge folded in: PlanBridges asks the hub first (runnerChat), so a runner that accepts chat takes the person's turns, with mcp.token in each turn: a rwj_ credential per show, minted for the runner's connection and dropped with it, which mcp.ts takes for that show only (choose_show answers show.locked). The client's turn-running moved into claude/bridge.ts PlanTurns, which runBridge and the runner share. /bridge, personal tokens and old bridges are unchanged. In the installed Studio, with both switches on, the runner takes the chat and the bridge process stops: one process.

Phase 2, jobs for service shows.

  • The host's pipe hands renders.start, renders.cancel and episode.voice to the hub first (intercept); a route of mine with no runner online, service, or a shared show without its owner's consent goes on to the daemon as before, and the daemon's own render.events for that render gain fallback on the way out (upstream). The answer to a renders.list gains the render a runner does (running).
  • A job: its manifest (runner/manifest.ts, every file but .git, renders/ and the caches other than the take store, hashed once per size and time), its writes (the render's own folder and the take store), signed with the service's Ed25519 key (kept at <data>/runners/signing-key.pem), good for six hours. The runner checks it (protocol.ts openJob), fetches the chunks it lacks four at a time, lays the show out (store.ts), renders in cores / 4 Godot jobs at once (at most eight) or voices, and offers its results; the service asks for the chunks it lacks, takes each only if it is what was offered, and puts the files together, render.json last with its paths made the service's.
  • A render's progress reaches every Studio in the show as render.event with runner (and stage: "queued" while it waits); the asker sees the machine's name, the others "Ada's computer". Done, its maker gets the render_finished notification as for a render on the service. A runner's renders cost no render quota; the owner's storage quota still holds.
  • A runner may drop and come back within two minutes: the jobs it still holds carry on, an upload resumes from the chunks already taken; past that, the job fails (with "always", it waits for the runner again). Stopping, the runner fails its jobs at once (runner.stopping).
  • A show that changes while it is sent fails the chunk's check (409) and the job is planned again once.

Phase 3, shows on the runner's disk. As designed above: --shows, the rn_<16 hex>:<id> keys, RelayTarget tickets, the relayed session and files (core/src/runner/shows.ts), and "On " in the web show menu and switcher. A runner show's own daemon is the desktop's, so its renders and previews run on that machine (one Godot job at a time, as the desktop's). A file Studio asks for goes to the runner show whose folder holds it, as that show's own session said its folder (the hello's show.root, which the relay reads on its way), so a runner show in one tab and a service show in another each load their own files.

Phase 4, previews on the runner. As designed above ("Previews", "A daemon asks the service"):

  • The daemon's side: session/service-link.ts (the asks over stdout and the grant pipe, which host/grant.ts now also reads answers from; serve holds what it says until its port line is out), studio/routed-previews.ts (in front of the daemon's own preview service on the service: a preview drawn already, by its own cache key, is never asked about; the restyle and a sound's picture never are), PreviewService.peek (the cache entry's key and files, drawing nothing) and render's report (where it was drawn), which previews.get turns into preview.event's runner or fallback. A preview of several documents, and the restyle's model sheets and set views (before and after), are asked for all at once, so a runner draws them as a batch.
  • The service's side (host/runners.ts preview): the person's role and their choice, a shared show's consent; a batch per runner, show and folder (the show, or the proposal's), dispatched 25 ms after its first preview or when the runner's preview slot frees (slots. preview, 2 by default); the job's manifest of that folder; results only under its .reelwright/previews/, and only within the owner's storage; each preview answered with its files, laid out there; the preview.event naming the machine only to its person. A runner that stops, sends back something else, or is away 10 s leaves the preview to the service, with "when it is on"; with "always" it is the preview's error.
  • The runner's side (runner/client.ts): a preview service of its own, as many at once as its render's Godot jobs; a checkout per show and one for the last proposal; each preview's cache entry copied to the service's key (into) and offered.
  • Studio: the gallery says where after the caption ("Otto · model sheet · on Wil's box", "· on Reelwright: Wil's box is offline", in the tooltip too); Your computers words a preview batch ("Previews: model sheet of char.otto and 2 more, The Marigold: drawing"); the installed Studio's runner takes previews (--accept render,voice,preview[,chat]), and its switch says "drawing previews of …".

Phase 5, the director's and memory's runs on the plan. As designed above ("Runs on the plan"): claude/plan-run.ts (the run, its tasks and answers, the provider that routes a direction or a memory build to it or to the person's key, the two hidden tools), the daemon's runs (StudioOptions.runs, used by direct_episode and the memory pass only: Write with Claude and the showrunner's own chat keep theirs), ModelCaller skipping the budget for a free provider, the direction's and memory's close ending the run, host/runners.ts (planReady, planStart, the run's turn and its lines on the runner's job), host/mcp.ts (?run=, the two tools for that session only, a task given whole past a summary's 20,000 characters), and on the runner PlanTurns.runPlan (claude/bridge.ts: a Claude Code of its own, PLAN_PROMPT, --allowedTools the two) with PlanChat's prompt and PlanShow.allowed.

Phase 6, a farm of the person's own machines. As designed above ("A farm of one's own machines"): runner/farm.ts (FarmEngine, which the pipeline asks for every chunk at once and which shares them out in frame order; partManifest, drawPart, placePart), the runner's frames jobs and its lead's sendAway (runner/client.ts), and the service's #farmPlan, #farmPart, #farmSent, #farmDispatch, #partDone, #partFailed and #partBack (host/runners.ts), a job's chunks served from the show or from what the service staged. #decide picks the runner that draws the most at once among the least busy, the hello says chunks, and #farmPlan leaves out machines in software under a GPU lead: the live check's first run had the newly connected lavapipe "laptop" lead the GPU box and take twice as long as the box alone (327.9 s against 163.5 s), which is what these fix. Your computers words a part ("Drawing frames 112:168 of …") and the switch "part of a render".

After phase 6, the farm made lighter and the plan named. Parts come back encoded: render/encode.ts (encodeSegment, joinSegments, probeSegment, checkSegment, the picture settings shared with the whole render's), render/verify.ts verifyEncoded, EngineRun.segment and renderEpisode's segments (its own chunks encoded three at once, every segment checked, then joined); drawPart checks and encodes, placePart takes a segment and a result. A late part: FarmEngine times the lead's own chunks a frame, draws a part away too long once it has room, keeps the first to finish and drops the other (farm.drop, the service's #farmDrop); RunnerOptions.farmLateMs for tests. Measured: host/runner-measures.ts (RunnerMeasures, the service's measures), the upload window of each job, a render's draw rate from its render.json, a part's bytes a frame; #farmPlan leaves out a helper that sends slower than its lead draws; GET /api/runners says uplinkMbps, Your computers "sends … Mbit/s". The plan named: LlmProvider.answeredBy, the plan run's (from plan.ready's claude, the runner's Claude Code), and the model director's provenance and description from it.

Proof

  • core/test/runner.test.ts: paths a job may name, a manifest that must add up, a job opened only with the service's key, for this runner, in its time, of a known kind; files hashed whole and in 4 MiB chunks; the checkout writing only what changed, removing what went, never through a link.
  • core/test/host-runners.test.ts, on a real service with real reelwright serve daemons and runRunner in the test with the stand-in engine and voices and real ffmpeg: pairing (a code once, any case, ten wrong a minute; a token that opens neither the API nor /mcp; the installed Studio's own, refused to a browser, audited without the token); a runner's report and its revocation; a render on the runner (queued, each stage, 674 frames in two Godot jobs, the render in the show with the service's paths, listed by the daemon; again, nothing down and less up); fallback with the words; "always" waiting, render.busy, renders.list's running, cancel, then running once the runner comes; a shared show only with its owner's consent, the owner seeing "jay's computer"; voicing on the runner and its take back in the show; chunks only to the runner holding the job, from its manifest, an unasked upload refused, cancel reaching the engine; the plan's chat on a runner (text, tool call, result, proposal, done; its credential locked to the show and dead once the runner stops, its file removed); a runner whose connection drops mid-render coming back holding the job, which lands, drawn once; a hand-made runner offering show.json (the job fails, nothing written) or forged bytes (refused, asked again); a show on the runner's disk (listed, others refused, its own daemon's hello, /api/session keeping it, a service show opened in another tab and both tabs' files still loading, files with ranges and nosniff, text sandboxed and a sound not, an HTML file, /etc/hostname and a JSON file beside the show refused, the session closing 1012 when the runner stops); the console's page and Revoke, one person's runners on their page and their own list, and the owners' switch (off: the runner let go and kept out, no pairing, a render falling back with the words, the console saying so; on: the runner back by itself); and reelwright runner itself (pairing kept at 0600, nothing printed of the token, running again with --service alone; --token-stdin --json stopping when its input ends).
  • Mutation-checked 2026-10-06, each caught by the test named: a job's signature not checked ("opens only with the service's key"); a checkout through a linked folder ("never writes through a link"); a chunk served to another runner of the person ("are served only to the runner holding the job"); a shared show without consent ("only with its owner's consent"); the chat's credential free to choose another show ("takes the person's turns before any bridge"); results taken outside the job's writes, and an upload not checked against its hash ("cannot write outside its job's output"); a relayed file from outside its show ("opens through the relay"); no grace for a runner that drops ("comes back holding the job").
  • engine/tests/ui/rw_runners_dialog_test.gd: Your computers against a stand-in service (each runner's lines, the choices sent, a refusal in the service's words, pairing, Revoke, this computer's switch), the runner's command and its state words, the account's runner token kept at 0600 and revoked, Episodes' words of where a render runs, and the web show menu's "On " group.
  • End to end on the dev box, tools/web/runner-check.ts (2026-10-06, runner-check: ALL PASSED, 20 checks): a real service under /app and a headless reelwright runner paired with a code; the web export in Chromium chooses "My computer when it's on" in Your computers, presses Render preview, and the render runs on the runner (its progress "· on This box"); it comes back to the show on the service with the runner's GPU in its render.json and the service's paths; Watch opens it and the browser plays it (1280x720, 28 s). The runner stops: Your computers says "Offline, seen just now", and the next Render preview runs on the service with "· on Reelwright: This box is offline". The runner comes back with --shows: "The Lighthouse at Home" is listed "On This box", opens through the relay on that machine's own daemon at its own folder, renders there, and plays in the browser streamed from it, with no file of it on the service. Screenshots in out/runner-check/shots/, each looked at.
  • The installed Studio as the runner, tools/web/studio-runner-check.ts (2026-10-06, studio-runner-check: ALL PASSED, 20 checks): headless Studio in its own Xvfb (engine/tests/support/rw_runner_tour.gd) signs in with the code the service mailed, opens Your computers and turns on "Let this computer run my Reelwright work". The service lists "Studio on Linux" online with its machine, its token made by Studio's own sign-in and kept at
    1. The web export in Chromium chooses "My computer when it's on" and presses Render preview: the render runs in the runner Studio keeps (the card says "· on Studio on Linux", Studio's switch line "drawing frames 55 of 674"), comes back to the show on the service with that GPU in its render.json, 94.5 s from the click, and the browser plays it. Studio closes, its runner with it, and the service sees it offline; the token is in no log, transcript or screenshot. Screenshots in out/studio-runner-check/shots/ and shots/desktop/, each looked at.
  • Phase 4, core/test/host-runner-previews.test.ts (real daemons, the runner in the test with the stand-in engine): model sheets, a set view and a swatch drawn on the runner, the three asked while the first is drawn going as one job, the show's files moved once; a preview drawn already never asked about (and the runner's files kept under the service's own cache key); the runner off, the service drawing it saying "Cy's laptop is offline, so Reelwright draws it", and with "always" waiting for the runner; an engine that wrote no image coming back as the preview's own error, naming the machine; an editor's runner only with the owner's consent, the owner seeing "fay's computer", never a viewer's; a proposal's model sheet drawn there, from the proposal (Otto at 170 cm), and approved as Otto's canon sheet, its hash the runner's file's; the runner stopping mid-batch and the service drawing it, saying so; a hand-made runner offering show.json (refused) and naming show.json as a preview's picture (never taken).
  • Phase 5, core/test/host-runner-plan.test.ts: a hand-made runner and an MCP client as Claude Code: the run's turn with the per-show credential, a ?run= of another run refused (403), a chat session of the same credential without the two tools, someone else in the show refused the run's task (plan.no_run), the director's instructions and both tools' schemas in the first task, a bad draft answered with what to fix, an unknown tool refused, the hand-written salvage claim drafts taken one by one and "The run is done", both scenes by the model at no cost, the proposal, the run listed done on the runner, and its session answering nothing after; no plan runner (Claude Code on a key, or plan chat off): the heuristic, saying llm.auth; the runner going away mid-run: the heuristic for the rest, saying so; and a real runner with the stand-in claude directing the salvage claim (its arguments: no key, --allowedTools the two, every other tool disallowed; its five calls; the token's file gone after) and building memory. core/test/plan-run.test.ts: the task text, the run's task and answer as the model's tool_use, a run whose Claude Code stops, no budget for a plan's calls, and a runner's preview taken only from the show's preview folder.
  • Mutation-checked 2026-10-06, each caught: a viewer's preview to their runner; a preview naming a file its runner never sent; previews not batched; a preview drawn already asked about again; the runner's files under its own key; a runner's failure said without why; everyone seeing the runner's name; a runner preview taken from outside the preview folder; any run opened by a runner's credential; every session listing the plan's tools; another person taking a run's task; a run on the plan without plan chat on; a run on a Claude Code not on a plan; a plan's calls held to the budget; Claude Code never told the direction, or memory's build, is done; a run's Claude Code free to call every tool; any run id answering.
  • engine/tests/ui/rw_preview_gallery_test.gd (the caption's "· on Wil's box" and "· on Reelwright: …", nothing on the desktop) and rw_runners_dialog_test.gd (a preview batch's line, "drawing previews of …"; a farm part's, "Drawing frames 112:168 of …: 30 of 56", and the switch's "part of a render").
  • Phase 6, core/test/host-runner-farm.test.ts (real daemons, two or three runners in the test with the stand-in engine saying what it draws on, real ffmpeg): Ada's two runners split the Lighthouse preview, every frame drawn once by one or the other, the render whole in the show on the service (12 chunks, six parts listed done on the helper, "drawing frames a:b of …"), Eve's runner online and never asked; a GPU tower leading and drawing alone with an llvmpipe laptop online that connected after it; a second GPU with --chunks 2 given two chunks while the tower leads; two llvmpipe machines a chunk each; a helper holding its parts and stopping, its lead drawing every frame in the end. With the other runner suites (runner, host-runners, host-runner-previews, host-runner-plan, plan-run): 53 tests pass.
  • Mutation-checked 2026-10-06, each caught: a machine in software given as many chunks as a GPU; a part whose helper stopped never going back to its lead; the lead drawing none of its share; a helper's frames never served to its lead; someone else's runner helping; a machine in software helping a GPU lead; the newest runner leading over the one that draws the most; a runner's --chunks ignored in its share, never sent in its hello, or never read from it.
  • After phase 6: core/test/segments.test.ts (real ffmpeg: three chunks encoded alone and joined, every decoded picture as it was in its segment, the render passing its checks; the Lighthouse rendered from segments, one encoded elsewhere and placed as a helper's is; a segment one frame short refused, naming the chunk; a segment's settings the whole render's), core/test/farm-engine.test.ts (the lead's engine with a helper played in the test: a part in time kept and never drawn here; a late part drawn here and dropped; the helper's part kept when it comes while the lead draws it too, the lead's draw stopped; a part back undone drawn here), core/test/runner-measures.test.ts, and in host-runner-farm.test.ts each part back as its segment, the lead's uplink in Your computers, a late part's helper stopped before the render ended, a helper with a 1 Mbit/s uplink left out; host-runner-plan.test.ts the direction's provenance naming "Claude Code on a max plan, on Ada's box". The core suite: 139 files, 2,531 tests passed. Mutation-checked, each caught: a helper sending frames, not its segment; the lead never drawing a late part; a part never dropped, or the drop ignored by the service; a helper too slow to send taken; uploads never measured; a farm render encoding all its frames at once; a plan's direction crediting the configured model; a joined segment not checked; a helper's part kept while the lead's own draw of it went on. Live, runner-work-check (26 checks, ALL PASSED): the salvage claim's provenance naming "Claude Code 9.9.9 on a max plan, on This box" (the stand-in's version), shown in the proposal opened in the browser; The second box's three parts back as segments of 12.2 to 12.5 MB, the lead logging each "came back from another of your computers, encoded"; each runner's uplink in Your computers. Also from this change, the proposal page's values whole and in one column, and a change's summary by its state (rw_proposal_view_test.gd, tools-keep.test.ts, each mutation-checked).
  • The runner tests made steady (runner-2): waits that end when their condition holds (the test's timeout the only bound), runners on the fixture's lip sync, fallback renders cancelled rather than waited out. Run three times alongside the whole core suite at load 67 to 126: 53 of 53 runner tests and 2,516 of 2,517 core tests (one skipped) each time.
  • End to end on the dev box, tools/web/runner-work-check.ts (2026-10-06, runner-work-check: ALL PASSED, 23 checks): the service pinned to two CPUs on lavapipe, a headless runner on the RX 6700 XT with the stand-in claude. In the web export in Chromium, as Ada: the kettle's prop sheet and the steam jet's effect sheet drawn on the runner, "· on This box" under each; Show settings › Bright Modern, the restyle's before-and-after sheets drawn on the runner as two batches of four (the show's, and the proposal's); Your computers listing them; the salvage claim directed with the model through the runner's Claude Code (both scenes by the model, no key, the run's two tools only, the four drafts each checked), the proposal opened in the browser. Then the farm: the Lighthouse preview by This box alone (6 chunks); with "The old laptop" online on lavapipe, This box still leading and drawing it alone; with "The second box" on the GPU drawing three at once, 9 chunks, three of them its parts, Episodes saying "Drawing frames: 0 of 674 · on This box, with The second box" and Your computers "Drawing frames 449:524 of ep.lighthouse_cold_open, Marigold Salvage Co.: 0 of 75 · 2 more waiting"; and with The second box stopped mid-part, This box drawing its parts ("came back undone: drawn here") and the render whole. Bo, on Reelwright, the same previews drawn by the service. Screenshots in out/runner-work-check/shots/, each looked at. Its first run failed two checks: the lavapipe laptop, connected last, led and held the render to twice the box's own time, which is what the lead's choice and the GPU-only helpers fix.

Measured

The Lighthouse cold open as a preview (674 frames at 1280x720), from the web Studio's click to render.event done, on the dev box (24 cores, RX 6700 XT, load 13 to 23 from other work):

Where Godot Click to done Compile Draw Encode Moved
This box's runner (mode a) GPU, 6 jobs at once 60.3 s 7.7 s 20.5 s 29.0 s 2.2 MB down (68 ms), 148.6 MB up
The service, shaped as Fly's machine (2 CPUs pinned, lavapipe) software, 1 job 1,001.7 s (16.7 min) 0.2 s 896.1 s 101.5 s none
This box's own show through the relay (mode b, the desktop's daemon) GPU, 1 job 112.8 s 8.4 s 55.9 s 43.4 s the video streamed only when watched

The runner is 16.6 times faster end to end and draws 44 times faster. An earlier run under twice the load gave 76.6 s, 1,000.0 s and 82.4 s. Read them with care: the service here is this box's own cores pinned to two, not Fly's EPYC; the results went up over loopback, where a home uplink of 20 Mbit/s would add about a minute for the 148.6 MB (the MP4 is 110 MB at CRF 16; a second render sends only what changed, so its stems, mix and captions go up once); and mode b renders in one Godot job, as the desktop's daemon does, so it is slower than a runner's job but moves nothing.

Previews of the demo show (runner-work-check, 2026-10-06, load 63 to 82 on 24 cores from other work), each from asking to drawn and back in the show on the service. The runner's times include opening Studio at the page in Chromium; the service's are asked directly:

Preview This box's runner (GPU) The service (2 CPUs, lavapipe) Faster
The kettle's prop sheet (5 stills, composed) 8.4 s (7.5 s drawing) 49.0 s 5.8×
The steam jet's effect sheet (6 stills) 9.9 s (8.0 s drawing) 34.7 s 3.5×
The restyle to Bright Modern (4 model sheets, 4 set views, composed) 31.6 s (two batches of 4, 12.4 s and 14.6 s) 218.6 s 6.9×

Three later runs, at a load of 100 to 170 from other work, say where the runner's lead ends: the restyle stayed 2 to 6 times faster on it (52.0, 60.6 and 93.0 s against 300.1, 238.6 and 166.4 s), but one small sheet was a wash (the prop sheet 15.3, 12.1 and 36.2 s against 47.6, 21.6 and 26.1 s; the effect sheet 20.5, 24.1 and 21.8 s against 21.3, 25.6 and 13.8 s): with every core busy, the runner's Godot waits for a CPU as the service's does.

A batch's show moved once: the first preview job fetched the show's 41 files (2.2 MB), every later one nothing (its 41 files laid out from the store in 4 to 91 ms). What comes back is small: 0.1 to 13.5 MB a batch (the eight sheets of the restyle). The director's run on the plan (the stand-in claude answering with the salvage claim's drafts) took 7.3 s from asking to the proposal: the director's own checks and compiles, two scenes; a real Claude Code takes as long as the model does.

The farm (runner-work-check, 2026-10-06), the Lighthouse preview from the click to done. Both "machines" are this box: the helper shares its GPU and its CPU, and other work kept the load between 100 and 170 on its 24 cores, so these say what a farm costs and that it works, not what a second machine saves. Three runs:

The render Chunks (helper's) Click to done Draw Encode Load
This box alone 6 234.3 s · 312.4 s · 358.6 s 57.5 s · 86.6 s · 70.5 s 162.2 s · 204.1 s · 262.1 s 101 · 139 · 171
The old laptop (lavapipe) online: This box alone still 6 (0) 254.9 s · 245.0 s · 320.8 s 47.5 s · 63.7 s · 79.6 s 195.4 s · 163.2 s · 223.5 s 120 · 108 · 128
With The second box (GPU, --chunks 3) 9 (3) 290.3 s · 327.0 s · 276.7 s 81.1 s · 60.5 s · 59.1 s 191.5 s · 242.4 s · 201.7 s 122 · 124 · 138
The second box stopped mid-part 9 (0 back) 256.6 s · 501.9 s · 307.7 s 58.4 s · 94.4 s · 57.9 s 184.7 s · 386.4 s · 230.5 s 128 · 167 · 141
Before the fix: the lavapipe laptop leading 7 (6) 327.9 s 95.5 s 201.1 s 88 at the run's start
Before the fix: The second box leading, 6 + 6 12 (6) 554.1 s 104.4 s 347.0 s 88 at the run's start

The service drew the same render in 1,001.7 s (above, at a far lower load). What the numbers say: the farm works, a helper that stops leaves its parts to the lead and the render lands whole, and the times follow the load more than the farm (with the second box it was slower than the box alone in two runs, faster in the third), as on one box they must. A part's folder reaches its helper in 0.1 MB (the show's files are in its store), but its 75 frames come back as PNGs of about 1.3 MB each: 96.5 to 98.3 MB a part, over loopback here. The farm splits only the draw, and on a loaded box the encode on the lead (one ffmpeg) is two thirds of the time. At the dev box's quiet load (the first table) the Lighthouse preview draws in 20.5 s of 60.3 s, so a second machine as fast as the first would save about 10 s, and over a home uplink of 20 Mbit/s each part's 97 MB takes about 40 s to send: at preview size the farm pays off only on a fast network, and it is for full-size renders and long episodes, whose draw is most of the time.

That is what the next change answered: parts sent back encoded, and a farm's render encoded in segments (runner-work-check after it, 2026-10-06, 26 checks, two runs):

The render Chunks (helper's) Click to done Draw Encode Load
This box alone (one encode, as before) 6 476.1 s · 160.7 s 108.6 s · 53.5 s 321.5 s · 94.2 s 148 · 66
The old laptop online: This box alone still 6 (0) 176.1 s · 134.4 s 45.3 s · 32.4 s 119.7 s · 93.8 s 93 · 72
With The second box, in segments 9 (3) 107.0 s · 107.8 s 57.7 s · 59.4 s 40.2 s · 38.9 s 118 · 95
The second box stopped mid-part, in segments 9 (0 back) 87.9 s · 104.5 s 37.8 s · 42.4 s 41.3 s · 52.8 s 89 · 91

Each part came back as 12.2, 12.2 and 12.5 MB, byte for byte the same in both runs, an eighth of its 97 MB as PNGs, which a 20 Mbit/s uplink sends in about 5 s. The encode fell from about 94 s to 39 to 53 s, since the lead encodes its chunks three at once and the helper encoded its own; even the render whose helper stopped, all drawn on this box, finished sooner than the box alone at a like or lower load. A render that is not a farm still encodes all its frames at once (below, "Not yet"). The joined render is 674 frames at 1280x720, and its frames 300 and 600 (looked at) are the Lighthouse's. The service measured the runners' uplinks over loopback (This box 121.1 and 316.6 Mbit/s, The second box 940.1 and 631 Mbit/s).

Not yet

  • End-to-end encryption of mode b's relay (above).
  • Runners shared by a team (a studio's render box serving several accounts): every runner is one person's in M15.
  • Choosing a runner by a label of the person's own ("the fast one").
  • A preview drawn on a runner of another Reelwright version is kept under the service's own cache key though its engine was another; the next version of the service draws it again.
  • Write with Claude and the showrunner's own chat on the API stay on the person's key; only the director's and memory's runs go to the plan (the web chat on the plan was already the bridge's).
  • A direction on the plan names the person's Claude Code, its version and plan, and the runner (provider: claude-code); it cannot name the model Claude Code chose, which its stream says but the runner does not pass on yet. A memory build on the plan records nothing of who answered.
  • A farm's lead that drops loses its parts with its render, as any render's runner does (two minutes to come back); its helpers' segments are not kept for a lead that starts again.
  • A farm's chunks are all as long and its shares are what each machine says it draws at once: a late part is drawn by the lead too, but nothing yet sizes a slower helper's chunks smaller.
  • A farm's segments are joined at the lead: the mix and the join are its own, and the join copies every segment's bytes once more through the service (up from each helper, down to the lead). A helper's measured uplink is from its own uploads only; the lead's downlink is not measured.
  • A full render whose show delivers a ProRes master or a frame sequence is drawn on one machine. Helpers could send their frames back as well as their segment for it (several times the bytes: 97 MB a part of 75 frames at 1280x720), or a ProRes segment of their own (ProRes is intra-frame, so its segments join as H.264's do); neither is built.
  • A render that is not a farm still encodes all its frames in one ffmpeg. Encoding its chunks as segments, as a farm's lead does, halved the encode here; it would change every render's bytes (each segment starts on its own keyframe), so it waits for its own decision.

Sources

Checked 2026-10-05.