Skip to content

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-workspace MCP (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
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

  1. Create the group and shared directory — same as steps 1–2 above, with $GROUP and $SHARED set to the video-specific values.
  2. 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.

  1. Symlink $SHARED in as shared (the fixed name the video MCP looks for) under each agent's video-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
  1. 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.