Skip to content

Sharing one video project between several agents

An operator runbook. It turns one video project into a document four agents on this host edit together, and it turns it back. Every command runs as root on the host. Nothing here is automated: agentctl does not create the group, does not move the project and does not make the symlink, on purpose — a shared project is a decision with a blast radius, and the verbs that would do it are the verbs an agent can call.

Read THE THREE THINGS THIS DOES NOT GIVE YOU before you run any of it.

What the code already does

The video MCP enumerates and resolves projects from TWO places and treats them as one flat namespace:

<VIDEO_ROOT>/projects/<id>      the agent's own projects        location: "projects"
<VIDEO_ROOT>/shared/<id>        projects reached through the pointer   location: "shared"

<VIDEO_ROOT>/shared is a SYMLINK to a directory that holds project folders. It is the whole configuration surface. list_projects returns both sets, each row carries location, and the envelope carries shared_root. Every other verb resolves a project id through the same resolver, so a write lands wherever the project really lives and nothing copies a shared project into projects/.

WITH NO SYMLINK, OR A DANGLING ONE, EVERY VERB BEHAVES EXACTLY AS BEFORE. That is asserted by the test suite, not asserted by this document.

A NAME IN BOTH PLACES RESOLVES TO THE PRIVATE ONE. The private project shadows the shared one, the shared row is not listed, and list_projects names the id in shadowed. Rename one of the two; do not leave it.

VIDEO_ROOT STAYS AT ~/workspace/video-editing FOR EVERY AGENT and this design depends on it. state/selection.json, inbox/, ingest-queue/, the per-agent Remotion package under video/, and the three systemd units that hardcode /home/%i/workspace/video-editing (agentctl-video-deps@, agentctl-video-preview@, agentctl-video-ingest@ + its .path) all stay private and keep working untouched. Do not move VIDEO_ROOT to the shared directory.

THE THREE THINGS THIS DOES NOT GIVE YOU

  1. The lock serialises; it does not merge. Every action verb takes an exclusive flock on the project directory for the duration of the call. Two agents therefore TAKE TURNS on clips.json — they do not edit it in parallel. Whoever writes second writes over the document it read at the start of its own call. The lock removes the interleaved read-modify-write that loses rows; it does not give you a three-way merge, and there is no conflict detection. Two agents must still work on different clips, or one at a time.

  2. A component library CAN be shared, and an agent's own still wins it. See Sharing the components too below. Without that directory, video/src/shared and video/src/components live under each agent's own VIDEO_ROOT and are built into that agent's own preview bundle by agentctl-video-preview@<agent> — so four agents rendering the SAME clips.json produce four different videos if their libraries have drifted. With it, they share one set of components; but an agent that has written its own component under the same name still draws ITS OWN. list_components reports that case under divergence; read it before you trust two agents' exports to match.

  3. Render jobs stay per-agent, and that is correct. The host spool under /var/lib/agentctl/render/<agent>/ is 0600 agent:agent by design (internal/cli/render.go), and render-run.sh refuses a uid mismatch. Agent B cannot read agent A's render progress and cannot cancel agent A's render. renders/ INSIDE the shared project is shared, so the output files land where everyone can see them.

Setting it up

The example moves 15-muskuloese-koerper out of noor's home and shares it with aidil, paqai, kambai and noor. Substitute your own project id and agent list.

1. The group

groupadd keshmesh
for a in aidil paqai kambai noor; do gpasswd -a "$a" keshmesh; done

A NEW GROUP, NOT agents (1015) AND NOT render (109). All four agents are already in both of those — and so are eleven agents that must not reach this project. render exists for /dev/dri access and agents is the whole fleet; using either as the sharing group grants the project to everyone who ever needed a GPU.

2. The directory

install -d -m 2775 -g keshmesh /home/video-shared
setfacl -d -m g::rwx /home/video-shared

ON /home, NOT ON /. /home is the 37 TB md0 array; / is a 117 GB NVMe with ~22 GB free. Footage belongs on the array — and because the shared directory and every agent's projects/ are then on ONE filesystem, step 3's mv is a rename: instant, atomic, and it cannot half-copy 40 GB of camera card.

THE SETGID BIT (2 in 2775) makes every directory created underneath inherit group keshmesh instead of the creating agent's own primary group. Each agent's primary group is itself (noor:noor, aidil:aidil), so without setgid, agent A's new work/scratch/ directory would be unreachable by agent B.

THE DEFAULT ACL IS THE OTHER HALF, and it is not decoration. setgid fixes the GROUP; the umask fixes the MODE, and every agent runs with umask 022, so a file A creates would be 0644 — group-readable, not group-writable. A POSIX default ACL is applied INSTEAD of the umask for anything created under the directory, so g::rwx gives new files 0664 and new directories 0775 with no fleet-wide umask change.

MEASURED, and the reason the MCP needed a code change anyway: tempfile.mkstemp passes mode 0600 to open() explicitly, and a default ACL is INTERSECTED with the mode the caller asks for. So the ACL alone does not repair the atomic-write path — it produced a 0600 file under a directory carrying g::rwx. _write_json no longer uses mkstemp.

AND work/ IS MAINTAINED BY THE MCP, NOT BY THIS SETUP, for the same reason one level up. The default ACL above applies to entries CREATED under the directory it is set on — and a project MOVED in by step 3 creates nothing, so it carries no default ACL at all and step 3's hand relabel is the only thing that ever touched it. MEASURED on 16-haeusliche-gewalt 2026-08-18: work/ at drwxrwsr-x and asr/, diar/, frames/, idcheck/, scratch/ underneath it all at drwxr-sr-x — setgid propagated the GROUP to every one of them and never the write bit, which it cannot. A second agent's render died with PermissionError: [Errno 13] Permission denied creating work/scratch/<job>. So every directory the MCP creates inside a project's work/ is chmod'd to 2775 after creation (_ensure_dir), whole chain, whatever the umask says. sources/ and renders/ are NOT — their story is the paragraph above.

DO NOT SET THE STICKY BIT (+t). An atomic write is rename() over the destination, which unlinks another agent's file — and that is exactly what +t forbids.

3. Move the project

systemctl stop agent@noor
mv /home/noor/workspace/video-editing/projects/15-muskuloese-koerper /home/video-shared/

Stop the OWNING agent first. A mv under a live agent's feet is a project that disappears mid-turn.

chgrp -R keshmesh /home/video-shared/15-muskuloese-koerper
chmod -R g+rwX    /home/video-shared/15-muskuloese-koerper
find /home/video-shared/15-muskuloese-koerper -type d -exec chmod g+s {} +

ALL THREE ARE NEEDED AND THE mv IS WHY. On one filesystem mv is rename(2): it keeps the inode, so it keeps noor:noor and 0755/0644. setgid and the default ACL on the parent apply to things CREATED under it, never to something moved in. The existing tree has to be relabelled by hand, once.

g+rwX — capital X — sets the execute bit on directories only, never on clips.json.

4. The pointer, once per agent

for a in aidil paqai kambai noor; do
  install -d -o "$a" -g "$a" -m 755 "/home/$a/workspace/video-editing"
  ln -sfn /home/video-shared "/home/$a/workspace/video-editing/shared"
  chown -h "$a:$a" "/home/$a/workspace/video-editing/shared"
done

ln -sfn, so re-running this is not an error and never nests a second link inside the first.

5. Restart the agents

for a in aidil paqai kambai noor; do systemctl restart "agent@$a"; done

NOT OPTIONAL, AND IT IS THE STEP THAT GETS FORGOTTEN. Supplementary groups are resolved when a process is spawned. A running agent has the group list it had before gpasswd, so until it restarts it sees keshmesh nowhere — and the symptom is a PermissionError on a project the group list says it owns.

Sharing the components too

One mkdir inside the directory you already made. It is optional: a shared project with no shared component library behaves exactly as it did before, and an install with no shared pointer never sees this at all.

install -d -m 2775 -g keshmesh /home/video-shared/_components
setfacl -d -m g::rwx /home/video-shared/_components
printf 'export {};\n' > /home/video-shared/_components/index.ts
chgrp keshmesh /home/video-shared/_components/index.ts
chmod 664      /home/video-shared/_components/index.ts

_components, WITH THE LEADING UNDERSCORE, AND THE UNDERSCORE IS LOAD-BEARING. This directory is a sibling of the project folders in the same flat namespace, and a project id must start with an alphanumeric — so this name cannot be a project. list_projects skips it, create_project refuses it and every verb's resolver refuses it, with no extra rule.

index.ts IS THE BARREL AND IT IS NOT OPTIONAL. A component is registered by being exported from it, exactly as in video/src/components. A .tsx file that is not exported there is listed by list_components and renders the red "no such component" box.

Write a component as <Name>.tsx beside it, export it from index.ts, and ship <Name>.props.json next to it so the cockpit can render a props form. Each agent's preview bundle then rebuilds ITSELF: agentctl-video-preview@<agent>.path watches this directory, so one edit here repaints all four bundles with no command at all.

BEFORE 2026-08-06 THAT WAS NOT TRUE and this passage told you to start the service by hand. The .path unit watched only video/src/shared — agentctl's own rung, the one the deb overwrites — so neither this directory nor an agent's own video/src/components triggered anything. Both are watched now.

Start it by hand only to force a rebuild with no edit — a recovery step, not the normal path:

for a in aidil paqai kambai noor; do systemctl start "agentctl-video-preview@$a.service"; done

THE PRECEDENCE, AND THE ONE THING TO WATCH

Three rungs, and the LAST one to carry a name wins it:

video/src/shared         agentctl's        overwritten on every upgrade
<shared>/_components     the projects'     only where the shared/ pointer is
video/src/components     THIS AGENT'S      never touched, and it WINS

An agent's own component beats the shared project's. That is deliberate: nothing arriving from outside an agent's own tree may silently restyle a video that agent already made — which is the same rule that already stops an agentctl release from doing it.

THE PRICE IS DIVERGENCE, AND IT IS THE ONE THING TO WATCH. If one agent has its own TitleCard and the shared library also defines one, that agent renders the shared project differently from the other three, off the same clips.json, with no error anywhere. list_components names exactly those components in divergence, with both file paths and the one to delete:

sudo -u aidil python3 /usr/share/agentctl/mcp/video/server.py <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_components","arguments":{}}}
EOF

divergence must be empty on every agent that renders this project. precedence in the same envelope says which rungs that agent has (["shipped","user"] means it is not seeing the shared library — check step 4's pointer), and shared_project_library names the directory it resolved.

Verify it worked

Every check below is a READ. Run them in order; each one fails differently.

# 1. the group really carries all four
getent group keshmesh

# 2. each agent's RUNNING process has it — NOT just /etc/group. Supplementary groups
#    are resolved at spawn, so an agent that has not restarted since step 1 reads the
#    group in /etc/group and does not carry it. `Groups:` in /proc/<pid>/status is the
#    process's real list; `id -nG` only re-reads the file and would answer yes either
#    way. This is the check that catches a forgotten step 5.
kgid="$(getent group keshmesh | cut -d: -f3)"
for a in aidil paqai kambai noor; do
  pid="$(pgrep -u "$a" -f 'claude|agentctl' | head -1)"
  printf '%s (pid %s): ' "$a" "${pid:-none}"
  grep -q "^Groups:.*\b${kgid}\b" "/proc/$pid/status" 2>/dev/null \
    && echo yes || echo 'NO — restart agent@'"$a"
done

# 3. the pointer resolves, per agent
for a in aidil paqai kambai noor; do
  printf '%s: ' "$a"; sudo -u "$a" readlink -f "/home/$a/workspace/video-editing/shared"
done

# 4. every agent can READ the edit decision list
for a in aidil paqai kambai noor; do
  printf '%s: ' "$a"
  sudo -u "$a" head -c 1 /home/video-shared/15-muskuloese-koerper/clips.json >/dev/null \
    && echo readable || echo UNREADABLE
done

# 5. every agent can WRITE it — the real test, done without changing the document.
#    An atomic write is rename-over-destination, so this needs write on the DIRECTORY
#    as well as on the file, which is the permission `chmod -R g+rwX` above grants.
for a in aidil paqai kambai noor; do
  printf '%s: ' "$a"
  sudo -u "$a" sh -c 'cd /home/video-shared/15-muskuloese-koerper &&
    cp -p clips.json .probe.$$ && mv .probe.$$ clips.json' \
    && echo writable || echo UNWRITABLE
done
stat -c 'after the probe: %a %U:%G' /home/video-shared/15-muskuloese-koerper/clips.json

Probe 5 proves the PERMISSION, not the MCP: cp -p + mv is the same rename-over- destination shape _write_json uses, so passing it means the group really can replace the file. The stat line then proves step 3's relabel: it must read 664 and group keshmesh. If it reads 644 or 600, chmod -R g+rwX did not reach this file, and no later write will repair it — _write_json KEEPS a document's existing mode on a rewrite, so a document that starts wrong stays wrong. Re-run step 3.

# 6. the MCP itself sees it, from an agent that does NOT own the directory
sudo -u aidil python3 /usr/share/agentctl/mcp/video/server.py <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}
EOF

The project must appear once, with "location": "shared", and shared_root must name /home/video-shared in the envelope. If it appears with "location": "projects" a private copy is shadowing it — check shadowed in the same envelope.

Reversing it

for a in aidil paqai kambai noor; do systemctl stop "agent@$a"; done
for a in aidil paqai kambai noor; do rm -f "/home/$a/workspace/video-editing/shared"; done

REMOVE THE POINTERS FIRST. With the symlinks gone every agent is back to its private projects/ and the code path is the one every single-agent install has always run — so the rest of the rollback happens with nothing reading the tree.

mv /home/video-shared/15-muskuloese-koerper /home/noor/workspace/video-editing/projects/
chown -R noor:noor /home/noor/workspace/video-editing/projects/15-muskuloese-koerper
chmod -R g-w,o-rwx /home/noor/workspace/video-editing/projects/15-muskuloese-koerper
find /home/noor/workspace/video-editing/projects/15-muskuloese-koerper -type d -exec chmod g-s {} +
for a in aidil paqai kambai noor; do systemctl restart "agent@$a"; done

Optional, once nothing else is shared:

rmdir /home/video-shared        # refuses if anything is still in there — that is the point
groupdel keshmesh

rm -rf HAS NO PLACE IN THIS ROLLBACK. rmdir fails loudly on a non-empty directory, and a non-empty /home/video-shared means a second shared project you have forgotten about.

The render-backend seam

Where a render actually runs is one seam, deliberately thin. A backend is exactly one script (packaging/payload/scripts/render-backend-<name>.sh) plus one name in the renderBackends registry (internal/cli/render.go). It takes the job-file path as argv[1] and signals through its exit code. Today the registry holds only local, and --backend <name> is validated at launch, so an unknown backend fails immediately rather than at minute six.

Everything above the seam is backend-independent: render-run.sh owns the queue lock, the state machine and the terminal notification. --no-notify writes "notify": false onto the job record — not the environment, which a transient unit inherits nothing from — and agentctl adds it automatically when AGENTCTL_GATE=1 is set in the client's environment, so gate runs never message a human.

What this design deliberately does not do

  • It is not a verb. No MCP tool creates the group, moves a project or makes the symlink. Sharing a project is an operator decision with a blast radius, and the agent that would call the verb is one of the agents the project would be shared with.
  • It does not move VIDEO_ROOT. Confirmed against the three units that hardcode /home/%i/workspace/video-editing: agentctl-video-deps@ (installs video/node_modules), agentctl-video-preview@ + its .path (watches all three component rungs — video/src/shared, video/src/components and shared/_components — and builds the cockpit bundle) and agentctl-video-ingest@ + its .path (watches ingest-queue/, runs server.py --drain-ingest). NONE of them enumerates projects/, so a shared symlink beside projects/ is invisible to all three and every one keeps working unchanged. A spooled ingest INTO a shared project also works: the queue stays private, the worker resolves the project through the same resolver, and it writes as its own agent.
  • It has no staleness reaper for the project-side job records. Separate finding, not fixed here: one job under <project>/renders/jobs/ on ep-15 has read state: running for 46 hours with no process alive and nobody holding the host render lock. The Go host spool resolves this from the job's mtime (renderJobMtime / resolveRenderState); the MCP's project-side spool has nothing equivalent. Sharing a project makes the stale record visible to four agents instead of one, so it is worth fixing — elsewhere.