Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Agents, workspaces and projects

An agent is one thing to the person using it and a different thing on disk. This chapter covers both, then the directory layout the proxy writes, and finally the one place where the two harnesses deliberately diverge.

What an agent is to a member

When you sign in to the chat client you do not pick a model or a container. You pick an agentalpha, beta, whatever this deployment declares — and you get a conversation with it.

With no workspace selected, the chat area becomes a picker: one row per tenant, a box per subscription inside it, and the agents you can reach as tiles showing your permissions on each (an eye for read, a pencil for write). Clicking one opens a fresh conversation.

An agent is therefore a kind of assistant, shared in name across everyone who can reach it, and private in substance to each of them. Two members chatting with alpha are talking to the same persona, the same model configuration and the same administrator-published skills — in two different containers, with two different memories, two different file trees, and no path between them.

Your agent is keyed on your account id, not your email. Emails are mutable and are kept only as a human-readable marker for operators; change your email and your agent and its history stay yours.

What an agent is on disk

The proxy’s unit of isolation is a four-part key: tenant, subscription, role (the agent key) and user account id. That key names one directory:

<data root>/tenants/<tenant>/subscriptions/<subscription>/agents/<agent>/users/<user>/

Every component is sanitized before it becomes a path segment, so nothing in a request can grow a separator or a ... The directory is created lazily, on the member’s first chat — or up front for a whole subscription if the optional subscriptionAccount.created webhook is registered with mycelium.

That directory is what this book calls the user directory. It belongs to the proxy. Some of it is mounted into the agent’s container; some of it deliberately is not.

The layout

<user dir>/                       the proxy's, NOT necessarily mounted
├── config.json                   harness configuration, proxy-written
├── .security.yml                 picoclaw only
├── .projects.json                proxy-owned: which projects exist
├── .schedules.json               proxy-owned: scheduled tasks (ganglion)
├── .crab-owner.json              proxy-owned: who this workspace belongs to
├── .crab-model.json              proxy-owned: this member's model choice
├── .crab-mode.json               proxy-owned: this instance's lifecycle override
├── workspace/                    THE MAIN AGENT
│   ├── AGENT.md SOUL.md HEARTBEAT.md USER.md
│   ├── memory/  public/  sessions/  ...
└── workspace-<project-id>/       ONE PER PROJECT, A SIBLING OF workspace/
    ├── AGENT.md SOUL.md HEARTBEAT.md USER.md
    └── memory/  public/  sessions/  ...

Two rules make this layout worth learning rather than looking up.

Every harness lays its per-user directory out the same way. A path that means one thing under picoclaw means the same thing under any other harness this stack spawns. That is not a courtesy to picoclaw; it is what lets the proxy read and write these directories with one set of functions instead of one per harness. A per-harness branch that is missing does not fail loudly — it reads a directory that never existed and reports that the member has no history, no files and no scheduled tasks.

The dotfiles above workspace/ are the proxy’s, not the agent’s. They decide which project identities exist, which model is used, whether the container may be kept alive, and what is scheduled. An agent able to edit them could route a peer’s conversations to itself, pick the endpoint its own keys are sent to, or keep its own container running indefinitely. So they sit outside what the agent can reach — by two different mechanisms, depending on the harness.

.crab-owner.json is a small JSON marker recording the full workspace tuple and the owner’s email, so an operator can find which human a container belongs to. It is needed because the container’s name cannot carry that information: the full tuple is two UUIDs, which exceeds the 63-character DNS label limit.

A project workspace is a sibling

When a member creates a project, the proxy creates a second agent identity for them with its own workspace:

workspace-<project-id>       correct
workspace/projects/<id>      WRONG

workspace-<id> is a sibling of workspace/, never a child of it. The name is not a preference. picoclaw derives a named agent’s workspace as <defaults.workspace>/../workspace-<id> when the agent config does not set one, so its own tooling resolves that path independently of anything the proxy writes. The proxy’s WorkspaceSegment helper returns workspace for the empty project and workspace-<id> otherwise, and it does not branch on the harness.

It used to. The ganglion kept projects under the main workspace for one release, to spare itself a second bind, and the cost was that one function was really two pretending to be one — every caller that reached for a project’s sessions, its public directory or its media inherited the split, and a caller that forgot did not fail: it read a directory that never existed. The legacy child path survives only so cleanup code can find a subtree written before the migration. Nothing may create one.

Each project’s workspace has its own persona files, its own memory, its own sessions and its own public/ directory. A project’s history is not reachable by asking for the main workspace’s, which is the point: a project scopes the transcripts, the context window and the files. See Working with projects for the member-facing side.

What the two harnesses mount, and why it matters

The layout is shared. The bind set is not, and that difference is load-bearing rather than incidental.

picoclaw mounts the whole user directory. It has its own restrict_to_workspace setting, which keeps its agent inside workspace/, so the proxy’s own state can sit beside the workspace and still be out of reach. The proxy additionally binds a read-only .secrets view into each workspace, one per project as well as the main one, plus the persona cascade and the shared skills root.

The ganglion mounts each workspace separatelyworkspace/, and one bind per workspace-<id> — and nothing above them.

The reason is that the ganglion has no restrict_to_workspace, and could not usefully have one. Its only filesystem tool is /bin/sh -c <string>: there is no path argument to refuse, and any denylist of .. or /etc is defeated by $(echo L2V0Yw== | base64 -d). What confines it instead is a Landlock domain around each command, whose only read-write hierarchy is the turn’s workspace — plus the container boundary itself. So proxy-owned state has to be on the other side of the boundary rather than merely adjacent to it.

The first version of the ganglion path did use one wide bind, and the cost was concrete: it put the container’s own bearer token one level above the shell’s working directory, where cat ../.crab-ganglion.json read it.

The clearest illustration is .schedules.json. It is not a log — it is a standing instruction to the proxy to wake a container and run a turn, on a timer, forever. Inside a ganglion bind, a turn steered by untrusted text could write one. For picoclaw a store inside the workspace is harmless, because the agent writing it is the same process that would have to act on it. Here the agent and the actor are different processes.

Two consequences follow from the per-workspace binds:

  • The bind set changes when the project set does, and a bind set is fixed at container creation. So the proxy checks for drift on every ensure and recreates the container when the projects no longer match — otherwise a project created since the container started would be invisible inside it, and the turn would run against a directory the proxy never reads.
  • The administrator’s shared skills are mounted into every workspace, not only the main one, because the Landlock root is the turn’s workspace. A skills index pointing at files a project turn cannot open is worse than no index.

What a ganglion workspace contains

A ganglion workspace is seeded with memory/, public/, sessions/ and windows/. It is picoclaw’s set minus what only picoclaw has, plus one of its own:

  • no cron/ — the proxy holds the ganglion’s schedules, above the bind;
  • no .secrets/ — credentials reach this harness as environment variables, not as files;
  • windows/ is the ganglion’s own, holding each conversation’s context window on disk. It has no picoclaw equivalent.

USER.md is seeded only if the workspace does not already have one. It is the one persona file the agent writes back, so overwriting it on every ensure would erase what the agent learned about the member every time the container was recreated — which, for a scale-to-zero agent, is routinely.

Inside the container the data root is /data/.ganglion, and only its workspace children are bound. The credential key file and the model registry land beside that root, read-only, outside every workspace: the workspace is the only hierarchy a command can reach, so a writable model list would let a tool steered by untrusted text choose the endpoint the deployment’s own keys are sent to.

Where to go next

Skills and memory covers what lives inside memory/ and skills/; Files and delivery covers public/. Working with projects is the member’s view of the sibling workspaces described here.