herdctl integration contract
The public-npm API surface paddock depends on, verified against the installed
@herdctl/core@5.10.1(the public npm package — NOT the local symlink). Every claim here was checked against the shipped.d.tsdeclarations and proven by a real spike (packages/server/src/spike.ts, which typechecks and runs against the package, constructing + initializing a FleetManager and a SessionDiscoveryService).Pinned versions inspected:
@herdctl/core@5.10.1(used),@herdctl/web@0.9.10and@herdctl/chat@0.3.14(referenced for protocol shape only — see question f).
TL;DR verdict
Section titled “TL;DR verdict”| Need | Public API supports it? | Mechanism |
|---|---|---|
| Construct + run a fleet | ✅ Yes | new FleetManager({configPath, stateDir}) → initialize() → start() |
| Add an agent at runtime | ⚠️ Only via yaml + reload() | No addAgent(). Generate per-agent yaml files + a herdctl.yaml, then reload(). This works and is what paddock does. |
| Stream a prompt’s output | ✅ Yes | trigger(agent, undefined, {prompt, resume, onMessage}) |
| New vs resume session | ✅ Yes | resume: null (new) / resume: <id> (resume); final id on TriggerResult.sessionId |
| List sessions + messages | ✅ Yes | SessionDiscoveryService.getAgentSessions() / getSessionMessages() |
| FleetManager events | ✅ Yes | EventEmitter: job:output, job:completed, config:reloaded, … |
| Reuse a web/chat transport | ❌ No (in core) | Transport lives in @herdctl/web/@herdctl/chat, not core. Build our own (we did, in ws.ts). |
The integration is viable on the public package today. The only real constraint is dynamic agents (yaml+reload, not a programmatic registry) — which is acceptable because paddock owns the generated config dir.
Package shape
Section titled “Package shape”@herdctl/core@5.10.1 ships as ESM ("type": "module"), main: ./dist/index.js, types: ./dist/index.d.ts. There is no exports map —
just main + types. Everything is re-exported flat from the root, so a single
import works:
import { FleetManager, SessionDiscoveryService, type SDKMessage, type TriggerResult, type AgentInfo, type FleetStatus, type DiscoveredSession, type ChatMessage,} from "@herdctl/core";Runtime deps of core: @anthropic-ai/claude-agent-sdk, chokidar,
cron-parser, dockerode, dotenv, execa, yaml, zod.
a. Construct + initialize a FleetManager (minimal setup)
Section titled “a. Construct + initialize a FleetManager (minimal setup)”import { FleetManager } from "@herdctl/core";
const fleet = new FleetManager({ configPath: "/abs/path/to/herdctl.yaml", // file or dir; auto-discovers if omitted stateDir: "/abs/path/to/.herdctl", // created if missing // optional: logger, checkInterval (ms, default 1000), configOverrides});
await fleet.initialize(); // loads + validates config, preps state dirawait fleet.start(); // starts the scheduler (and chat connectors, if any)FleetManagerOptions (verified):
interface FleetManagerOptions { configPath?: string; // file, dir, or omitted (auto-discover up from cwd) stateDir: string; // required logger?: FleetManagerLogger; checkInterval?: number; // default 1000ms configOverrides?: FleetConfigOverrides; // only overrides fleet.web {enabled,port,host}}There is also initializeWebOnly({port?, host?}) — a zero-agent mode that serves
session data from ~/.claude/ without a herdctl.yaml. Paddock does not use it
(we always have at least the scratch agent), but it’s available.
Config-file requirements discovered the hard way (the spike caught these — a naive inline config 400s):
- The
fleetblock is strict: onlynameanddescriptionare allowed.log_levelis NOT a fleet field (it’s a chat/connector concern). - The
agentsarray accepts only path references —{ path: string, overrides?: object }. You cannot inline an agent definition inherdctl.yaml. Each agent must live in its own yaml file referenced by path. defaults(deep-merged into every agent) acceptsruntime,model,max_turns,permission_mode,allowed_tools,denied_tools,docker, etc.- An agent yaml requires only
name; everything else is optional and merged withdefaults.
Minimal working pair (what spike.ts writes):
version: 1fleet: { name: paddock-spike, description: spike fleet }agents: - path: /abs/scratch.agent.yamlname: scratchworking_directory: /abs/dirruntime: climax_turns: 3permission_mode: defaultsystem_prompt: "You are a spike agent…"allowed_tools: []b. CRITICAL — adding agents at runtime
Section titled “b. CRITICAL — adding agents at runtime”There is NO programmatic agent-registration API. The FleetManager class
exposes (verified from fleet-manager.d.ts): initialize, start, stop,
getFleetStatus, getAgentInfo, getAgentInfoByName, getSchedules,
enable/disableSchedule, reload, trigger, cancelJob, forkJob,
streamLogs/streamJobOutput/streamAgentLogs, plus getters. No
addAgent / registerAgent / removeAgent.
Agents come from the config file on disk. The supported way to add one at runtime is therefore:
- Write a new per-agent yaml file (
working_directory= the project dir). - Regenerate the
herdctl.yamlto reference it (path reference). - Call
await fleet.reload().
reload() (from config-reload.d.ts):
- Re-reads + re-validates the config from
configPath. - On validation failure, keeps the old config (fails gracefully).
- Running jobs keep their original config; new triggers use the new one.
- Updates the scheduler with new agents/schedules.
- Emits
config:reloadedwith aConfigChange[]diff (added/removed/modified × agent/schedule/defaults).
// paddock's HerdctlService.ensureProjectAgent()await regenerateConfigFiles(allProjects); // writes agents/<name>.yaml + herdctl.yamlconst payload = await fleet.reload(); // hot-reload; no restart// payload.changes => [{type:"added", category:"agent", name:"keeper-foo"}, ...]This is proven at runtime (paddock smoke test): creating a project writes
the files, calls reload(), and the new keeper-<slug> agent immediately shows
up in getFleetStatus().counts.totalAgents / getAgentInfo() while the fleet
keeps running.
Config-dir layout paddock owns (regenerated, never hand-edited):
<PADDOCK_DATA_DIR>/ herdctl.yaml # fleet + defaults + agent path refs agents/ scratch.yaml # one-off chats agent keeper-<slug>.yaml # one per project, working_directory = project dir .herdctl/ # state dir (state.yaml, jobs/, sessions/, …) scratch/ # scratch agent working dir projects/<slug>/ # project dirs (project.yaml, CHANGELOG.md, …)GAP (minor, app-managed): dynamic agents require file generation +
reload()rather than an in-memory call. Acceptable because paddock owns the config dir. A future herdctl enhancement (fleet.addAgent(resolvedAgent)) would remove the file round-trip — see Gap list.
c. Trigger + stream + sessions
Section titled “c. Trigger + stream + sessions”const result: TriggerResult = await fleet.trigger("keeper-foo", undefined, { prompt: "Summarize the current state of this project.", resume: null, // null = NEW session; <id> = resume; undefined = agent fallback triggerType: "web", // "discord"|"slack"|"web"|"manual" onJobCreated: (jobId) => { /* enable a stop button, etc. */ }, onMessage: (m: SDKMessage) => { if (m.session_id) currentSession = m.session_id; // session id arrives mid-stream if (m.type === "assistant" && typeof m.content === "string") { stream(m.content); // plain assistant text } // m.type can also be: system | stream_event | result | user // | tool_progress | auth_status | error | tool_use | tool_result },});
result.sessionId; // final SDK session id (trust only when result.success === true)result.jobId; // job idresult.success; // booleanresult.error; // Error | undefinedTriggerOptions (verified) also has: workItems, bypassConcurrencyLimit,
injectedMcpServers (runtime MCP tool injection), systemPromptAppend
(per-trigger system-prompt suffix — used by chat connectors for “be concise on
Discord”-style hints).
SDKMessage is a wide struct (runner/types.d.ts): type, subtype?,
content?, session_id?, name?, input?, tool_use_id?, tool_name?,
tool_use_result?, message?, event?, result?, success?, code?, plus
[key: string]: unknown. Assistant text is either m.content (string) or
nested text blocks in m.message.content[] (paddock’s ws.ts handles both).
New vs resume vs continue:
- New chat →
resume: null. - Resume a specific session →
resume: "<sessionId>". resume: undefined→ agent-level session fallback (for CLI/schedule use).
d. Sessions + working-directory model
Section titled “d. Sessions + working-directory model”Sessions are not a first-class FleetManager method; they’re read via two
layers, both keyed on an agent’s working_directory (Claude Code stores
transcripts under ~/.claude/projects/<cwd-with-slashes-as-dashes>/, so the
project dir IS the session key — no manual tagging):
SessionDiscoveryService (the one paddock uses)
Section titled “SessionDiscoveryService (the one paddock uses)”import { SessionDiscoveryService } from "@herdctl/core";
const discovery = new SessionDiscoveryService({ stateDir: "/abs/.herdctl", claudeHomePath: "/home/ed/.claude", // default: ~/.claude // cacheTtlMs?: number // default 30s});
// list a project's chats (sorted by mtime desc)const sessions: DiscoveredSession[] = await discovery.getAgentSessions( "keeper-foo", // agent qualified name "/abs/projects/foo", // working directory false, // dockerEnabled { limit: 50 }, // optional);// DiscoveredSession: { sessionId, workingDirectory, mtime, origin, agentName,// resumable, customName, autoName, preview }
// all sessions grouped by directoryconst groups = await discovery.getAllSessions( [{ name: "keeper-foo", workingDirectory: "/abs/projects/foo", dockerEnabled: false }], { limit: 100 },);
// a session's messagesconst messages: ChatMessage[] = await discovery.getSessionMessages( "/abs/projects/foo", "<sessionId>", { dockerEnabled: false },);// ChatMessage: { role: "user"|"assistant"|"tool", content, timestamp, toolCall? }
await discovery.getSessionMetadata(dir, id); // SessionMetadata (counts, previews, branch…)await discovery.getSessionUsage(dir, id); // { inputTokens, turnCount, hasData }discovery.invalidateAttributionCache(dir); // call after a new chat creates a sessionLower-level state/* helpers (also exported)
Section titled “Lower-level state/* helpers (also exported)”listSessions(sessionsDir, opts)/getSessionInfo/updateSessionInfo/clearSession— these operate on.herdctl/sessions/<qualified-name>.json(the agent’s current session pointer, not the full transcript list).parseSessionMessages(file),extractSessionMetadata(file),extractSessionUsage(file)— raw JSONL parsers.SessionMetadataStore— custom session names. (Paddock TODO: use it to let users rename chats up-front; seeroutes.tsPOST/api/projects/:slug/chats.)
e. FleetManager events
Section titled “e. FleetManager events”FleetManager extends EventEmitter. Typed event map (event-types.d.ts):
| Event | Payload |
|---|---|
initialized | — |
started | — |
stopped | — |
config:reloaded | { agentCount, agentNames, configPath, changes[], timestamp } |
agent:started / agent:stopped | agent payloads |
schedule:triggered / schedule:skipped | schedule payloads |
job:created | { job, agentName, scheduleName?, timestamp } |
job:output | { jobId, agentName, output, outputType, timestamp } |
job:completed | { job, agentName, exitReason, durationSeconds, timestamp } |
job:failed | { job, agentName, error, exitReason, durationSeconds?, timestamp } |
job:cancelled / job:forked | job payloads |
slack:* | slack connector events |
error | Error |
job:output.outputType ∈ stdout|stderr|assistant|tool|system. For paddock’s WS
streaming we use the per-trigger onMessage callback (finer-grained, gives
SDKMessage), and reserve events for fleet-wide UI (status, reloads).
f. Reusing web/chat transport
Section titled “f. Reusing web/chat transport”No — not from @herdctl/core. Core has zero HTTP/WS server code. The
transport lives in sibling packages:
@herdctl/web(0.9.10) — Fastify + WS dashboard. Itsws/types.tsdefines the chat protocol (chat:send→chat:response/chat:tool_call/chat:complete/chat:error, plussubscribe/job:outputfor live logs) and itschat/web-chat-manager.tsdoes the SDKMessage→protocol translation.@herdctl/chat(0.3.14) — shared session/streaming primitives.
Core only exposes IChatManager (an interface) + getChatManager(platform) —
i.e. core can host a chat manager you (or a sibling package) provide, but it
ships none for HTTP.
Decision: paddock builds its own transport (packages/server/src/ws.ts),
modeling the message shapes on @herdctl/web’s protocol for familiarity, and
wiring real streaming through FleetManager.trigger({onMessage}). We do NOT
depend on @herdctl/web at runtime (it pulls React/Fastify dashboard weight we
don’t need; paddock’s UI is its own SPA). We did read its ws/types.ts for the
protocol contract — that’s the only “reuse.”
Richer tool-call extraction (tool_use blocks nested in assistant content, paired tool_result blocks) is what
@herdctl/web’s web-chat-manager does; core exposes the building blocks (extractToolUseBlocks,extractToolResults,getToolInputSummaryinstate/tool-parsing). Paddock’sws.tshas a TODO to wire these for inline tool rendering parity.
Gaps requiring a local herdctl change (→ PR later)
Section titled “Gaps requiring a local herdctl change (→ PR later)”These are the points where the public API can’t (yet) do what paddock’s project model wants cleanly. None are blockers today — each has a working app-layer workaround — but they’re the candidates for an upstream local fix + PR.
-
Programmatic dynamic agents (primary). Add a public
FleetManager.addAgent(agent)/removeAgent(name)(orregisterAgents(ResolvedAgent[])) so paddock can register a project’s keeper agent in-memory instead of writing yaml +reload(). The internals already re-key the scheduler on reload, so the plumbing exists; it just isn’t exposed. Workaround in use: generateagents/<name>.yaml+herdctl.yaml, callreload(). Works, but couples paddock to herdctl’s on-disk config format and forces a full re-read on every project create. -
First-class session list on FleetManager. Session enumeration requires instantiating
SessionDiscoveryServiceseparately and passing each agent’s{name, workingDirectory, dockerEnabled}by hand. Afleet.getAgentSessions(agentName)/fleet.getSessionMessages(agentName, sessionId)that derives the working dir from the loaded config would remove the duplication and the chance of cwd/agent mismatch. Workaround in use: paddock’sHerdctlServicekeeps its ownSessionDiscoveryServiceand maps slugs→dirs. -
Reusable HTTP/WS chat transport in (or alongside) core. The SDKMessage→
chat:*translation + tool-call extraction lives in@herdctl/web, tangled with the dashboard. A small transport-agnostic@herdctl/chat-level helper (streamTriggerToHandlers(trigger, handlers)) would let paddock drop its hand-rolledws.tstranslation and stay in sync with herdctl’s message handling. Workaround in use: paddock reimplements the translation inws.ts(with a TODO for tool-block parity). -
Trigger that returns before completion (streaming handle).
trigger()resolves only when the job finishes; paddock streams viaonMessageduring the call, which is fine, but a returned async handle/stream (job id + an async iterator) would make cancellation and backpressure cleaner for the WS layer. Workaround in use:onJobCreatedgives the job id mid-flight, andcancelJob(jobId)exists — sufficient, but a unified streaming handle would be nicer.
- Real wrapper:
packages/server/src/herdctl.ts(HerdctlService). - Real spike (typechecks + runs vs public API):
packages/server/src/spike.ts. - WS protocol + streaming:
packages/server/src/ws.ts. - Project layer:
packages/server/src/projects.ts(ProjectStore).