Sharing a workspace folder between agents¶
Give two or more agents on the same host a folder they both read and write — notes, files,
a shared project, anything — without a dedicated storage service. Every command runs as
root. No agentctl verb does any of this.
When this is the right tool¶
Every agent's ~/workspace/ is just a directory owned by that agent's Linux user. On a
single-host deployment — every participating agent is a Linux user on the same box —
you can hand two of them a common folder with nothing more than a Unix group, a shared
directory, and a symlink. No MCP, no external service, no extra moving part.
It stops being the right tool the moment either of these is true:
- The fleet spans more than one host. A symlink only reaches other paths on the same filesystem; it cannot span hosts. See Multi-host fleet.
- The shared thing needs to survive a host being wiped or replaced, or you need
versioning/audit history on it. A plain directory has neither — losing the host loses the
folder. Reach for a dedicated shared-storage MCP instead: a shared Google Drive folder via
the
google-workspaceMCP (MCPs), a network filesystem, S3, or similar. Those survive a host swap and work across hosts; this pattern does neither.
This page's pattern is the general filesystem mechanism. The worked
example below applies it to one concrete
case — a video-editing project shared between agents that have the video MCP wired — which
carries a few extra rules of its own on top of the plain case.
Variables¶
NAME=team-notes # name the shared folder gets inside each agent's workspace
AGENTS="aidil paqai noor" # every agent that gets access
GROUP=team-notes # dedicated group, created in step 1
SHARED=/home/shared/team-notes # shared directory, on the same filesystem as every agent's home — not inside any of them
Prerequisites¶
| Requirement | Check |
|---|---|
Root shell on the host running every agent in $AGENTS |
id -u returns 0 |
setfacl installed |
command -v setfacl |
$SHARED and each agent's home on one filesystem |
df --output=source /home |
The pattern¶
1. Create the group¶
groupadd "$GROUP"
for a in $AGENTS; do gpasswd -a "$a" "$GROUP"; done
2. Create the shared directory¶
install -d -m 2775 -g "$GROUP" "$SHARED" # setgid: entries below inherit $GROUP
setfacl -d -m g::rwx "$SHARED" # default ACL: new files 0664, new dirs 0775
3. Symlink it into each agent's workspace¶
for a in $AGENTS; do
ln -sfn "$SHARED" "/home/$a/workspace/$NAME"
chown -h "$a:$a" "/home/$a/workspace/$NAME"
done
4. Restart the agents¶
for a in $AGENTS; do systemctl restart "agent@$a"; done
Step 4 is required
A process's supplementary group list is fixed at the moment it starts (systemd calls
initgroups once, at spawn — this is a Linux fact, not an agentctl one, and it applies
to every runtime agent@<name>.service (packaging/payload/units/agent/agent@.service)
starts, pi and claude alike). Until an agent restarts it does not carry $GROUP, and
every write into $SHARED from its own tools fails with PermissionError.
Verify¶
Checks 1–2 are reads. Check 3 writes a probe file per agent directly (as that agent's own process, not through the cockpit). Check 4 deliberately provokes a write refusal.
# 1. the live process carries the group — id -nG only re-reads /etc/group
kgid="$(getent group "$GROUP" | cut -d: -f3)"
for a in $AGENTS; 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
# 2. the pointer resolves, per agent
for a in $AGENTS; do
printf '%s: ' "$a"; sudo -u "$a" readlink -f "/home/$a/workspace/$NAME"
done
# 3. each agent can write and read the other's file — same rename-over-destination pattern
# the cockpit's own writer uses, run here as the AGENT's own process (not through agentd)
for a in $AGENTS; do
printf '%s: ' "$a"
sudo -u "$a" sh -c "cd $SHARED && printf 'hi from %s\n' \"\$USER\" > .probe.\$\$ && mv .probe.\$\$ $a.txt" \
&& echo wrote || echo failed
done
ls -la "$SHARED"
# 4. the COCKPIT's own write path refuses inside the shared folder — expected, see below
set -- $AGENTS
agentctl ws write "$1" "$NAME/probe.txt" <<<"hi"
| Check | Expected | If not |
|---|---|---|
| 1 | yes for every agent |
Restart the named agent (step 4) |
| 2 | $SHARED for every agent |
Re-run step 3 |
| 3 | wrote for every agent, and every agent's file present in ls |
group not yet carried (check 1) or a permissions/ACL mistake in step 2 |
| 4 | ws write: path escapes workspace (symlink) — shared/overlay files are read-only here |
If it instead succeeds, $NAME is not actually a symlink out of the workspace — re-check step 3 |
Limits and gotchas¶
| Caveat | Why it matters | What to do |
|---|---|---|
| No write-conflict protection at all | Two agents' own tool calls (chat-driven edits, scripts) writing the same file at the same moment: last write wins, silently, no lock, no merge, no error | Different files per agent, or coordinate who writes what and when |
| The cockpit's own Save/new-file/rename/delete refuse the whole folder | ws write/mkdir/rm/mv — the verbs behind the cockpit's file browser and editor — resolve a workspace symlink for READS only (internal/fleet/workspace.go); any write reached through $NAME is refused with path escapes workspace (symlink) |
Files are read/browsed fine from the cockpit; they are created and edited by the agent's own tool calls (a chat turn that has it write/edit the file), which run as the agent's own Linux process and are gated only by the Unix permissions from step 2 — not by the cockpit's write seam |
ws search / the zk notebook never reaches in |
zk index treats a symlinked directory as an unindexable non-.md entry and skips it outright — confirmed live (zk index -v logs ignored linked: expected extension "md"); the per-agent agentctl-zk-index@ cron hits the same wall. No flag changes this — zk index --help has none for it |
Notes saved inside $SHARED never appear in zk list / ws search for either agent. Don't put anything there you expect the notebook search to find |
| Only the outermost symlink is honored | The read seam only follows the FIRST symlinked path component under the workspace root; a second symlink further inside $SHARED still fails to resolve |
Keep $SHARED a plain directory tree below the one symlink — don't nest another symlink inside it |
| Nothing in agentctl backs it up | $SHARED sits outside every agent's home, so nothing that scopes a backup to an agent's own tree will reach it |
Point your own host backup solution at $SHARED directly, if you use one |
agentctl doctor / agents validate don't look here |
Neither reads workspace contents; a broken or missing symlink raises no flag | The Verify section above is the check — nothing else runs it for you |
Reverse it¶
for a in $AGENTS; do systemctl stop "agent@$a"; done
for a in $AGENTS; do rm -f "/home/$a/workspace/$NAME"; done # pointers first
rmdir "$SHARED" # refuses if anything is left in it — move or delete the contents first
groupdel "$GROUP"
for a in $AGENTS; do systemctl restart "agent@$a"; done
Never rm -rf the shared root
rmdir refuses a non-empty directory on purpose. A refusal means there is still content
in $SHARED — go look at it before deciding whether to keep or discard it.
Worked example: sharing a video-editing project¶
The same three-part pattern — group, setgid shared directory with a default ACL, symlink
into each agent's workspace, restart — turns one video-editing project into a directory
several agents with the video MCP wired edit together. This is the scenario that
motivated this whole page; the only difference from the generic case above is that the
symlink has a name the video MCP itself recognizes (shared, directly under each agent's
video-editing/ folder) and that the MCP's own writes are serialized by a lock the plain
case does not have.
Variables¶
PROJECT=15-muskuloese-koerper # project id to share
OWNER=noor # agent that holds the project today
AGENTS="aidil paqai kambai noor" # every agent that gets access
GROUP=keshmesh # dedicated group, created in step 1
SHARED=/home/video-shared # shared root, on the /home array — not on /
How a shared project resolves¶
<agent>/workspace/video-editing/shared is a symlink to a directory of project folders, and
it is the whole configuration surface. The video MCP enumerates both paths into one flat
namespace (list_projects, in packaging/payload/mcp/video/server.py):
| Path | location in list_projects |
|---|---|
~/workspace/video-editing/projects/<id> |
projects |
~/workspace/video-editing/shared/<id> |
shared |
Steps¶
- Create the group and shared directory — same as steps 1–2 above, with
$GROUPand$SHAREDset to the video-specific values. - Move the existing project into it, and relabel it, instead of creating an empty folder:
systemctl stop "agent@$OWNER"
mv "/home/$OWNER/workspace/video-editing/projects/$PROJECT" "$SHARED/"
chgrp -R "$GROUP" "$SHARED/$PROJECT"
chmod -R g+rwX "$SHARED/$PROJECT" # capital X: directories only, never clips.json
find "$SHARED/$PROJECT" -type d -exec chmod g+s {} +
mv keeps the moved tree's existing inode, owner and mode — it inherits nothing from
$SHARED automatically, which is why the relabel above is a separate, explicit step.
- Symlink
$SHAREDin asshared(the fixed name the video MCP looks for) under each agent'svideo-editing/folder:
for a in $AGENTS; do
install -d -o "$a" -g "$a" -m 755 "/home/$a/workspace/video-editing"
ln -sfn "$SHARED" "/home/$a/workspace/video-editing/shared"
chown -h "$a:$a" "/home/$a/workspace/video-editing/shared"
done
- Restart the agents — same as step 4 above; the same "group list is fixed at spawn" rule applies identically here.
Verify¶
Checks 1–2 from the generic Verify section apply unchanged (group carried, symlink resolves). Two more are specific to the video MCP:
# every agent can write clips.json — same rename-over-destination the MCP's own writer uses
for a in $AGENTS; do
printf '%s: ' "$a"
sudo -u "$a" sh -c "cd $SHARED/$PROJECT &&
cp -p clips.json .probe.\$\$ && mv .probe.\$\$ clips.json" \
&& echo writable || echo unwritable
done
stat -c '%a %U:%G' "$SHARED/$PROJECT/clips.json"
# the MCP 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
| Check | Expected | If not |
|---|---|---|
stat |
mode 664, group $GROUP |
Re-run the write check; a rewrite keeps a file's existing mode |
list_projects |
one row, "location": "shared", shared_root = $SHARED |
"projects" means a private copy shadows it — read shadowed |
Limits and gotchas specific to the video MCP¶
These are on top of the generic ones above — the MCP's own writes bypass the cockpit's write seam entirely (it is a separate process, writing straight to disk as the agent's own user), so the "cockpit refuses writes through a symlink" caveat does not apply to it; these do instead:
| Caveat | Why it matters | What to do |
|---|---|---|
The flock serializes the MCP's own writes; it never merges |
Two agents editing the SAME clip: the second writer's save overwrites what the first read, with no conflict detection — this is a lock against corruption, not against clobbering | Different clips per agent, or one at a time |
| An agent's own component outranks the shared library | One clips.json, different videos render, no error anywhere |
Keep divergence empty in list_components |
| Render jobs stay per-agent | Agent B cannot read or cancel agent A's render | Read progress as the agent that started it |
| A private project of the same id shadows the shared one | The shared row leaves list_projects |
Rename one; shadowed names the id |
agents and render already hold every agent on the host |
Either grants the project to the whole fleet | Use a dedicated group (step 1) |
Three systemd units hardcode /home/%i/workspace/video-editing |
Moving VIDEO_ROOT breaks deps, preview and ingest |
Share the project directory only |
+t forbids rename() over another user's file |
That is exactly how an atomic write lands | Never set the sticky bit on $SHARED |
Sharing the component library (optional)¶
install -d -m 2775 -g "$GROUP" "$SHARED/_components"
setfacl -d -m g::rwx "$SHARED/_components"
printf 'export {};\n' > "$SHARED/_components/index.ts"
chgrp "$GROUP" "$SHARED/_components/index.ts"
chmod 664 "$SHARED/_components/index.ts"
Add a component as <Name>.tsx, export it from index.ts, and ship <Name>.props.json
beside it for the cockpit props form. agentctl-video-preview@<agent>.path watches the
directory and rebuilds every agent's bundle per edit.
| Rung | Path | Lifecycle |
|---|---|---|
| shipped | video/src/shared |
overwritten on every agentctl upgrade |
| project | <shared>/_components |
present only where the shared/ pointer is |
| user | video/src/components |
this agent's own; wins every name it defines |
_components cannot collide with a project: an id must start with an alphanumeric. A
.tsx file that index.ts does not export renders the red "no such component" box.
# per agent that renders the project: divergence must be empty
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
# force a rebuild with no edit
for a in $AGENTS; do systemctl start "agentctl-video-preview@$a.service"; done
precedence of ["shipped","user"] means that agent does not see the shared library —
re-check the symlink step. shared_project_library names the directory it resolved.
Reverse it¶
for a in $AGENTS; do systemctl stop "agent@$a"; done
for a in $AGENTS; do rm -f "/home/$a/workspace/video-editing/shared"; done # pointers first
mv "$SHARED/$PROJECT" "/home/$OWNER/workspace/video-editing/projects/"
chown -R "$OWNER:$OWNER" "/home/$OWNER/workspace/video-editing/projects/$PROJECT"
chmod -R g-w,o-rwx "/home/$OWNER/workspace/video-editing/projects/$PROJECT"
find "/home/$OWNER/workspace/video-editing/projects/$PROJECT" -type d -exec chmod g-s {} +
for a in $AGENTS; do systemctl restart "agent@$a"; done
Once nothing else is shared:
rmdir "$SHARED"
groupdel "$GROUP"
Never rm -rf the shared root
rmdir refuses a non-empty directory. A refusal means a second shared project is still
in there.