Skip to content

Workspaces

A workspace is a directory with an agent attached, plus everything that hangs off that pairing: chats, files, changes, history, settings, and triggers. It is the unit Paddock actually operates on.

The instance itself is a workspace — the projects root, with its own agent. A project is a workspace nested inside it. So “project” is not the general case any more; it is the special case with a non-empty name.

<projectsRoot>/ ← the ROOT workspace (agent cwd = this directory)
├── paddock/ ← a project = a workspace nested inside the root
├── herdctl/ ← another
└── notes.md ← the root's own files

Everything a project can do, the root can do, and by the same code — see one plugin, two mounts below for why that is a structural guarantee rather than a promise.

A workspace is identified by a workspace key — its path relative to the projects root. A project’s key is its slug (paddock). Nested workspaces are just longer relative paths (repo/sub); nothing in the model assumes a single segment.

That makes the root’s key the empty string, and this is the load-bearing design choice of the whole model. The empty string is not a reserved name or a sentinel — it is the zero value already sitting in the key space, the correct relative path from the projects root to itself. Because path.join(root, "") === root, resolution needs no special case at all:

function dirFor(root: string, key: string): string {
return path.join(root, key); // the root falls out; there is no branch
}

An earlier design modelled the root as a project holding a reserved slug (__root), which forced every resolver to branch on it. That branch got duplicated, one copy was missed, and every root file route 404’d. With a relative-path key there is no branch to forget — the bug class is unrepresentable, not merely fixed.

An empty string cannot ride in a URL path segment: /api/projects//chats does not match any route. Rather than smuggle a sentinel back onto the wire, Paddock registers the workspace-scoped routes as one Fastify plugin, mounted twice:

MountWorkspace key
/api/root""
/api/projects/:slugparams.slug

Handlers were left untouched by the split — they still read req.params.slug. The root mount has no :slug segment to match, so it adds an onRequest hook that injects slug: "". onRequest is the earliest lifecycle hook and, in particular, runs before params validation, so the existing required: ["slug"] schemas keep validating unchanged on a path that has no such segment.

The result is the point: /api/root/chats and /api/projects/paddock/chats are the same handler, the same schema, and the same error paths. Parity between the root and a project is true by construction, not by discipline — a route that exists on only one of the two mounts is a bug in the mount, not a missing feature.

There is nothing to create and nothing to enable. The root workspace has no project.yaml gate, no creation endpoint, and no “enable” card — a fresh instance serves GET /api/root with a 200 on the first boot. Its metadata is derived (the name defaults to the projects-root directory’s basename), and a record is written to disk lazily, only once you change a setting.

Its agent and sweeper are registered at boot like any workspace’s, and its transcripts are gitignored like any workspace’s.

herdctl agent names must be non-empty, so the empty key genuinely cannot be represented in that namespace. It is encoded there — and only there — as _root:

WorkspaceAgentSweeper agent
the rootkeeper-_rootsweeper-_root
paddockkeeper-paddocksweeper-paddock

That is one leading underscore. The same encoding applies to hook-_root-<name> and trigger-_root-<name>. It can never collide with a project, because project slugs are lowercase alphanumerics and dashes — the slug pattern rejects underscores outright, so no slug can ever equal _root.

The keeper- prefix is legacy in the same way: it predates the retirement of “keeper” as a concept, and stays because that literal string is persisted in job records, state.yaml and the session directories. Read it as an opaque prefix, like _root itself.

This is a name, not an identity. A workspace is identified by its key everywhere else; the encoding is applied in a single function at the herdctl boundary, which keeps all four agent-name builders uniform with no root branch of their own.

A project agent’s working directory is its own project directory, so its file surface, its Changes pane, and its Bash calls are confined to that subtree.

The root agent’s working directory contains every project. It can read and edit any project’s files, and its Changes tab is the whole backing repo — which is exactly the intent: the root is where you commit across the instance and do cross-project work. But it is a real step up in reach from a project agent, and worth knowing before you hand a root chat a broad instruction.

The file surface applies its usual guard at the root: paths are resolved and refused if they escape the workspace directory, or if they traverse through any dot-prefixed directory segment. So .chats/ and .git/ stay unreachable through the files API at the root just as they are inside a project.

/ is the root workspace’s Home — the instance’s front door, no redirect and no sticky last tab. The root carries the full workspace tab bar, with each tab at a top-level URL:

TabRoot URLProject URL
Home//projects/:slug/home
Chat/chat/projects/:slug/chat
Files/files/projects/:slug/files
Changes/changes/projects/:slug/changes
History/history/projects/:slug/history
Settings/settings/projects/:slug/settings
Triggers/triggers/projects/:slug/triggers

Changes appears only when the workspace directory is a git repo. There is no Projects tab: the projects grid is a section of root Home, and old /projects links still land on / rather than an error screen.

That section is gated on being the root, not on having any projects — a root workspace with zero projects still renders it, showing the grid’s empty state.

Two things stay instance-wide rather than workspace-scoped. /settings at the root edits the root workspace’s own project.yaml, exactly like a project’s Settings tab does; instance-wide admin config lives separately at /config, because its lifecycle is different — it is frozen at boot, so every save is restart-required. See Environment variables for what that file holds.

  • Projects — the nested case: notebook vs. repo-backed, and what a project directory contains.
  • Agents — the agent attached to each workspace.
  • Chats are sessions — what lives inside a workspace’s Chat tab.
  • API reference — the workspace-scoped routes, at both mounts.