Contents

M12 Sharing a show on the service

Status: design, 2026-10-05. Comes after the web edition (docs/design/m12-web-service.md) and each person's own Claude key (its "Claude on the web"; core/src/host/keys.ts, grant.ts).

A show on reelwright.diy/app belongs to the person who made it. A showrunner works with others: a co-writer who edits, a producer or a client who watches and reads. This note says how one show is shared with other accounts on the same service, who may do what, whose Claude key pays for what, and how two people in one show see the same thing.

People and roles

  • Owner: the person whose workspace holds the show (<data>/users/<owner>/shows/<id>/). Only the owner shares, changes roles and removes people. An admin who is in the show (shared with them) may do the same, for support; an admin who is not in it gets the same 404 as anyone (no route reaches a show nobody shared with you).
  • Editor: everything Studio does in the show (write, direct, edit, voice, render, chat) except sharing.
  • Viewer: reads and plays: the dashboard, scripts, the timeline, stills, renders, history, memory. Nothing that writes the show, spends money or starts a job (no render, no voicing, no chat, no Write with Claude, no direct).

A show is shared with an existing account, by its email. The service's accounts stay admin-made (core/src/host/accounts.ts): an email with no account is answered "No one uses Reelwright with that email here yet; an admin can invite them". Names are optional on the service, so wherever a person is named to others (the people list, presence, History, git) the name is their account's name, else the part of their email before the @.

Storage

store.ts gains a table shares (owner_id, show_id, user_id, role, added_by, added_at), the role editor or viewer, one row per (owner, show, person), both users REFERENCES users ON DELETE CASCADE: deleting a person, or the owner, removes their shares. Every share, role change and removal is audited (show.shared, show.role_changed, show.unshared, the target the show's key, the detail the person and the role). host/sharing.ts (Sharing) holds the rules over the store and the workspace: it resolves keys, lists what a person may open, and adds, changes and removes people.

Naming a shared show

Show ids are per workspace. The API names a show by a show key: its id for the person's own shows (unchanged, so nothing about existing links or last_show moves) and <ownerId>~<showId> for a show shared with them (~ is never in a show id or a user id; <their own id>~<showId> is read as their own show). Sharing.resolve(user, key) answers the folder, the owner and the person's role, or throws a 404 (never a 403: a show you are not in does not exist for you). Every route that takes a show id takes a key, and a user's last_show holds the key they opened last; when it no longer resolves (they were removed), /api/session falls back to their newest own show, as it does for a show deleted.

API

  • GET /api/shows: the person's own shows, newest first, then those shared with them, each { id (key), title, open, role, owner: { id, name } }.
  • POST /api/shows/<key>/open, GET /api/session: as now, for any key the person may open; the answer's show gains role and owner.
  • GET /api/shows/<key>/people: { people: [{ id, name, email?, role }], you: { role, manage } }, the owner first. Every member may read it; emails are only in the answer for the owner and admins.
  • POST /api/shows/<key>/people { email, role } (201), PATCH …/people/<userId> { role }, DELETE …/people/<userId>: the owner and admins in the show only (403 for an editor or a viewer, 404 for anyone not in it). The owner is not in the shares, so cannot be removed or re-roled. A role change closes that person's sockets to the show with 1012, so their Studio says hello again and is told the new role. A removal closes them with 4403 and the reason "Ada stopped sharing this show with you", forgets their grant in the show's daemon, and takes the show out of their warm ones; Studio says so and goes to their own last show.
  • GET /api/file: a file inside the person's own folder or inside a show shared with them, read-only, as now.

One daemon per show

Today the pool (DaemonPool, daemons.ts) keeps daemons per user. Two people in one show must see one state: the pool keeps one daemon per show (owner + show id). Each user still has a list of the shows they opened, the current one first (perUser of them, 2); a daemon runs while some user has it in their list, and stops when nobody does, when it has been idle 30 minutes, or when it is the least recently used daemon that is nobody's current show beyond maxWarm. A render started by one member keeps running when they leave while it is in another's list, or warm as now. stop(userId) (an account disabled or deleted) stops the daemons of the shows they own and takes them out of everyone else's. One start at a time per show, so two people opening it at once share one daemon.

Who is on a socket

The browser no longer receives the daemon's token: /api/session answers token: "service". The host's pipe (#pipe in server.ts) sits between the browser and the daemon: it reads the browser's first frame, which must be one session.hello (anything else closes the socket with 4001), and forwards it with token set to the daemon's token and as: { id, name, role } set to the person the host signed in and their role in this show, whatever the browser put there. The daemon trusts as only in a hello that carries its token, which only the host has. A later hello from the browser carries no good token, so the daemon closes that socket (as it closes any wrong token) and as cannot be changed on a connection. A desktop Studio sends no as and is the owner, as now. The upgrade checks the person's role in the show again, so a ticket handed out before a removal does not open.

What a role may do

The method table moves out of session/protocol.ts, where it was kept by hand, into the session schema: session.schema.json gains x-methods, one entry per method with its params and result $defs and its access: read, write or tool (tools.call, whose tool's own kind decides: a read tool reads, a propose tool writes). The TypeScript codegen writes it into generated/session.ts as sessionMethods, which protocol.ts serves. Write means anything that writes the show, spends money or starts a job: chat, assist, changes' decisions and locks, renders, voicing, takes, memory's edits and passes. A daemon refuses a write from a viewer with session.read_only ("You can view this show: ask its owner to make you an editor"), before the method runs. Studio is told its role in session.hello's result (role), says "View only" under the show's status and hides or disables what would be refused (New…, Import script…, Direct, Render, Voice, Record, the chat's composer, Write with Claude, script editing and the reading's fixes, decisions on proposals and memory updates, locks, the timeline's edits), but the daemon's refusal is the guarantee.

Whose key

A model call is paid by the person who asks. The grant pipe (grant.ts, fd 3) stays open for the daemon's life: the host writes one line per person, { userId, accountDir, anthropicKey? }, each time they join the show's daemon (before their hello goes on), and { userId, forget: true } when they are removed. Connecting, replacing or removing a key no longer stops the person's daemons, which other people may be working in: the host writes their new grant to every daemon they are in and closes their sockets there (1012), so their Studio says hello again with the right capabilities.

In the daemon, each request runs as the person on its connection (session/person.ts, an AsyncLocalStorage), and everything a request starts (a chat turn, Write with Claude, the model director, memory's pass) runs as them too. On the service AnthropicProvider takes the key for each call from that person's grant, never one fixed when the daemon started, and CostMeter charges that person's account folder and holds them to its cap. A person with no key has no chat and no Write with Claude, even when the owner has a key. A call made as nobody (none is, on the service) has no key. The hello waits up to three seconds for the person's grant line, which goes on a different pipe from the socket. One chat turn at a time per show, as now; the thread is the show's.

Who changed what

A change set's author gains who: { kind: "user", id, name } for a person's own change, { kind: "showrunner", id, name } for one the showrunner made, naming who asked it. The session's ChangeSummary carries it as by: { id, name }. History and a proposal say the name ("by Ada", "by the showrunner, for Ada"), and Studio's Ctrl+Z undoes only the person's own changes, never another's. The show's git commits carry that person as author, with <user id>@users.noreply.reelwright.diy for an address (the owner's configured identity stays the committer), so no email leaves the host. On the desktop nothing changes: no person, no name, the repository's own identity.

Two people at once

  • Presence: the daemon sends session.people (who is connected, with their role) to everyone in the show when someone says hello or leaves; Studio shows their initials by the show's name. A desktop daemon, whose connections name nobody, never sends it.
  • Same script: the script editor already reads a screenplay again when it changes on disk, but with unsaved text it only said so, and Save then wrote over the change. screenplays.get now answers the file's hash, the editor sends it back as base with write_screenplay and direct_episode, and a save over a newer text is refused with screenplay.changed, naming who changed it and when. The editor keeps the person's text; saying Save again writes it over the other's knowingly. Other documents already go through change sets, whose base hashes refuse stale writes.

Studio

  • The web show menu lists "Shared with you" under the person's own shows, each with its owner's name.
  • People… in the show menu (and the palette, and the show's own settings: World, Show) opens the show's people: the owner and admins see Add by email with a role, a role to change on each person and Remove; members see the list only.
  • A viewer's Studio says "View only" on a line under the show's status, and its writing controls are hidden or disabled.
  • A person removed while in the show is told "Ada stopped sharing this show with you" and goes to their own last show.
  • Presence: the initials of the others in the show, on that same line.

Proof

Core tests for the store and Sharing (keys, resolution, audit), every route's role rules (404 for strangers, 403 for members on owner-only routes), the pool per show, the hello rewrite (a browser's own as and token are replaced; a first frame that is not a hello closes the socket; a removed person's ticket does not open), the daemon's read-only refusal per method kind, per-person grants (each person's model calls carry their own key; an editor with no key has no chat even when the owner has one), authorship and git authors, presence and the stale save. Engine tests with RwFakeHttp and RwFakeDaemon for the menu, People, view-only Studio, presence and the removal notice. A live proof on a real host: the owner shares with an editor and a viewer, the editor's edit is seen by the owner, the viewer is refused a writing method, and removing the viewer cuts their socket.

Not yet

  • A turn in the shared chat naming who asked it; Write with Claude's suggestion events go to everyone in the show (each Studio keeps only its own).

Since

M13 (docs/design/m13-shared-shows.md, "So far") changed what this note describes: people join by accepting an invitation (the add by email above is now one, and someone with no account can be invited, to make one on accepting), a reviewer role sits between editor and viewer, anyone but the owner may leave, the owner may give the show to an editor or delete it, and a removal's reason names the show ("Ada stopped sharing Tea Time with you").