Contents

M12 Web service: Reelwright with accounts, on the web

The same Reelwright, hosted. People sign in with a code sent to their email, an admin adds and invites them, and each one gets their own shows and their own session daemon on the server. Studio's web build talks to that daemon through the service, exactly as the desktop Studio talks to the daemon it starts. The core does the work, as it does on the desktop: the same compiler, previews, renders (headless Godot on Mesa's lavapipe), ffmpeg and Rhubarb.

Read with docs/design/m11-release.md (how a core runs without a checkout) and ADR-0011 (one UI, two hosts).

The pieces

browser ──https──▶ reelwright.diy/app/* (Vercel: a rewrite to reelwright-app.fly.dev/app/*)
   │                 │
   │                 ▼
   │               reelwright host (core/src/host, one node:http server, port 8080, BASE_PATH=/app)
   │                 ├─ /app/login, /app/api/auth/*      sign-in by emailed code (Resend)
   │                 ├─ /app/admin                       the admin console, for the owners
   │                 ├─ /app/api/session, /app/api/shows each user's shows and daemon
   │                 ├─ /app/api/claude                  their own Anthropic API key (sealed)
   │                 ├─ /app/mcp, /app/oauth/*           MCP for their own Claude Code or Claude
   │                 │                                   app (m12-remote-mcp.md)
   │                 ├─ /app/bridge                      the plan bridge on their computer, for
   │                 │                                   Studio's chat on their plan (m12-plan-bridge.md)
   │                 ├─ /app/api/file?path=              their own files (previews, sound, renders)
   │                 ├─ /app/studio/                     the web export of Studio (static)
   │                 ├─ /healthz, /app/healthz           health
   │                 └─ /releases/<version>/<file>       the desktop release's packages, for anyone
   │                    (also under /app)
   └──wss──────────▶ reelwright-app.fly.dev/app/ws?ticket=…  (straight to Fly: Vercel does not
                      ══ WebSocket pipe ══▶ reelwright serve --show <their show>   carry sockets)
                                            (a child process per user, 127.0.0.1)
  • One address, reelwright.diy/app. The marketing site is static on Vercel; its site/vercel.json rewrites /app and /app/* to the same paths on the Fly app, so the pages, the API, POSTs, cookies and byte ranges all go through reelwright.diy and DNS (at Namecheap) never changes. The service runs every route under BASE_PATH=/app and puts it on every redirect, link, form and asset of the pages it draws; the session cookie's Path is /app. Health and the release downloads also answer at the root of the Fly origin: Fly checks /healthz, and the download links already published name https://reelwright-app.fly.dev/releases/<version>/…. Nothing else does there. The site keeps sending /page/ to /page with an explicit redirect that leaves /app/… alone (trailingSlash: false would bounce /app/studio/ to /app/studio and back forever).

  • The socket goes straight to Fly, with a ticket. Vercel's rewrites carry plain HTTP but not WebSockets, and the cookie set for reelwright.diy does not go to reelwright-app.fly.dev. With WS_ORIGIN=wss://reelwright-app.fly.dev, /app/api/session names the socket as a whole address there, wss://reelwright-app.fly.dev/app/ws?ticket=<random>. A ticket (core/src/host/tickets.ts) is 32 random bytes, works once, expires after 60 seconds, is bound to the user and the daemon they have at that moment, and is kept only in memory as its SHA-256. /app/ws takes the cookie or a ticket: an unknown, spent or expired ticket is a 401, one whose user is not the cookie's a 403, one for a daemon since stopped a 409 (one kept warm after another show opened still opens). The Origin check stays: the service's own origin and PUBLIC_URL's (https://reelwright.diy) may open the socket and send state-changing requests; any other site may not. Studio asks /app/api/session again before every reconnect, so each attempt has a fresh ticket.

  • reelwright host --data <dir> [--port 8080] (core/src/cli/host.ts) is the whole service. It needs no database server: accounts live in <data>/reelwright.db through node:sqlite, which is built into Node 24.

  • A daemon per show, and the one a user left kept warm. /api/session (or opening a show) starts reelwright serve --show <dir> as a child process of the service, the same daemon Studio starts on the desktop, and reads its {"port","token"} line; each line it prints after that is a notice ({"notice": …}: whom a comment concerns), which becomes that person's notification, or else goes to the log (m13-shared-shows.md). One daemon serves everyone who has the show open (a show shared with others, m12-sharing.md). Opening another show keeps the one left running (core/src/host/daemons.ts): switching back is instant (the same daemon, no new start), and a render or voicing there goes on. A new account's first /api/session makes them a copy of the demo (while the console's "demo show for new accounts" is on) and says "first": true, once: Studio then opens on "Make your show" over it, with "Or look around the demo" as the way out, and the tour after (product audit #22). What that first visit waits on, in order: the copy and its daemon, then the Show page's episodes.status (a compile of each episode without lip sync), then its pictures. The Show page asks for each episode's still first and the poster's line-up only once no still is being drawn: a daemon's engine draws one picture at a time, and the line-up, asked first, kept the cold open's still waiting 36 seconds on an idle machine and minutes on a busy one. The copy comes with the lip sync of the demo's lines (13 seconds of Rhubarb in every account before) and with the still and the line-up themselves, drawn once as the image is built (reelwright host demo-pictures, below): no account's first still waits on the engine. Nor do the Cast, Sets and World grids: their pictures (the crew pattern's line-up, the sets' views, the props' and effects' sheets, the styles' swatches) are drawn there too; the pattern's came 47 seconds after a new account pressed Cast. A person's own pictures are drawn on one engine kept for them while a page asks for them one after another (12 of a line-up's 20 seconds on lavapipe were Godot starting). Limits, from the machine's 4 GB that renders share and a daemon's ~175 MB: two shows a person (perUser, the open show and the last one; a third show leaves their list, and its daemon stops when nobody else has it), and at most four warm ones across everyone (maxWarm; the least recently used goes first, never anyone's open show). Every daemon stops after 30 minutes with no traffic of its own (no frames either way on its sockets and no API use): a warm daemon whose render still reports stays. Closing its stdin is how it is told to stop; SIGTERM and then SIGKILL follow if it lingers. The service stops only daemons it started.

  • The WebSocket is a pipe, but for the hello. /ws (the cookie or a ticket; the service's own origin or the public site's) connects to the show's daemon on ws://127.0.0.1:<port> and passes every frame through unchanged, both ways, except the first, which must be session.hello (anything else closes the socket with 4001): the service puts the daemon's token in it, which the browser never has (/api/session says "token":"service"), and as, who is signed in and their role in the show, whatever the browser sent for either. The daemon trusts as only in a hello with its token. The daemon sees a native client (no Origin header), so its loopback-origin rule is unchanged.

  • Files by path, contained. The daemon hands Studio absolute paths (preview PNGs, playback jobs and frames, sound.wav, MP4 renders). The web Studio fetches them through /api/file?path=, which serves a file only when its real path (symlinks resolved) is inside the user's own folder. Live playback reads the playback job's frozen assets (a painted backdrop's image, <job dir>/assets/<sha256>.png) the same way, into the page's memory, before it builds the set.

  • Switching shows in the browser. The web Studio switches as the desktop one does (Ctrl+O or Ctrl+Shift+O, the switcher): POST /api/shows/<id>/open, then the show it leaves keeps its open socket, listening for what finishes there, and a show still warm comes back on its own socket with no new hello. Each show's place (the section, the script and its unsaved draft, the timeline's episode, playhead and zoom, the chat) is kept in the browser's localStorage under reelwright.projects.<user id>, never on the service and never as a path of the person's computer. A socket that drops while a show opens asks /api/session, which names the show being opened, so the reconnect never sends Studio back to the show it left.

  • Back, Forward and an address for every place. The browser's history follows Studio (RwStudioHistory, RwStudioRoute): each place the person goes to (a section, a show, a document, the timeline's episode, a script, a proposal, the booth) is pushed with history.pushState as the page's address, <page>#/<show>/<section>[/<what>], each part URI-encoded (#/marigold/cast/char.otto, #/marigold/script/episodes%2Ftea%2Ftea.fountain, Show settings' #/marigold/settings); a detail of the place (the booth's line, the place a show opens at) replaces it. A hash needs nothing of the service: it serves /studio/ as before. Back and Forward come back as popstate, and Studio goes there without pushing again; a reload, a bookmark or a link opens the address's show (the service still decides whether the person may) and its place. Studio's own Back out of a document is the browser's Back. Going back to another script over unsaved changes asks first, as opening one does, and staying keeps the history where it was. Alt+Left and Alt+Right do the same on the desktop, with the mouse's back and forward buttons (tools/web/history-check.ts proves it in a browser).

  • The same picture as the desktop. The web export draws with Godot's Compatibility renderer (WebGL 2), the stills and renders with Forward+. Compatibility hands a spatial shader its colors in sRGB and takes ALBEDO and EMISSION as sRGB, and adds the pass of a light that casts shadows (the key) to the rest in sRGB; the live picture was 30 to 50 levels lighter. The shaders do their color math in linear on both (rw_color.gdshaderinc), and the key's pass adds only what the key changes. The frame of a still and the same frame played in the browser now agree within about two levels on average (tools/web/shots.ts measures it); where a practical's pool meets the key's light the web is a few levels lighter. Characters' polygons rebuild their meshes on every drawing there: Godot 4.7 updates a same-sized mesh's triangles through a buffer target WebGL refuses.

Accounts

  • Sign-in: /login asks for an email, POST /api/auth/request mails a six-digit code, POST /api/auth/verify checks it and sets the session cookie rw_session (HttpOnly, Secure, SameSite=Lax, 30 days). A code works for 10 minutes and once; five wrong guesses use it up. Codes are stored as SHA-256 of the address and the code; sessions as SHA-256 of their id, so the database alone signs nobody in.
  • Invite-only (as the service came; "Opening sign-ups" below is the one setting that changes it): a code goes only to an active user, to an address with an open invite (signing in accepts it and makes the account with the invite's role), or to an address in ADMIN_EMAILS, which is always let in and always an admin. Anyone else is told plainly that there is no account and to ask for an invite. An invitation to a show (docs/design/m13-shared-shows.md) also lets its address in, when the sign-in carries its token ({email, code, invitation}) and invitations may bring new people: signing in accepts it and makes a member's account.
  • The installed Studio signs in with the same code, sending client: "studio" and a device name to /api/auth/verify, and gets a device token instead of a cookie (core/src/host/devices.ts): Authorization: Bearer <token> on every API request. Tokens are kept as SHA-256, listed in the person's sessions with their name and last use, revoked there or by signing out on that device, and end after 180 days unused. They never open the admin console, which takes a browser session (browser_only, 403).
  • Rate limits: 5 code requests per address per 15 minutes, and 30 per IP per hour. The IP is the visitor's: through reelwright.diy, Vercel's x-real-ip, taken only when the request carries the edge's secret (x-reelwright-edge, EDGE_SECRET, set by site/vercel.json's route); straight to Fly, Fly-Client-IP, whatever headers it brings. Refusals and sign-ins go to the audit log. Sign-ups and the waitlist have limits of their own by the same address ("Opening sign-ups").
  • The admin console (/admin, the service's owners only): see "The admin console" below.
  • From the server's shell: reelwright host users list|add <email> [--role admin]|remove <email> --data <dir>, and reelwright host session <email> --data <dir>, which prints a session cookie for an active user, for checks. reelwright host pack <show dir> [--out <file.zip>] (on anyone's computer) makes the upload that puts a show on the service and prints {"file","bytes","sha256"}; `reelwright host upload --to
    ` makes it and sends it as the device token on its standard input names, a JSON line a step, as the installed Studio's Put it on Reelwright does (`core/src/host/put.ts`).

Opening sign-ups

Who may join is one setting: the admin console's Settings › Who can join (access.signups, kept in reelwright.db), which takes effect at once, with no redeploy. The service came invite-only and stays so until an owner picks something else: nothing in the code, the image or tools/host/fly.toml changes it, and a service rolled back to a version from before these choices reads either new one as invite-only.

Choice A visitor with no invitation Who gets a new account
Invite-only (invite, the default) is told to ask for an invite, exactly as before an owner's invite, an invitation to a show, ADMIN_EMAILS
Invite-only, with a waitlist (waitlist) is offered the waitlist on the sign-in page as invite-only; owners let people in from the waitlist
Open, with free accounts (open) signs up there with a code by email anyone; each account made that way is on the free tier
Closed (closed) is told nobody new is joining nobody (an owner may still add an account)

To flip it, sign in as an owner, open /app/admin/settings#access, pick the choice and Save; or from the server's shell, with no sign-in (see "Changing a setting from the shell"): fly ssh console -a reelwright-app -C "node /app/reelwright.cjs host settings set access.signups open --data /data --by lead". Either way the change is audited (settings.access) and every console page says when sign-ups are open. To undo it, pick Invite-only again: nobody new joins, accounts made while it was open stay on the free tier, and anyone waiting stays on the list for the console. Then say it on reelwright.diy too: set signups in tools/site/service.json to the same, run node tools/site/pages.ts and deploy the site. Its deploy (tools/site/claims-check.ts --live) refuses a site whose service.json differs from what the live sign-in page shows.

The waitlist. While it is on, the sign-in page's foot offers "Join the waitlist", and a stranger whose code request is refused is offered it with their address filled in. Joining, or "Check where I stand" when they come back, replaces the form with their place in line; an address with an invitation or an account goes back to signing in, saying so. POST /api/waitlist {"email"} answers where they stand: {"state": "waiting", "position", "waiting", "message"} (adding them when new), "invited" once an invitation waits for them, or "account". Owners see the list, the longest waiting first, on Users (/admin/users#waitlist): Let in (one, or the first so many) makes each an ordinary member's invitation, mails it ("You're off the waitlist") and takes them off the list; Remove takes an address off with nothing sent. From the server's shell, reelwright host waitlist list --data <dir>, … admit <email>… or … admit --first N (with PUBLIC_URL for the link, and RESEND_API_KEY, or --dev-mail on your own machine), and … remove <email>.

What is kept, in the words people read. Under the waitlist form the sign-in page says:

We keep your address only in this service's own database, on its own disk, to let you in. It is never written to a log, shared or sent anywhere, and we don't email you while you wait. When you're let in, one invitation comes to this address.

The service holds to that: the address is one row of the waitlist table in reelwright.db on the service's volume (/data); joining or asking writes no line to the service's log (what Fly keeps) and no audit entry naming it; while there is a waitlist, a refused sign-in by an address with no account is audited without the address; nothing is mailed while it waits; the limit below keeps only the visitor's IP, for a day. Letting someone in makes an ordinary invitation, and from then on their address is kept as any invited person's is: the invitation, its one email through Resend, and the audit log's waitlist.admitted.

The free tier (Settings › Free tier): renders a day (5), render minutes a month (60), storage (1024 MB), and Claude a month ($5: the monthly cap a free account starts with, on its own key's spending, "Spending" below). They apply over the quotas for every account; an account's own quotas, set on its page, still come first. The service enforces each, never the page: the host's pipe refuses a renders.start before the daemon hears it (renders a day, render minutes, renders at once, storage), a new show and a recorded take past the storage, and each daemon checks the Claude cap before every model call. A refusal names the limit and when it starts again: "You have started all 5 renders you may start in a day on this Reelwright service. You can start more at midnight UTC, in about 3 hours.", "… They start again on 1 November.", and for storage, which does not, what makes room. GET /api/usage gives a person their use against each cap that applies to them ({"usage": {"free", "caps": [{"id", "label", "used", "limit", "text", "full"}]}}), which Studio shows in Settings › Account › Your use and the console on their page.

Limits by the visitor's address (the same address as the code limits above, through the edge's secret): a code asked for an address with no account while sign-ups are open is a sign-up, 5 an hour and 20 a day from one address (SIGNUP_LIMITS); joining the waitlist or asking where one stands, 5 an hour and 20 a day (WAITLIST_LIMITS); and the list keeps at most 10,000 addresses.

The admin console

The service's owners run it from /admin (core/src/host/console.ts, its pages console-pages.ts), with no redeploy and no shell.

  • Who. The owners are the addresses in ADMIN_EMAILS (today the owner's own), and they alone reach the console and every route under /admin. Signed out, a page goes to sign in and back (/login?next=/admin/users) and a form is a 401; anyone else signed in gets the same 404 as a page that does not exist, whatever their role, so nothing says there is a console. Studio is told who is an owner ("owner": true in /api/me and /api/session's user) to offer "Admin console…".

  • From Studio. On the web an owner's show menu has "Admin console…" (between Help and support and Sign out), and so has the command palette; either opens <base>/admin in a new tab (RwWeb.open_tab). Nobody else's Studio offers it (RwSessionStatus.set_service_owner, from the session's user.owner). The owners' banner shows across the top of Studio, over the first screen and the workspace alike (RwAnnouncementBar): info, warning or critical, with a close button; a banner closed stays closed in that browser (its id in localStorage) until the owners change it. While the service is down for maintenance, Studio's /api/session gets a 503 maintenance and loads the page again (RwWeb.reload_page), which is then the service's own maintenance page.

  • What the admin role means now. The console calls it helper: someone who may manage the people in a show shared with them, for support (m12-sharing.md), and nothing service-wide. A helper is a member everywhere else; they never see the console or change a setting. Owners are always admin in the database too (signing in puts it back).

  • Forms only from the service's own pages. The session cookie is SameSite=Lax; every POST with another site's Origin is refused (403) before routing, and the console also refuses a Sec-Fetch-Site other than same-origin (a same-site page included). Every form posts back without JavaScript and ends in a redirect to a page, with what happened in a flash cookie whose Path is <base>/admin. Destructive forms ask first (confirm()); the delete of an account takes the address typed out, checked on the server.

  • Every action is audited, with the owner as its actor and their IP.

  • The pages. A left rail (a row of tabs on a phone): Overview, Users, Shows, Operations, Settings, Audit log. Across the top of every page, what is switched off or about to bite: maintenance on, sign-ups closed, KEY_ENCRYPTION_KEY missing, the spending ceiling reached, the banner showing.

    • Overview: accounts (active, disabled, invited), active in the last day, daemons running and warm, renders under way, Claude spent by everyone this month against the ceiling, the data volume, an invite form and the latest activity.
    • Users: every account with role (owner, helper, member), status, created, last sign-in, last active (any signed-in request, kept to the minute), shows, storage, Claude connected, spent this month against its cap, and sharing (people their shows are shared with, shows shared with them); searched by email or name, filtered by status, sorted by any column (or the Order select, for a phone). Invite (a helper or a member), add an account with no email, and the pending invites with resend and revoke.
    • A user's page (/admin/users/<id>): rename; role and status (disable signs them out and stops their daemons), never an owner's nor one's own; Claude: whether their key is connected and since when (never any of it), this month against their cap, set the cap, disconnect the key (forgotten, their grant written again without it); their shows (size, last changed, who each is shared with and as what, its daemon open, warm or stopped) and those shared with them; the daemons of shows they are in, each with Stop; their sessions (browser, address, last seen) and installed Studios (each device token's name and last use, devices.ts Devices.devicesOf), each Studio with Revoke (revoke: its token signs nobody in, and their sockets close with 1012 so it cannot carry on while a browser of theirs says hello again; audited device.revoked), and Sign out everywhere (their sessions end, every device of theirs is revoked, and their open sockets close with 1008, so Studio goes to sign in); Email a sign-in link (to /login?email=…, which fills the address in; no code goes with it); delete (the account, its sessions, shares and key, their daemons, and their folder users/<id> with every show in it; their spending this month stays in accounts/<id>, so the ceiling stays true); and everything they did and that was done to them.
    • Shows: every show on the service, its owner, size, last change, how many people it is shared with and its daemon (open, warm, stopped), searched by title, id or owner and sorted. Never what is in a show: the console shows what it takes and who is in it.
    • A show's page (/admin/shows/<owner id>/<show id>): its owner, folder, size (and its renders'), last change; its people with their roles and when they were added; its daemon as it runs: pid and port, up since, last traffic and the idle limit, memory (the daemon and what it started, Godot or ffmpeg, from /proc: system.ts processTree), who is connected (and in how many tabs), for whom it is the open show, the render under way, and Stop (everyone in it disconnected with 1012; Studio starts it again when someone opens the show; audited daemon.stopped); and its activity.
    • Operations: the machine (the data volume's free and used space, memory and the service's own, load against the CPUs, how long the service and the machine have been up, the version and the commit it was built from and when (BUILD.json, which tools/host/build.ts writes into the image; a checkout's own commit otherwise), Node, whether mail goes out (Resend asked for its domains, which sends nothing, at most every five minutes; --dev-mail says it writes to the log) and whether Claude keys can be kept); every daemon (its show and owner, open or warm, pid and port, up, memory, who is connected, last traffic, Stop) with the pool's limits; the renders under way (episode, quality, stage, frames, who started it, for how long); and an email test, which goes only to the signed-in owner's own address whatever the form says (audited mail.test).
    • Audit log: every entry, newest first, 50 a page (Newer, Older), narrowed by who (an account, or the service itself with no one signed in), what (an action or its start, user.), target (text in the target or detail; an account's address finds it by id too) and a range of days (UTC, both ends whole). Export CSV gives every entry the filter matches (/admin/audit.csv: id, time, actor and their address, action, target and its address, detail, IP; every cell quoted, and one a spreadsheet would read as a formula starts with an apostrophe); the export itself is audited (audit.exported).
    • Settings: every setting below, a section and a Save each, with when it was last changed and by whom; a value out of bounds is refused with why, and nothing changes.
    • A user's own quotas (on their page): each quota the default, no limit, or their own number, with what they use now beside it.
  • The settings (core/src/host/settings.ts) live in reelwright.db (settings, one row a section, as JSON), are parsed and bounded on the way in and on the way out (a section that no longer reads is its defaults), and are, until an owner changes one, exactly how the service behaved before. Each change applies at once, with no restart, where it acts:

    Setting Default Where it is enforced
    Sign-ups: invite-only or closed invite-only accounts.ts: closed, an open invite lets nobody sign in (the code request says sign-ups are closed, the invite link that it no longer works) and no invite is sent or resent; people with accounts and the owners still sign in, and an owner may still add an account
    Maintenance (on, message) off server.ts: for everyone signed in but the owners, every /api/ route answers 503 {"error":"maintenance","message"}, /studio/ is a 503 page with the message (Try again, Sign out), and the socket refuses them (503); accounts.ts sends them no code and signs them in nowhere; the sign-in page says it. Turned on, the daemons of every show not an owner's stop (a render there with them) and anyone else's sockets in an owner's show close
    Announcement (on, info, warning or critical, text) off /api/session and /api/me say announcement: {id, level, text} (else null), which Studio shows as a bar; the sign-in page shows it over the card. id is a hash of the level and text, so a person who closed one sees the next
    Claude on the web on claude.ts: off, grantFor hands no key to any daemon (every grant is written again at once, without keys, and the sockets close so Studio says hello again and has no chat), /api/claude says available: false and why, and connecting a key is a 403 claude_off; keys stay sealed for when it is on again
    Sharing shows on sharing.ts: off, a show reaches only its owner (roleOf), shows shared with someone leave their list, its key is a 404 for them and its files are theirs no more, and inviting someone, asking for access and approving a request are a 403 sharing_off (invitations.ts, through sharing-settings.ts); turned off, each person in a show not theirs leaves it, their sockets closed with 4403 and why. The shares are kept
    Sharing: invitations may bring new people on invitations.ts (sharing-settings.ts sharingSettingsOf reads it): off, or while sign-ups are closed, an invitation to an address with no account is refused (no_account) and one sent before brings nobody in (signing in with its token is refused); people with accounts are invited as before
    Sharing: requests from a show's link on invitations.ts: off, asking for access is a 403 requests_off
    Sharing: people per show none invitations.ts: the people in a show besides its owner and its open invitations together; an invitation past it is refused (too_many_people, 409). People already in stay. Bounds 1–1000
    A demo show for new accounts on server.ts #open: off, a person with no show is answered show: null, and Studio's first screen offers to make one
    Monthly Claude cap for new accounts none written as cap.json when an account is made: an invite accepted, an owner's Add, or host users add
    Monthly Claude ceiling for everyone none <data>/accounts/ceiling.json, which each daemon's CostMeter reads before every model call (showrunner/cost.ts): a call must fit what is left of it across every account's ledger for the month, holds of calls in flight included, as a person's own cap does ("… only $0.00 is left of the $100.00 a month everyone on this Reelwright service may spend together (its owner sets it; …)")
    Quotas: shows, storage MB, renders at once, renders a day, render minutes a day and a month (docs/design/render-capacity.md: a minute is the machine at work on a render, its wait for its turn left out; the console says what one costs) none the account's own over the defaults (user_quotas), and for a free account the free tier's ("Opening sign-ups"). Shows and storage: POST /api/shows, an upload (POST /api/uploads) and accepting a show offered to them (…/transfer/accept) are a 403 quota_shows or quota_storage. Storage (of the show's owner, as last measured: usage.ts, a minute old at most, measured as people join the show), renders at once, renders today (UTC: each render the daemon takes counts, in render_starts) and minutes today and this month (of the person who asks, jobs.ts): the host's pipe answers a renders.start or takes.upload over them itself with a JSON-RPC error (quota.storage, quota.renders, quota.renders_today, quota.render_minutes_today, quota.render_minutes), and the daemon never hears it; either in a batch is refused (session.batch). A render under way finishes
    Render workers: at most, size, idle minutes 0, performance-8x, 5 workers.ts (docs/design/render-capacity.md): extra Fly Machines drawing a render's chunks, started through the Machines API when a render can use them (only with FLY_API_TOKEN), stopped after the idle minutes; lowered, the idle ones stop at once; bounds 0–8 and 1–60
    Daemons: warm at most, shows per person, idle minutes 4, 2, 30 daemons.ts configure: lower limits stop what is over them at once; bounds 0–12, 1–4 and 5–240
    Sign-in codes: per address and window, per IP and window 5 in 15 min, 30 in 60 min accounts.ts requestCode

    The owners' own accounts are never held by maintenance and never changed by the console's access forms; every change is audited settings.<section> with what changed.

  • Renders, as the host sees them (core/src/host/jobs.ts RenderWatch). A render on the service starts only one way, a person's renders.start through the host's pipe, whose answer names the render: that render is theirs. The host also keeps a quiet socket of its own on every daemon it starts, signed in with the daemon's token and naming nobody (so in nobody's presence), and hears each render.event there, so it knows a render's progress and when it ended even with no Studio connected. When it ends (or its daemon stops) the minutes it ran are added to the person's month (render_usage in reelwright.db). Its progress counts as the daemon's own traffic, so a warm daemon rendering is not stopped as idle. One that finishes (done) is in the person's notifications (render_finished; email only when they ask for it): docs/design/m13-shared-shows.md.

The API the web Studio uses

All under the base path (/app in production; the paths below leave it out), on the page's origin, authenticated by the rw_session cookie, or for the installed Studio by Authorization: Bearer <device token> (401 JSON {"error","message"} without either).

Request Answer
GET /api/session {"user":{"id","email","name","role"},"show":{"id","title"},"ws":…,"token":"service"} (the daemon's own token stays with the service, which says the hello for the browser): while a show is opening for them, that show (a socket that dropped as Studio switched asks here, and must not be sent back to the show it left); else the show they opened last (its running daemon, else a new one; never a show merely kept warm), else their newest show, else a copy of the Marigold demo made for them now. ws is <base>/ws on the page's origin, or with WS_ORIGIN the whole address there with a new one-time ticket (wss://reelwright-app.fly.dev/app/ws?ticket=…); for a device token, always the whole address with a ticket (on the request's own origin without WS_ORIGIN). 503 when the daemon does not start.
GET /api/renders/estimate?show=<key>&episode=<id>&quality=preview|full&frames=<n> {"estimate":{"frames","width","height","quality","here":{"seconds","waitSec","ahead","text"},"runner"?:{"id","name","online","seconds"?,"text"},"runners":{"enabled","paired","route"}}}: how long the render takes on the service and after how many, and on the person's own computer (docs/design/render-capacity.md); 404 for a show they are not in, 400 for anything else wrong
GET /api/me {"user":{"id","email","name","role","owner"?}}: owner: true for the service's owners (ADMIN_EMAILS), as in /api/session's user
POST /api/me/language {language} {"ok":true}: the language Studio's Settings chose (en, es), which the service's pages and mails to the person are in from then on, whatever their browser asks for; "" follows the browser again. 400 bad_language for one the service does not speak (docs/design/translate-core.md, "The service's pages and mails")
GET /api/signed-in For reelwright.diy's own header, and answered signed out too (200, never 401): {"signedIn":false}, or {"signedIn":true,"name"} (the name others see: theirs, else their email before the @), nothing else. Cache-Control: no-store and Vary: Cookie, so no cache on the way keeps it. Signing in sets rw_signed_in=1 beside the session (Path=/, readable by the site's pages, no secret in it, as long as the session), and so does any request of a session that comes without it (one made before the hint was), for the session's time left; signing out and a signed-out answer take it away. A page with the hint asks this before it draws its header (tools/site/layout.ts); one without it shows Sign in and asks nothing
GET /api/shows {"shows":[{"id","title","open","role","owner":{"id","name"},"archived"}]}: their own shows, newest first, then those shared with them (m12-sharing.md), each id a show key (<owner id>~<show id> for a shared one); archived when the person put it away (Studio lists it under Archived)
POST /api/shows {title, logline?, setting?, style?, start?} 201 {"show":{"id","title"}}: the core's show maker (core/src/show/make.ts, as init runs it). start is blank (the default: the person's own show, its theme from their title, logline and setting, Reelwright's built-in styles with style active, its starting patterns, nothing of the Marigold), kit (the Marigold's theme, styles and patterns) or demo (a copy of the Marigold; {"demo":true} still asks for it). Its repository commits as the person. 400 with the words when a title is missing or too long, or style is not a built-in one; 403 quota_shows or quota_storage when the person's quotas leave no room
POST /api/shows/<key>/open the same body as /api/session, on that show: its daemon when it runs (warm, or someone else's in it), else a new one; the show left stays warm. 404 for a show they are not in. The session's show has their role in it and its owner. A key of a show that moved to another owner opens it where it is now, and the answer names its new key
DELETE /api/shows/<key> {confirm} {"ok":true,"deleted":"<key>"}: deletes the show (docs/design/m13-shared-shows.md), as Show settings' Delete asks once its name is typed: the owner only (403 for its members, 404 for anyone else), confirm its title (400 confirm otherwise). Its daemon stops, every socket in it closes with 4403 and "Ada deleted Tea Time", its people, invitations and requests go, it leaves everyone's archived shows, and everyone in it is notified
POST /api/shows/<key>/duplicate {title?} 201 {"show":{"id","title","archived"}}: a copy of a show the person is in as a show of their own (<title> (copy) by default, a new id and show.<slug>), committing as them from a history of its own, without its renders or caches; one more show on their quotas (403 quota_shows, quota_storage)
POST /api/shows/<key>/archive {archived} {"show":{"id","title","archived"}}: off the person's own list, or back on it; kept per person by key in <data>/users/<id>/archived.json, the show and everyone else's lists untouched. 400 unless archived is true or false
GET /api/file?path=<abs path> the file, with its content type, ETag and byte ranges; 403 outside the user's folder and the shows shared with them, 404 when missing
POST /api/auth/verify {email, code, client: "studio", device, language?} the installed Studio's sign-in: {"ok","user","token","device":{"id","name"}}, a device token (rwd_…) in the answer and no cookie; device names it in the person's sessions ("Studio on Linux"; "Reelwright Studio" when none); language, Studio's, is the one the service mails them in until they choose one
POST /api/logout ends the session; with a device token, revokes that device
GET /api/sessions {"sessions":[{"id","kind":"browser" or "studio","name","createdAt","lastUsedAt"?,"expiresAt","current"}]}: the person's browser sessions and devices, newest first (a browser session's id is s_ and 16 hex characters of its hash, never the session itself)
DELETE /api/sessions/<id> ends that session or revokes that device (the person's own; 404 for anyone else's)
POST /api/uploads (application/zip, X-Reelwright-SHA256) puts a show on the service (m13-shared-shows.md): the zip of history.bundle and files/<path> that reelwright host pack makes, checked and validated like any Show Pack, becomes a show in the person's workspace: 201 {"show":{"id","title"}}. 400 no_checksum, checksum_mismatch, bad_pack, bad_history; 409 upload_busy; 413 too_large (over MAX_UPLOAD_MB); 415 not_a_pack; 422 invalid_show with its first errors. Studio's own writes only (the header, no other site)
GET /api/shows/<key>/pack a copy of the show to keep, application/zip as <id>.zip: its folder under <id>/, and for its owner its history as <id>.bundle. 404 for a show they are not in. Show settings' Export as Show Pack downloads it on the web

A show route answers 404 for a show the person is not in, never a hint it exists; signed out, 401; a request from another site's page, 403 (every method that changes something is checked for its Origin, not only POST), and a write that is not Studio's own (the header, JSON) 403. Renaming, the logline, the style and every other setting are the show's own documents, which its daemon changes (show.update) on the web as on the desktop: its owner and its editors may, a reviewer or a viewer is refused (the session schema's method classes). | GET /api/claude | {"claude":{"available","connected","key"?,"connectedAt"?,"problem"?,"spentThisMonth","monthlyCap"?}}: whether the service can keep keys (KEY_ENCRYPTION_KEY), and of the person's own key only "sk-ant-…" and its last four characters, when it was connected (RFC 3339), what they spent this month through the service and the cap an admin set them | | POST /api/claude/key {key} | checks the key with Anthropic and keeps it ("Claude on the web" below): 200 {"claude":{…}}; 400 no_key, not_a_key or subscription_token; 403 from another site's page or without X-Reelwright-Studio: 1 and JSON; 422 when Anthropic refuses it (its words, the key cut out); 429 after ten checks in an hour; 503 keys_unavailable without KEY_ENCRYPTION_KEY. The person's daemons start again with it. | | DELETE /api/claude/key | forgets the key (the same header and Origin rules); the daemons start again without one | | GET /api/mcp | {"mcp":{"url","command","connections":[{"id","kind","label","createdAt","lastUsedAt","expiresAt"}]}}: the MCP endpoint's address, the claude mcp add command for it, and the person's Claude connections (m12-remote-mcp.md) | | POST /api/mcp/tokens {name} | 201 {"token":"rwp_…","connection","mcp"}: a personal token for the MCP endpoint, shown this once; Studio's own writes only | | DELETE /api/mcp/connections/<id> | ends that connection: its tokens stop and its MCP sessions close | | POST /mcp (bearer token) | Reelwright's MCP server for Claude Code and the Claude apps, with its OAuth server under /oauth/ and its metadata at the origin's root (m12-remote-mcp.md) | | POST /api/mcp/plan-chat {on} | Studio's own chat on the person's plan, through the bridge on their computer, on or off (m12-plan-bridge.md); GET /api/mcp says planChat, the bridge (online, since, its Claude Code) and the bridgeCommand | | GET /bridge (personal token), then its WebSocket | where the plan bridge opens its socket, and the socket: turns to it, chat events back (m12-plan-bridge.md) | | GET /ws (upgrade) | the pipe to the daemon, signed in by the cookie or ?ticket=. 401 signed out or a ticket unknown, spent or expired; 403 from another site, or a ticket for another user than the cookie's; 409 when the user has no daemon, or the ticket's daemon has stopped (call /api/session again). A socket stays open while its daemon runs, warm or not: the web Studio keeps the one of the show it left, to hear what finishes there. When the daemon stops (idle, or put away for another) the socket closes with 1012. | | GET /studio/… | the web export (signed in; else a redirect to /login). .wasm as application/wasm; .br/.gz twins when the browser takes them, gzip on the fly otherwise. Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, so a threaded Godot export gets SharedArrayBuffer. With no export installed, a page that says Studio is on its way and opens the session. | | GET /healthz | {"ok":true,"version","daemons"}, at the root and under the base path | | GET /releases/<version>/<file> | Public, no sign-in: a desktop package or its SHA256SUMS from <data>/releases/<version>/, which reelwright.diy/download links to. Content-Disposition: attachment, its type (application/vnd.appimage, application/x-xz; text/plain for the sums), Cache-Control: public, max-age=31536000, immutable (a version's files never change), HEAD, and byte ranges, so a download resumes (If-Range with the file's Last-Modified; anything else gets the whole file). The version is x.y.z[-pre] and the name a plain file name (letters, digits, ., _, -, no dot first): anything else, a folder, a missing file or a link out of the folder is a 404. GET and HEAD only. |

The routes below are M12's sharing (m12-sharing.md) as M13 left it (m13-shared-shows.md): people join by accepting an invitation, roles are editor, reviewer and viewer. Every write is Studio's own (JSON, X-Reelwright-Studio: 1, no other site), every show route is a 404 for anyone not in the show, and everything is audited.

Request Answer
GET /api/shows/<key>/people {"people":[{"id","name","email"?,"role"}],"you":{"role","manage","invite"},"editorsCanInvite"}: the owner first, then everyone it is shared with; emails only for the owner and admins in it; invite whether they may invite and answer requests
POST /api/shows/<key>/people {email, role} M12's add, now an invitation: the same as POST …/invitations with one address
PATCH /api/shows/<key>/people/<user id> {role} changes their role (the owner and admins in the show); their sockets to the show close with 1012, so they say hello again with it, and they are notified (role_changed)
DELETE /api/shows/<key>/people/<user id> stops sharing it with them: their sockets to it close with 4403 and "Ada stopped sharing Tea Time with you", their grant is forgotten in its daemon, a ticket they still hold opens nothing, and they are notified (removed)
PATCH /api/shows/<key>/sharing {editorsCanInvite} the owner (or an admin in the show) lets its editors invite people and answer requests, or not
GET /api/shows/<key>/invitations {"invitations":[{"id","email","role","note","by","createdAt","expiresAt","state"}]}: the last 30 days' not accepted or revoked, state pending, expired or declined; for those who may invite (403 for other members)
POST /api/shows/<key>/invitations {emails or email, role, note?} 201 {"invitations":[…],"refused":[{"email","status","error","message"}]}: an invitation per address (at most 20; a list, or one string split at commas and spaces), each emailed with its Accept link, as editor, reviewer or viewer; refused per address: invalid_email, owner, already_shared, disabled, no_account (no account here while invitations may not bring new people), too_many_people, mail_failed (kept; resend it). When every address is refused, the first refusal is the answer. 400 invalid_role, note_too_long; 403 sharing_off; 429 after 50 in a day
POST /api/shows/<key>/invitations/<id>/resend a new link and another 14 days, emailed again (the old link stops working): the owner any, an inviting editor their own
DELETE /api/shows/<key>/invitations/<id> revokes it: its link and id open nothing (410)
GET /api/invitations {"invitations":[{"id","show":{"key","title","owner"},"role","note","by","createdAt","expiresAt"}]}: the open invitations to the signed-in address
POST /api/invitations/<id>/accept {"show":{"id","title","role","owner"}}: joins the show; only the invited address (anyone else 404); 410 invitation_gone when accepted, declined, revoked or expired, saying which
POST /api/invitations/<id>/decline {"ok":true}; the inviter sees it declined
POST /api/shows/<key>/requests {note?} someone signed in who opened the show's link (<owner id>~<show id>) asks for access: 201 {"request":{"id","createdAt"}}, the owner and inviting editors notified; asking again while one is open answers it (200) and tells nobody again; 404 for no such show, 409 already_in, 403 sharing_off or requests_off, 429 within a day of a decline or after ten in an hour
GET /api/shows/<key>/requests {"requests":[{"id","person":{"id","name","email"},"note","createdAt"}]}: the open ones, for those who may invite
POST /api/shows/<key>/requests/<id>/approve {role}, …/decline lets them in as role (they are notified) or declines
POST /api/shows/<key>/leave anyone but the owner (400 owner) leaves; their sockets to it close with 4403
GET /api/shows/<key>/transfer {"offer":{"to":{"id","name"},"createdAt","expiresAt"} or null}, for the owner and the person offered it
POST /api/shows/<key>/transfer {to}, DELETE …/transfer the owner offers the show to one of its editors (201; 400 not_editor; they are notified), or withdraws the offer
POST /api/shows/<key>/transfer/accept, …/decline the person offered it takes it: {"show"} as they name it now (their own id). Its daemon stops (everyone's socket 1012), the folder moves to their workspace, everyone keeps their role, the old owner becomes an editor, and old keys find it. Or they decline
GET /api/notifications?unread=1&limit=&before= {"notifications":[{"id","kind","createdAt","read","show"?:{"key","title"},"by"?,"role"?,"previous"?,"note"?,"text"?,…}],"unread"}, newest first
POST /api/notifications/read {ids} or {all: true} marks them read (the person's own only): {"unread"}
GET /api/notifications/prefs, PATCH … {prefs: {kind: mode}} {"prefs":{kind: "immediate" or "daily" or "off"}}; invitation, access_request and ownership_offer are never daily (400 invalid_mode)
GET <base>/invitations/<token> (a page) the invitation's link: Accept and Decline when signed in as its address, Accept by signing in (/login?invitation=<token>) when signed out, a word on whose it is when signed in as someone else; 404 for no such link, 410 when it is gone. POST answer=accept (signed in as its address) or answer=decline (the link is enough)
GET <base>/unsubscribe?t=<token> (a page) an email's stop link: says what it stops; POST (its button, or a mail program's one click, RFC 8058) stops that kind for that address. 404 for a token this service did not sign

GET <base>/ goes to <base>/studio/ when signed in, else to <base>/login. With a base path, nothing but /healthz and /releases/… answers outside it (404).

Files the person brings and takes need no API of their own and name no path on either computer. A script to import (Episodes' Import script…) is picked with the browser's own file picker and read there (RwWeb.pick_file: an <input type="file"> and a FileReader, opened straight from the click, files over 2 MB refused unread); its bytes go over the socket as screenplays.import, as a recorded take's go as takes.upload. A script exported (the script editor's Export) comes back over the socket as screenplays.export's bytes, which the browser downloads under the name the core gave (RwWeb.download, Godot's JavaScriptBridge.download_buffer).

Data layout

/data/                     the Fly volume (--data)
  reelwright.db            accounts, codes, sessions, invites, audit, each person's
                           Anthropic API key sealed, and (M13) shares, invitations to shows,
                           requests, offers, moves, notifications and email settings
                           (SQLite, WAL)
  users/<user id>/shows/<show id>/
                           a Show Pack, a git repository committing as its owner
  accounts/<user id>/      cap.json (the monthly cap an admin set) and spend/<YYYY-MM>.jsonl
                           (every priced model call of theirs); outside users/, so neither
                           /api/file nor their tools reach it
  incoming/                an upload while it is checked (M13), outside users/; emptied
                           whatever the outcome
  home/                    HOME: Godot's user folder
  cache/                   REELWRIGHT_CACHE: downloads, the Kokoro model (fetched by the
                           first voice; the voices' Python and packages are the image's)
  releases/<version>/      the desktop release's packages and SHA256SUMS, served read-only
                           at /releases/<version>/<file>

A release's packages go up with fly ssh sftp put into a dot folder the route cannot serve, are checked there with sha256sum -c SHA256SUMS, and are then renamed to their version (the recipe is in the verify skill's release.md).

Settings

Variable
RESEND_API_KEY (secret) Resend's API key; without it the service will not start (--dev-mail writes mail to the log instead, for a machine of your own)
RESEND_FROM the sender; default Reelwright <login@reelwright.diy>, which the service uses since Resend verified reelwright.diy (2026-10-05)
RESEND_REPLY_TO where a reply to a code or an invite goes; default support@reelwright.diy, the address the site gives
ADMIN_EMAILS comma-separated addresses that are always admins
PUBLIC_URL the public address with the base path (https://reelwright.diy/app): emailed links start with it, and its origin may post and open the socket; default the address the request came to
BASE_PATH the path every route lives under (/app); default the root
WS_ORIGIN the service's own wss:// origin, for pages that come through a proxy without WebSockets: the session then names the socket there with a one-time ticket
WEB_STUDIO_DIR the web export; default out/web-studio (/app/web-studio in the image)
HOST_TRUST_PROXY read the client's IP from the proxy's headers (on by itself on Fly)
EDGE_SECRET (secret) what the edge in front (Vercel) sends in x-reelwright-edge (openssl rand -hex 32; the same value is the Vercel project's EDGE_SECRET, which site/vercel.json sets the header from). A request carrying it is counted by its x-real-ip, the visitor's; /healthz says "edge": true for it. Two values, comma-separated, while one replaces the other; a value shorter than 32 characters stops the service at start.
HOST_INSECURE_COOKIES=1 cookies without Secure, for plain http on your own machine
KEY_ENCRYPTION_KEY (secret) 32 random bytes in base64 (openssl rand -base64 32): what each person's Anthropic API key is sealed with. Without it the service runs, keeps no keys, says so at start and across the admin console; a value that is not 32 bytes of base64 stops it at start. Losing or changing it means everyone connects their key again (the dialog says so).
MAX_UPLOAD_MB the largest show anyone may put on the service (POST /api/uploads), in megabytes; default 256

The daemons get the service's environment without its secrets (RESEND_*, *_API_KEY, *_TOKEN, *_SECRET); KEY_ENCRYPTION_KEY the service deletes from its own environment once it has read it, so nothing it starts (daemons, git) has it. No Anthropic key is set on the service: each person brings their own.

Claude on the web: each person's own key

The showrunner chat, Write with Claude (assist.write), the model director and show memory's model pass work on the web as on the desktop, on the person's own Anthropic API key. The service never runs anything on a Claude plan: Anthropic's terms do not let a hosted service use one (docs/design/claude-plan.md), and a daemon the service runs refuses --claude-plan. A person's plan works the other way round: their own Claude (Claude Code, a Claude app) connects to the service's MCP endpoint, <base>/mcp, and drives the showrunner's tools on their shows from there (m12-remote-mcp.md); and, when they turn it on, Studio's own chat runs on their plan on their own computer, through Reelwright's bridge there (m12-plan-bridge.md).

  • Connect Claude in the web Studio is the desktop's dialog with the plan card hidden (RwConnectDialog on the web). It opens from the chat's empty state ("Connect Claude to talk to the showrunner: with your own Anthropic API key, which Reelwright keeps encrypted for your account."), a refused key's error in the chat, Write with Claude's "Claude not connected" error, the dashboard's Write with Claude while no key is connected, the show menu and the palette. It asks GET /api/claude and shows what the service has ("Connected: sk-ant-…wxyz, since Oct 3, 14:05", this month's spending and any cap) with Remove my key.
  • The check. POST /api/claude/key refuses, before asking anyone: when the service has no KEY_ENCRYPTION_KEY; an empty paste; a subscription sign-in token (sk-ant-oat…, sk-ant-ort…), as the desktop does; anything not shaped like an API key; and an eleventh check within an hour for one person (the service is no oracle for other people's keys). Then it asks Anthropic exactly as the desktop's Connect Claude does, by listing one model, which costs nothing (checkKey, an SDK client that logs nothing). A refusal is said in Anthropic's words with the key cut out of them, and nothing is kept.
  • At rest. The table claude_keys in reelwright.db: the user (ON DELETE CASCADE, so a deleted account's key goes with it), the sealed key, a fingerprint of the secret that sealed it, its last four characters and when it was connected (core/src/host/keys.ts). Sealed is v1.<nonce>.<ciphertext>.<tag>: AES-256-GCM under KEY_ENCRYPTION_KEY, a fresh 12-byte nonce each time, the 16-byte tag checked at its full length, and the user's id as additional data, so a sealed key copied into another user's row does not open. The secret is a Fly secret, not on the volume, so a copy of the volume or the database alone opens no key. A key sealed under an earlier secret is reported as one to connect again.
  • For that person's calls only. Each daemon has a pipe of its own, fd 3 (serve --grant-fd 3, core/src/host/grant.ts), kept open while it runs. Each time a person's socket is piped to a daemon the pool writes their grant there, one JSON line (ClaudeService.grantFor: their user id, their key, decrypted, and their account folder), before their hello goes on; the daemon keeps the key in its memory (supplyApiKey, by person), and a hello waits up to three seconds for its person's line. The daemon reads the pipe on its event loop, as it reads its sockets (readGrants, a socket on the fd), so a line written before a hello is read before it: read on the thread pool, as a file stream reads a pipe, a busy pool held a new grant or a forget back past the hello that followed it, which then went on with the key the person had before. Each request runs as the person its connection's hello named (session/person.ts), so a chat turn, Write with Claude, the model director and memory's pass use that person's key, never another's in the same show (docs/design/m12-sharing.md). Godot, ffmpeg, git and Rhubarb, which the daemon starts, inherit no key: it is never in an environment, which every daemon shares and any process of the same user can read in /proc/<pid>/environ. On the service (REELWRIGHT_HOSTED=1) the key is that one and nothing else: resolveApiKey ignores ANTHROPIC_API_KEY, the key file, the Keychain, ANTHROPIC_AUTH_TOKEN and the SDK's profiles, which on a server would be someone else's, and its messages say "Connect Claude in Studio's menu" instead of naming key files.
  • When it changes. Connecting, replacing or removing a key writes the person's new grant to every daemon they joined, at once, and closes their sockets there with 1012; nothing stops, so a render under way goes on, and other people in a shared show are not touched. Studio asks /api/session again as it does after any drop and says hello again: the chat has its chat capability and works without reloading the page.

Spending

  • show.json's budget applies as on the desktop: each daemon's CostMeter plans every model call against maxUsdPerRequest, maxUsdPerMonth and maxUsdPerEpisode and the show's ledger (.reelwright/spend/<YYYY-MM>.jsonl).
  • Each person's account. Every priced call from any of their daemons is also written to <data>/accounts/<user id>/spend/<YYYY-MM>.jsonl, whichever show it was for (the grant names the folder; CostMeter's account). The admin console shows what each person spent this month.
  • A monthly cap per person, none by default: an owner sets it on the person's page in the admin console (POST /admin/users/<id>/cap, usd=12.50, empty for none; any owner, for anyone, themselves included: it is money, not access; audited as user.cap). It is cap.json in the account folder, which the person's daemons read before each model call: a call whose worst case does not fit what is left is not made ("… only $0.00 is left of your $5.00 a month on this Reelwright service (its admin sets it; $5.00 spent this month; it starts again on 1 November)"), in the chat, Write with Claude, the model director and show memory alike, and a call under way holds its worst case in the account's ledger so the person's other daemon plans around it. A cap file that cannot be read is a cap of $0. A new cap takes at the next call, with no restart.

Studio's Content-Security-Policy

Every file under /app/studio/ carries a policy of Studio's own (core/src/host/studio-csp.ts; a Worker's script gets it from its own response, so the camera's Worker has it too):

default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' 'sha256-…' …;
style-src 'self' 'sha256-…'; connect-src 'self' <WS_ORIGIN>; worker-src 'self'; img-src 'self' blob:;
media-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self';
frame-ancestors 'none'

Studio loads, runs and talks to its own origin only. What it needs beyond that, and why:

  • 'wasm-unsafe-eval': the engine (index.wasm) and the camera's LiteRT.js are WebAssembly, which a browser compiles only when the policy allows it.
  • 'unsafe-eval': Godot's JavaScriptBridge.eval, how Studio's own scripts work the page (the keyboard, the address bar, the DOM mirror, the encoder, the microphone; 22 places), runs JavaScript's eval. What it runs is Studio's code, never a show's words. Dropping it means moving those scripts into a file of the page's and calling them through JavaScriptBridge.get_interface.
  • The page's inline <script> and <style> (engine/app/web/shell.html: the theme chosen before the engine starts, the engine's config and boot, the loading screen), each by the sha256 of its text in the export served. The service reads them from index.html, and again when a new export replaces it, so no other inline script runs.
  • blob: pictures: the engine draws the window's icon and Studio's own mouse cursors as PNGs it makes in the page (URL.createObjectURL). Only the page's own scripts can make one, and it carries nothing in or out. Without it they were refused, and the page's console said so (the first run of the web checks under the policy: img-src on blob:, from index.js).
  • WS_ORIGIN in connect-src: the proxy in front (Vercel) carries no sockets, so on reelwright.diy the session's socket goes straight to wss://reelwright-app.fly.dev, another origin. Without a WS_ORIGIN the socket is the page's own origin and connect-src is 'self'.

Not needed, so not there: no other origin for anything, no blob: script or data: (the microphone's worklet is a file beside the page, rw-mic-worklet.js, not a blob), no inline event handlers. Links Studio opens elsewhere (Anthropic's console, the docs) are new tabs, which the policy does not govern. The service's own pages (sign-in, the console, the MCP pages) keep their own policy, pageCsp in html.ts: scripts by a nonce of each response, no eval. tools/web/dev-server.ts sends Studio's policy too, so every web check runs under it. Proved by core/test/host-server.test.ts ("runs the web Studio under a policy of its own origin…", "lets web Studio reach its socket on the WS_ORIGIN…") and tools/web/csp-check.ts: Studio runs to Episodes and the booth's microphone with no violation, with its socket on a WS_ORIGIN of its own, and a fetch, beacon, WebSocket, picture, script and inline script put on the page for another site are each refused, and nothing reaches that site.

Security review: Claude keys

Threat How it is prevented Proof
The key in a log It is never put in a log line: the check's errors are Anthropic's words with the key (and anything else shaped like one) cut out, the audit log records claude.connected/claude.disconnected with no detail, and the SDK client that checks keys logs nothing. The daemons' output, which the service writes to its log, has anything shaped like a key replaced (redactKeys), so even a daemon that printed its key would not get it into the log. host-claude.test.ts "has the key in no answer and no log line" (a daemon that prints its key, a check that throws it); claude-check.ts finds no line with the key or [redacted]
The key in an answer or an error message No route returns it: /api/claude and the key routes say "sk-ant-…" and its last four; refusals (not_a_key, subscription_token, the grant's and the secret's parse errors) quote nothing they were given. the same test collects every answer and its headers
Another person's socket, or another person in the show The socket is piped only to a daemon of a show the person is in (the cookie's, or a one-time ticket bound to the person and the daemon), and the service, not the browser, says who is on it: the daemon's token never leaves the service, which puts it and as in the hello, and the daemon trusts as only with its token. A daemon holds the keys of the people who joined it, and each call uses the key of the person whose request made it; a person removed from the show is forgotten there. No protocol method returns a key. "each person's calls carry their own key, and no other": one real daemon, three people, each one's model calls carry their own key, one with none has no chat though the owner has a key, and one removed has none; "says the hello for the browser"; the ticket tests
/api/file Keys are only in reelwright.db, sealed, outside <data>/users/<id>/; /api/file serves a file only when its real path is inside the person's own folder; the account folder is outside it too. the file route's containment tests
A daemon's environment, read by another daemon No key is in any environment: it goes on the daemon's own pipe and stays in its memory. The environment all daemons share holds none of the service's secrets (KEY_ENCRYPTION_KEY is gone from the service's own environment before any daemon starts), and a daemon on the service never uses a key from the machine. /proc/<pid>/environ of each daemon holds no key (host-claude.test.ts), nor the key or KEY_ENCRYPTION_KEY on the real service (claude-check.ts); the machine's ANTHROPIC_API_KEY and key file are never sent
CSRF on the key routes (the settings form) The session cookie is SameSite=Lax; a write must be JSON, carry X-Reelwright-Studio: 1 (another site's page cannot send it without a CORS preflight the service never answers) and come with no other site's Origin and no Sec-Fetch-Site but same-origin; DELETE gets the same check (the service's own Origin check covers POST only). The console's cap form is for owners only, behind the same Origin check and its Sec-Fetch-Site check. "the key routes and another site's page" (no header, a foreign Origin, cross-site and same-site fetches, a form, text/plain, DELETE); the cap form from another site
An owner reading keys No route or page returns a key: the console shows whether one is connected and since when, and what was spent; the database holds them sealed; the secret is only in the service's process. (The machine's operator, with the volume and the secret, could decrypt them: that is who runs the service.) the console tests find "connected" on the users list and a person's page, and not even the last four
A stolen database or volume backup Sealed keys do not open without KEY_ENCRYPTION_KEY, which is not on the volume; a row moved to another user does not open either. the sealing tests
Checking stolen keys through the service Only invited, signed-in people can ask, ten checks an hour each. "asks Anthropic at most ten times an hour"
A subscription token (Anthropic's terms) Refused by the API, by the daemon's supplyApiKey, and serve --claude-plan is refused on the service. "refuses a subscription token"; showrunner-account.test.ts
The ElevenLabs key (the cloud voice, docs/design/voice-provider.md) The same as the Anthropic key throughout: sealed under KEY_ENCRYPTION_KEY in its own table with its kind and user in the additional data (a sealed ElevenLabs key does not open as an Anthropic key, nor for another user), checked for free with ElevenLabs' subscription call within the same ten checks an hour, shown as "…" and its last four, given to the person's own daemons on their grant line only, kept in the daemon's memory, scrubbed from logs (sk_…-shaped), the same CSRF checks on its routes. host-voices.test.ts; settings-check.ts (the stand-in saw only the fake key, no log line holds it)

Found by this review and fixed since: every daemon runs as the service's one Unix user, and three of the daemon's tools read a path outside the show, so a person could have made their daemon read another person's files: import_asset (a picture by any path), screenplay_needs (episodes/../../…, which RelPath's pattern lets through; it answered the cues and headings of any file read as Fountain) and previews.get with kind: "sound" (a target such as sfx/../../… reached .flac files elsewhere). Now a sound is only ever named by a sound's id (isSoundId, ^(sfx|music)\.[a-z0-9_]{1,48}$), refused as an unknown document whether or not such a file exists; a screenplay is read only inside the show (insideRoot, through symlinks); and on the service (REELWRIGHT_HOSTED) import_asset takes only a file inside the show, while the desktop still imports from anywhere on the person's computer (core/test/containment.test.ts, "on the web service imports only a file inside the show"). None of them could reach a person's key, which is in no file and no environment. A sandbox per user is still wanted before open sign-up. The service's own secrets (KEY_ENCRYPTION_KEY, RESEND_API_KEY) are in its process's environment, as Fly secrets are, where only a process of the same user could read them.

Changing a setting from the shell

The lead (or anyone with the machine) changes a setting without the console's sign-in:

fly ssh console -a reelwright-app -C "node /app/reelwright.cjs host settings get access --data /data"
fly ssh console -a reelwright-app -C "node /app/reelwright.cjs host settings set access.signups open --data /data --by lead"
fly ssh console -a reelwright-app -C "node /app/reelwright.cjs host settings set access.minFreeGb 6 --data /data --by lead"

get [section] prints the settings in force as JSON (every section, or one). `set

. ` changes one field through `Settings.update`, the console's own way: the same bounds (a refusal says why, in the console's words, and changes nothing), the audit log's `settings.
` with what changed and how (`signups "invite"→"open" (from the command line, by lead)`; `--by` with an account's address makes that account the actor). A number's `none` is no limit; a switch takes `on` or `off`. The running service reads the change at once: each look at the settings asks SQLite whether another connection wrote the database (`PRAGMA data_version`), and when one did it reads them again and applies what changed, as if an owner had saved it in the console (the warm daemons' limits, the spending ceiling, Claude on or off).

Room for sign-ups

The free tier lets each account fill 1 GB of the one volume every show lives on (20 GB, at /data). So while the volume has less free space than the owners keep (Settings › Who can join › Free space kept, access.minFreeGb, 4 GB unless they change it; empty keeps none), open sign-ups are held: a code asked for an address with no account joins the waitlist instead of making a free account, and the answer says where it stands (403 signups_held, with state, position and waiting). No code goes out and no account is made, not even with a code asked for before. Accounts already there sign in as always, an owner's invitation or an invitation to a show still lets its address in, and invite-only, the waitlist and closed are as they were.

  • The sign-in page says why, in the visitor's language: "New accounts are paused while we make room. Leave your address on the waitlist and we'll invite you as soon as there's room. Already have an account? Sign in as usual." Its foot offers the waitlist, and a sign-up there shows the visitor their place in line.
  • The console says why across every page while it lasts: how much is free and how much is kept, with links to Access and the waitlist; Settings › Who can join shows the free space now.
  • The owners get one email each (Reelwright: new sign-ups are going to the waitlist), the first time sign-ups are held and again only after they were let go. The audit log keeps each turn (service.signups_held, with what was free, and service.signups_resumed); the service's minute sweep and every held sign-up note it.
  • To let sign-ups through again, make room (delete old renders or shows, or let people in from the waitlist as room allows), grow the volume (fly volumes extend <id> -s 40 -a reelwright-app), or lower the free space kept. The page and the console follow at once.

The free space is read where it is asked, from a reader the service is given (room.ts volumeFree, the volume's statfs, on the service; a number in tests): what decides reads no disk and no clock.

Build and deploy (Fly.io)

tools/host/build.ts makes out/host/context: the core bundled into one script (app/reelwright.cjs, esbuild, as the release bundles it), its resources at the repository's paths (VERSION, schemas, the whole engine project without its tests, the toolchain pins, the Kokoro project, the demo show), the web export with .br and .gz twins, and a Dockerfile generated from tools/toolchain.json: Ubuntu 24.04 with Mesa's lavapipe, Xvfb and fonts, Node (the version the core was bundled with), Godot 4.7.2, and BtbN's ffmpeg and Rhubarb 1.14.0 in a release's tools/ layout, and the pinned uv, each checked against its pinned checksum. That uv installs the Python the voice projects ask for (3.12) at /opt/reelwright/python, and makes their environments from their lock files, Kokoro's and the WORLD singer's, at /opt/reelwright/venvs (REELWRIGHT_VENVS, which the core reads). At run time uv is offline (UV_OFFLINE=1, UV_PYTHON_DOWNLOADS=never): the one thing a voice fetches is the Kokoro model, once, onto the volume. Before, the image had no uv or Python: the core fetched uv, and uv a Python and some 80 packages, onto the volume the first time; the volume's first environment linked to a Python under /root, which the next image did not have, so the next voice fetched them all again. The image imports the engine project once at build time and checks Godot starts on lavapipe, compiles the demo's episode, and in a cache it throws away (tools/host/voice-check.sh) fetches Kokoro's files as a machine does when it starts, voices one of the demo's lines said anew with every network route closed, and fetches again, which must fetch nothing. An image that cannot compile, or voice a new line with nothing but those files, never ships. Last, it draws the pictures a new account's first visit asks for, the demo's stills and its poster's line-up, then every picture its Cast, Sets and World grids show, with its own engine kept for all of them, into /app/examples/demo-pictures (core/src/host/demo-pictures.ts), where each copy of the demo the service makes takes them (the desktop's copies take the first two, kept in the repository, the same pictures, and draw their grids' on their own GPU); an image whose engine cannot draw never ships either. That adds about two minutes of lavapipe to the build, once, instead of to each new account's first visits.

tools/host/deploy.sh                          # build, then fly deploy --remote-only
tools/host/deploy.sh --web-studio <dir>       # with a web export from elsewhere

No local Docker is needed: Fly's remote builder builds the image. One-time setup, done for reelwright-app:

fly apps create reelwright-app --org personal
fly volumes create reelwright_data --region iad --size 20 -a reelwright-app
# from stdin, so no secret is on a command line:
printf 'RESEND_API_KEY=%s\nADMIN_EMAILS=%s\nRESEND_FROM=%s\n' … | fly secrets import -a reelwright-app
printf 'KEY_ENCRYPTION_KEY=%s\n' "$(openssl rand -base64 32)" | fly secrets import -a reelwright-app

Keep a copy of KEY_ENCRYPTION_KEY somewhere safe: without it the sealed keys cannot be opened, and everyone has to connect theirs again.

tools/host/fly.toml: region iad (Ashburn, Virginia), one performance-2x machine (2 dedicated CPUs, 4 GB), always on, health check on /healthz, the volume at /data, and the address settings, which are not secret: PUBLIC_URL=https://reelwright.diy/app, BASE_PATH=/app, WS_ORIGIN=wss://reelwright-app.fly.dev (a secret of the same name would override them, so PUBLIC_URL is no longer one). Deploy the service before the site: the site's deploy checks /app/login and /app/healthz through reelwright.diy.

Check a deploy with tools/host/check-live.ts: it signs in with a cookie (from host session on a throwaway admin), opens the session, says session.hello over /ws, renders Otto's model sheet with previews.get and fetches the PNG through /api/file.

Cost

At Fly's list prices: the performance-2x machine running all month is about $62, the 20 GB volume $3, and traffic $0.02 per GB in North America and Europe, so about $65 a month before traffic. Renders are CPU-bound on lavapipe: a model sheet takes about 13 s on this machine; a whole episode takes minutes. A bigger machine (performance-4x, 8 GB, about $124) halves render times. Stopping the machine when idle would cut the cost but drops running daemons.

Not yet

  • Isolation between users is by folder and by the file route, not by OS user: every daemon runs as the service's user, and the daemon's own tools can read paths a show names. Fine for invited people; a sandbox per user (separate Unix users, or a machine each) comes before open sign-up.
  • One machine. Daemons and renders share its CPUs, and the volume pins the service to one machine. Its renders draw one at a time in one line across everyone, each told its place, and render workers (off until its owners allow some) can draw a render's chunks on other machines (docs/design/render-capacity.md).
  • Kokoro voices work with the image's uv, Python and packages, and its model and voices are fetched onto the volume as the machine starts when they are not there (start.sh runs reelwright voices prepare beside the service and logs how long it took), so no person waits for them. On a replica of the image (2026-10-14) its 56 files, 354 MB, took 36 to 51 s on an empty volume and nothing on the next start; a first voice that had to fetch the model itself took 24 to 28 s, where one with the files there takes 10 to 13. Render workers voice nothing, so they fetch nothing.
  • No account self-service: names are set by an admin, and there is no sign-up.