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'sshowgainsroleandowner.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.getnow answers the file'shash, the editor sends it back asbasewithwrite_screenplayanddirect_episode, and a save over a newer text is refused withscreenplay.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").