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¶
-
The lock serialises; it does not merge. Every action verb takes an exclusive
flockon the project directory for the duration of the call. Two agents therefore TAKE TURNS onclips.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. -
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/sharedandvideo/src/componentslive under each agent's ownVIDEO_ROOTand are built into that agent's own preview bundle byagentctl-video-preview@<agent>— so four agents rendering the SAMEclips.jsonproduce 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_componentsreports that case underdivergence; read it before you trust two agents' exports to match. -
Render jobs stay per-agent, and that is correct. The host spool under
/var/lib/agentctl/render/<agent>/is0600 agent:agentby design (internal/cli/render.go), andrender-run.shrefuses 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@(installsvideo/node_modules),agentctl-video-preview@+ its.path(watches all three component rungs —video/src/shared,video/src/componentsandshared/_components— and builds the cockpit bundle) andagentctl-video-ingest@+ its.path(watchesingest-queue/, runsserver.py --drain-ingest). NONE of them enumeratesprojects/, so asharedsymlink besideprojects/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 readstate: runningfor 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.