Skip to content

Agents

Every chat in Paddock is run by a Claude Code agent registered with herdctl’s FleetManager. There are three kinds:

  • keeper-<slug> — the per-project agent that runs Claude in the project’s working directory, with the project’s full toolset. This is the one you chat with.
  • trigger-<slug>-<name> — a scoped agent per event hook or capability-scoped schedule, holding just that trigger’s granted tools. You do interact with these: replying inside a trigger’s chat runs your turn at that agent’s capability, not the project’s — which is what the chat’s capability banner is there to say. (A schedule with no run.tools allow-list runs on the keeper instead.)
  • sweeper-<slug> — the internal per-project curator, which you never chat with directly.

Every workspace has an agent, including the instance root — a chat that belongs to no particular project is simply a chat of the root workspace, run by an ordinary agent with the full toolset: the self-management MCP, curation, triggers, attachments and run history.

Each project has one long-lived agent, and its working directory is the project’s workingDir — an external directory you nominated with path, else the nested checkout if the project has a repo, else the project directory itself. Because Claude Code keys transcripts by working directory, that cwd is what ties a project’s chats to that project.

  • Registered programmatically at startup and on project create/update via HerdctlService.ensureProjectAgent() (fleet.addAgent(config, { replace: true }) — no yaml round-trip). See keeperAgentConfig() in herdctl.ts.
  • Runs the project’s default model (project.model ?? DEFAULT_MODEL, Opus by default) and honors the project’s permissionMode, maxTurns, and driveMode.
  • Allows up to KEEPER_MAX_CONCURRENT (10) concurrent chats, so several chats — and forked children — of the same project can run in parallel.
  • Can receive the self-management MCP tools (env-gated).

Because it is one shared agent per project, a per-chat model override is applied by re-registering that agent (ensureAgentModel) — last-write-wins across concurrent chats of the same project. Acceptable for single-user; a clean per-trigger override is a herdctl follow-up.

The root project’s agent is named keeper-_root — the root workspace’s key is the empty string, which the herdctl agent namespace cannot represent, so _root is substituted at that boundary (SLUG_RE rejects underscores, so no project can collide with it). Its working directory is projectsRoot — the directory that contains every project. It is an ordinary agent in every mechanical respect, but worth calling out plainly: its cwd contains every project, so a root chat can read and edit any project’s files, and root’s git status is the whole backing repo. That is the intent — the root is where you act across the instance — but it is a real escalation over a project agent, which is confined to its own subtree.

Its chats live at /chat and — under the default transcripts: own — in <projectsRoot>/.chats/. Under transcripts: host they live in the user’s own ~/.claude/projects/<encoded-cwd>/ instead.

A chat that turns out to matter can be promoted into a project of its own, re-homing it under that project’s agent. HerdctlService.promoteSession(sessionId, from, to) (herdctl.ts, wired at POST /api/projects/:slug/chats/:sessionId/promote):

  1. Quiesces the chat’s in-flight turn (#731). Promote moves the transcript, so mid-turn it used to lose the chat from both projects. The turn is now cancelled and its death verified first; if that can’t be confirmed within 10s the route returns 409 { code: "turn_running" }. This happens before the project is created, so a refusal leaves no orphan project behind. A success reports cancelledTurn so the UI can say what it stopped.
  2. Moves the transcript from the source project’s .chats/ into the new project’s .chats/, preserving mtime.
  3. Rewrites the embedded cwd token in the JSONL to the new project’s workingDir — which is the nested checkout for a project with a repo, or the external directory for one linked with path. (Resume does not depend on this: Claude Code keys resume on where the transcript is, not on its recorded cwd. The rewrite keeps the file honest about itself.)
  4. Evicts the source agent’s in-process session state (deleteSession(keeper-<from>, sessionId)) so a same-process resume works, and drops the source keeper’s agent-level session pointer (#730) — left dangling it would break the next resume of any other chat in the source project.
  5. Re-attributes the session to keeper-<to> and invalidates both agents’ discovery caches so the chat immediately shows under the new project.

The UI offers this on root chats — the ones that belong nowhere in particular, which is exactly the population promotion was invented for. The server route is generic.

A related operation, forkSession, copies a session (minting a new session id) rather than moving it — see Chats.