Skip to content

herdctl and Paddock

Paddock is not a Claude Code wrapper. It is a project layer over a fleet orchestrator — and that orchestrator, herdctl, is a separate project with its own CLI, its own docs site, its own npm packages, and users who have never heard of Paddock.

Understanding where one ends and the other begins explains a lot about Paddock: why a chat is a resumable session, why a project maps to exactly one agent, why schedules survive a restart, and why some things you might expect Paddock to own are not in this repository at all.

The one-sentence version: herdctl runs Claude Code agents; Paddock gives them projects, a browser, and a memory.


herdctl’s own pitch is “let Claude Code invoke itself.” Claude Code is enormously capable, but almost everything it does is triggered by a human sitting at a terminal. herdctl is the orchestration layer that removes the human from the trigger: agents are declared in YAML, and they fire on schedules, on webhooks, or on chat messages.

Crucially, it is not a sandbox and not a model wrapper. An agent gets the same tools, the same MCP servers, the same CLAUDE.md, and the same slash commands it would have if you ran claude in that directory yourself. herdctl decides when a session runs and what it is pointed at — not what Claude can do once it starts.

Figure 1 — Anatomy of a herdctl fleet
Anatomy of a herdctl fleet Five surfaces can reach a fleet: the herdctl CLI, a Discord bot, Slack, the web dashboard, and a host application such as Paddock. They all talk to a single herdctl process, which holds one FleetManager providing one agent registry, one scheduler and one runner. The FleetManager runs several agents, each of which is a name, a working directory, a schedule and a tool allow-list. Each agent maps to a Claude Code session, stored as a resumable JSONL transcript. Ways to reach a fleet herdctl CLI start · trigger · logs Discord bot per agent Slack one app, routed Web dashboard @herdctl/web A host app Paddock lives here One herdctl process FleetManager one agent registry · one scheduler loop · one runner · durable state in .herdctl/ security-auditor working_directory: ~/herdctl schedules: cron 0 6 * * * allowed_tools: Read, Bash… runtime: sdk Wakes itself every morning. price-checker working_directory: ~/prices schedules: interval 4h hooks.after_run: discord runtime: cli Pings you only if something moved. …one per job to be done Agents are declared in YAML — or, when a host app is driving, registered in memory at runtime fleet.addAgent(config) This is the door Paddock walks through. Claude Code session resumable JSONL transcript Claude Code session resumable JSONL transcript …and one job record per run, in .herdctl/jobs/

A fleet is one process holding one registry, one scheduler and one runner. Each agent is a name, a working directory, a schedule and a tool allow-list — and maps onto a resumable Claude Code session. Every way in, including Paddock, drives the same FleetManager.

herdctl start boots one process that holds one FleetManager. That object owns the agent registry, a single polling scheduler loop, the runner that executes jobs, and durable state under .herdctl/. Everything else — the CLI, the Discord and Slack connectors, the web dashboard — is a thin client on top of it.

That layering is a deliberate architectural rule in herdctl: core never imports an interaction-layer package. @herdctl/core discovers @herdctl/discord, @herdctl/slack and @herdctl/web at runtime through dynamic imports, only if your config actually references them. Core builds and runs perfectly well with none of them installed.

That rule is precisely what makes Paddock possible. Paddock is simply one more client of FleetManager — one that happens to ship its own UI.

A minimal fleet is two files:

herdctl.yaml
version: 1
fleet:
name: my-fleet
agents:
- path: agents/security-auditor.yaml
agents/security-auditor.yaml
name: security-auditor
working_directory: ../../
system_prompt: |
You audit this codebase for security regressions.
schedules:
daily:
type: cron
cron: "0 6 * * *"
prompt: "Run /security-audit-daily"
allowed_tools: [Read, Grep, Bash, WebSearch]
runtime: sdk

Note the schedule’s prompt invoking a Claude Code slash command. Because the agent’s working directory is a real project, its .claude/ commands, its CLAUDE.md and its MCP servers all load exactly as they would interactively.

herdctl can execute an agent two ways, chosen per agent with runtime::

RuntimeHow it runsWhy you’d pick it
sdk (default)In-process, via the Claude Agent SDKStreaming, richer control, standard API pricing
cliSpawns a claude subprocessUses Claude Max plan pricing rather than API billing

Docker is not a third runtime — it is a decorator. Setting docker.enabled wraps whichever runtime you chose so the same execution happens inside a container. The privilege split is deliberate: an agent’s own YAML may set safe knobs like memory and CPU limits, but only fleet-level config can set the image, the network mode, or host volume mounts — because an agent that can edit its own config file must not be able to mount your filesystem.

A run produces a job record (.herdctl/jobs/job-*.yaml plus a .jsonl output log) and updates a session pointer (.herdctl/sessions/<agent>.json) tracking which Claude session that agent is on. The transcript itself belongs to Claude Code, under ~/.claude/projects/.

This is why herdctl sessions resume works, and it is the same property Paddock leans on to make a chat survive a server restart: the session is on disk, not in memory.


herdctl gives you agents, schedules and durable runs. What it does not give you is a place to work: no notion of a project, no browser UI, no live streaming transport, no per-user read state, no auth boundary, no git integration.

That is the whole of Paddock.

Figure 2 — The stack
The Paddock stack Four layers, top to bottom. Your browser runs the Paddock single-page app. It talks to the Paddock server, which owns HTTP routes, the WebSocket transport and session hub, the project and git layer, and auth plus in-process MCP tools. The Paddock server calls into the @herdctl/core FleetManager, which owns the agent registry, the scheduler, the runner and the session discovery and reaper. herdctl in turn drives Claude Code, either through the Claude Agent SDK or by spawning a claude CLI subprocess. Your browser Paddock SPA React + Vite — chats, files, changes, settings WebSocket /ws + REST Paddock server — @paddock/server (Fastify) HTTP + REST one plugin, mounted at /api/root + /api/projects Streaming transport ws.ts + SessionHub — buffer, replay, fan-out Projects + git ProjectStore, GitService, JSON sidecars Auth + MCP tools req.user boundary, self-MCP, send_file HerdctlService — the single seam @herdctl/core — FleetManager Agent registry addAgent / removeAgent, config validation Scheduler one polling loop — cron, interval, session wakes Runner + jobs SDK or CLI, optionally in Docker; cancel, fork Sessions discovery, resume, the reaper Claude Code Claude Agent SDK in-process — Paddock's default claude CLI subprocess spawned per job — Max-plan pricing
Paddock owns it herdctl owns it Claude Code owns it

Reading the stack top to bottom:

  • The SPA is a React app — chats, files, changes, settings. It talks to the server over REST for state and a single WebSocket for anything live.
  • The Paddock server is Fastify. It owns the workspace-scoped HTTP routes (one plugin, mounted twice — at /api/root and /api/projects/:slug — so the root workspace and a nested project provably run the same handlers), the streaming transport, the project and git layer, the auth boundary, and the in-process MCP tools it injects into agent turns.
  • @herdctl/core is where Paddock stops. Agent registry, scheduler, runner, sessions.
  • Claude Code actually does the work.

Paddock’s entire integration with herdctl lives in a single class, HerdctlService (packages/server/src/herdctl.ts). No other module in the server holds a FleetManager reference — every other file calls deps.herdctl.<method>().

That is a deliberate containment strategy, and it is unusually strictly observed: if herdctl’s API shifts, exactly one file changes.

Two facts about how Paddock registers agents are worth internalising, because they surprise people who know herdctl standalone:

Paddock’s herdctl.yaml contains zero agents. It is generated, marked do-not-edit, and holds only the fleet block and defaults. Every agent is registered in memory at boot and on demand:

await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });

Each project gets a small family of agents, named deterministically:

AgentPurpose
keeper-<slug>The project’s main agent. Its working directory is the project directory.
sweeper-<slug>Tool-less curator that updates OVERVIEW.md / CHANGELOG.md after a turn.
trigger-<slug>-<name>One per trigger; the trigger’s granted tools are the agent’s tool config.

A workspace is identified by its path relative to the projects root, which means the root workspace’s key is the empty string. That is elegant inside Paddock — path.join(root, "") is just root, so no code path needs a special case.

But herdctl agent names cannot be empty. So there is exactly one translation, at exactly one boundary:

export const ROOT_KEY = ""; // the root workspace
export const ROOT_AGENT_KEY = "_root";
export function agentKeyFor(key) {
return isRootKey(key) ? ROOT_AGENT_KEY : key;
}

The root workspace’s agent is therefore keeper-_root. Project slugs cannot contain underscores, so a collision is impossible by construction.


Figure 3 — One turn, end to end
One chat turn, end to end A sequence across four lifelines. The browser sends chat:send to the Paddock server. Paddock resolves the project, composes the prompt and builds its injected MCP servers, then calls openChatSession on the herdctl FleetManager. herdctl resumes the Claude Code session and drives the Agent SDK. Claude streams SDK messages back through herdctl to Paddock, which drops sub-agent sidechain steps, translates each message and stamps provenance, then emits sequence-stamped WebSocket frames to the browser. This middle part repeats per message. On the terminal result Paddock sends chat:complete with usage, and fires the after-turn sweep. Browser Paddock SPA Paddock server ws.ts · HerdctlService @herdctl/core FleetManager Claude Code Agent SDK 1 — chat:send { message, sessionId } 2 — resolve project · compose prompt (attachments, preload) · build MCP tools 3 — openChatSession(keeper-<slug>, { resume, prompt }) 4 — resume session, drive the SDK repeats per message 5 — SDKMessage stream 6 — RuntimeSession.messages (async iterable) 7 — drop sidechain steps · translate via @herdctl/chat · stamp provenance 8 — chat:response · tool_start · tool_call 9 — chat:complete { success, usage } 10 — afterTurn → the sweeper curates the project's notes

The middle block repeats for every message Claude emits. Paddock’s contribution is at steps 2 and 7 — composing the prompt on the way down, and filtering, translating and attributing on the way back up.

A few steps deserve expansion.

Step 2 — Paddock composes the prompt. What Claude receives is not exactly what you typed. Attachments are wrapped in a hint block pointing the Read tool at their stored paths, and a brand-new chat can be prefixed with the project’s OVERVIEW.md and CHANGELOG.md. Paddock also builds the MCP servers it will inject for this turn — send_file always, and the self-management tools when enabled.

Step 3 — two drive modes. Paddock can drive a turn two ways:

Modeherdctl callBehaviour
session (default)openChatSession()Persistent session; background tasks and scheduled wake-ups survive the turn boundary. Always uses the SDK runtime. Writes no job record.
batchtrigger()One-shot. Honours the agent’s configured runtime. Writes a job record.

Session mode writing no job record has a visible consequence: Paddock’s chat list and attribution are built from .herdctl/jobs/, so Paddock writes synthetic job records itself to keep a running chat visible while it is still streaming.

Step 7 — sub-agent steps get filtered. When a turn spawns foreground sub-agents, their internal steps arrive on the same stream, tagged as sidechain messages. If they are not dropped they render twice — once in the sub-agent’s card and once inline in the parent transcript. Paddock has five distinct live-turn code paths (a human send, a slash command, a scheduled wake, a background sink, and a spawned agent turn), and each one needs the guard independently; for a long time only one had it.

Step 8 — frames are sequence-stamped. Every frame goes through a session hub that stamps a monotonic sequence number, buffers recent frames, and fans out to every subscribed socket. This is what lets a turn survive its originating socket dying: reconnect, and the hub replays the gap.


Figure 4 — Who owns what on disk
Who owns what on disk Three storage areas. Paddock owns its data directory: a generated herdctl.yaml with no agents in it, the projects tree with each project's metadata and its dot-chats transcript folder, attachments, and JSON sidecars for read state, stars and provenance. herdctl owns its dot-herdctl state directory: state.yaml, job records, session pointers and session metadata. Claude Code owns the dot-claude directory in your home folder. The link between them is that Claude Code's per-working-directory transcript folder is a symlink pointing at the project's dot-chats directory, so Claude writes transcripts straight into the project. Paddock owns <dataDir>/ herdctl.yaml generated · contains zero agents projects/ CLAUDE.md <slug>/ project.yaml OVERVIEW.md CHANGELOG.md .chats/<id>.jsonl the real transcript store attachments/ read-state.json run-provenance.json …and the other JSON sidecars herdctl owns .herdctl/ state.yaml fleet + schedule state, session wakes jobs/job-*.yaml jobs/job-*.jsonl one record + output log per run sessions/<agent>.json which session an agent is on session-metadata/ docker-sessions/ Paddock reads jobs/ for attribution — and writes synthetic records into it, because chat sessions produce none. Claude Code owns ~/.claude/ projects/ <encoded-working-dir>/ This directory is not a directory. It is a symlink pointing back at the project's .chats/ folder — so Claude writes straight into it. The directory name is Claude Code's own lossy encoding of the working directory: /a/b/c → -a-b-c herdctl reproduces it byte-for-byte, then disambiguates by reading the cwd recorded inside each transcript. one store, three readers — nobody copies transcripts anywhere

Three parties, three storage areas — but only one transcript store, reached from two directions through a symlink.

The load-bearing trick is that bottom arrow.

Claude Code stores transcripts at ~/.claude/projects/<encoded-working-dir>/, where the directory name is its own lossy encoding of the working directory (every non-alphanumeric character becomes a dash — so /a/b/c, /a/b-c and /a-b/c all collapse to the same name). Paddock makes that directory a symlink to the project’s .chats/ folder.

The result is that Claude Code writes transcripts directly into the project directory, while herdctl’s session discovery — which knows nothing about Paddock — resolves, resumes, renames and deletes those same sessions transparently. Nobody copies anything.


Part 5 — Where the boundary actually runs

Section titled “Part 5 — Where the boundary actually runs”
ConcernOwner
Which agents exist, and their configPaddock decides; herdctl validates and holds the registry
Cron and interval firingherdctl — Paddock writes schedules in herdctl’s own schema and lets its scheduler read them
Choosing SDK vs CLI, spawning processes, Dockerherdctl
Session discovery, resume, transcript parsingherdctl
Job records, cancel, forkherdctl
Idle teardown, keeping a session alive for background workherdctl’s session reaper
Turning SDK messages into text/tool events@herdctl/chat
Projects, workspaces, the root workspacePaddock
HTTP, WebSocket, the frame protocol, replayPaddock
Auth, and the external Management APIPaddock
Unread, stars, archive, provenance, nested chatsPaddock
Prompt composition and MCP tool injectionPaddock
Transcript surgery — fork, revert, promotePaddock, by rewriting the JSONL directly

The dependency is not purely one-directional. herdctl exposes three inversion-of-control seams that let a host application take over behaviour that would otherwise run headless:

  • setScheduleTriggerHandler — Paddock takes over execution of scheduled turns so they land in a real chat you can watch, instead of running invisibly.
  • setSessionWakeHandler — when a ScheduleWakeup fires and herdctl’s reaper resumes the session, the live session is handed to Paddock to stream, even though no browser is connected.
  • setResolveInjectedMcpServers — lets Paddock re-supply its in-process MCP tools on a turn it did not initiate.

Several @herdctl/core APIs exist because Paddock needed them, and the core changelog says so explicitly: addAgent()/removeAgent() were added for hosts that manage agents in memory rather than on disk; openChatSession()’s manageLifecycle option is described as Paddock’s session drive-mode; and the spawned trigger type was added so a host could record provenance for agent-spawned jobs.

The relationship runs the other way too. Paddock is the largest embedding consumer of @herdctl/core, so it tends to find the sharp edges first — and several of the gaps documented in Paddock’s older integration notes have since been closed upstream.


  • Architecture overview — how Paddock’s own code fits together, cited to packages/server/src.
  • herdctl integration contract — the call-by-call API surface. Note that page carries a staleness warning: it was written against an older core and two of its headline findings have since been fixed upstream.
  • Concepts — what a project, chat, schedule or sweeper is, without the plumbing.
  • herdctl.dev — herdctl’s own documentation, including running it standalone with the CLI, Discord and Slack.