Skip to content

FleetManager API Reference

The FleetManager class is the primary entry point for using herdctl programmatically. It provides a simple, high-level API to initialize and run a fleet of agents with minimal configuration.

Terminal window
npm install @herdctl/core
# or
pnpm add @herdctl/core
import { FleetManager } from '@herdctl/core';
const manager = new FleetManager({
configPath: './herdctl.yaml',
stateDir: './.herdctl',
});
await manager.initialize();
await manager.start();
// Subscribe to events
manager.on('job:created', (payload) => {
console.log(`Job ${payload.job.id} created for ${payload.agentName}`);
});
// Graceful shutdown
process.on('SIGINT', async () => {
await manager.stop();
process.exit(0);
});

Creates a new FleetManager instance.

const manager = new FleetManager(options: FleetManagerOptions);
ParameterTypeRequiredDefaultDescription
options.stateDirstringYesPath to the state directory (e.g., .herdctl). Created if it doesn’t exist. Stores job artifacts, session state, and logs.
options.configPathstringNoAuto-discoverPath to herdctl.yaml. Can be absolute, relative, or a directory path. If not provided, searches up from cwd.
options.loggerFleetManagerLoggerNoConsole loggerCustom logger with debug, info, warn, error methods.
options.checkIntervalnumberNo1000Interval in milliseconds between scheduler checks.
options.configOverridesFleetConfigOverridesNoRuntime overrides applied after the config file is loaded and parsed — lets CLI flags or programmatic callers override specific fleet-level values. Currently supports web (enabled, port, host).
interface FleetConfigOverrides {
web?: {
enabled?: boolean; // Enable/disable the web dashboard
port?: number; // Override the web dashboard port
host?: string; // Override the web dashboard host
};
}
import { FleetManager } from '@herdctl/core';
// Minimal configuration
const manager = new FleetManager({
stateDir: './.herdctl',
});
// Full configuration
const manager = new FleetManager({
configPath: './config/herdctl.yaml',
stateDir: './.herdctl',
checkInterval: 5000, // 5 seconds
logger: {
debug: (msg) => console.debug(`[DEBUG] ${msg}`),
info: (msg) => console.info(`[INFO] ${msg}`),
warn: (msg) => console.warn(`[WARN] ${msg}`),
error: (msg) => console.error(`[ERROR] ${msg}`),
},
});

Initializes the fleet manager by loading configuration and preparing the state directory.

await manager.initialize(): Promise<void>

This method:

  1. Loads and validates the configuration file
  2. Initializes the state directory structure
  3. Prepares the scheduler (but does not start it)

After initialization, the fleet manager is ready to start.

ErrorCondition
InvalidStateErrorAlready initialized or running
ConfigurationErrorConfiguration is invalid or not found
FleetManagerStateDirErrorState directory cannot be created
import { FleetManager, ConfigurationError } from '@herdctl/core';
const manager = new FleetManager({
configPath: './herdctl.yaml',
stateDir: './.herdctl',
});
try {
await manager.initialize();
console.log(`Loaded ${manager.state.agentCount} agents`);
} catch (error) {
if (error instanceof ConfigurationError) {
console.error('Configuration error:', error.message);
}
}

Initializes the fleet manager in web-only mode, without a configuration file.

await manager.initializeWebOnly(options?: { port?: number; host?: string }): Promise<void>
ParameterTypeRequiredDefaultDescription
options.portnumberNo3232Port for the web dashboard
options.hoststringNo'localhost'Host for the web dashboard

Builds a minimal in-memory config with zero agents and the web dashboard enabled, so the dashboard can serve session data from ~/.claude/ without requiring a herdctl.yaml fleet configuration. Any constructor configOverrides (e.g. web.port) are still applied on top of the built-in defaults.

Like initialize(), this prepares the state directory, session lifecycle, and scheduler, and emits 'initialized' on success.

ErrorCondition
InvalidStateErrorNot in uninitialized, stopped, or error state
const manager = new FleetManager({ stateDir: './.herdctl' });
// No herdctl.yaml needed — just the dashboard over ~/.claude sessions
await manager.initializeWebOnly({ port: 4000 });
await manager.start();

Starts the fleet manager scheduler, which begins processing agent schedules.

await manager.start(): Promise<void>

This begins the scheduler, which will:

  1. Check agent schedules at the configured interval
  2. Trigger agents when their schedules are due
  3. Track schedule state in the state directory
ErrorCondition
InvalidStateErrorNot initialized
await manager.initialize();
await manager.start();
// The manager is now running and processing schedules
manager.on('schedule:triggered', (payload) => {
console.log(`Triggered ${payload.agentName}/${payload.scheduleName}`);
});

Gracefully stops the fleet manager.

await manager.stop(options?: FleetManagerStopOptions): Promise<void>
ParameterTypeRequiredDefaultDescription
options.waitForJobsbooleanNotrueWait for running jobs to complete before stopping
options.timeoutnumberNo30000Maximum time in ms to wait for jobs to complete
options.cancelOnTimeoutbooleanNofalseCancel jobs that don’t complete within timeout
options.cancelTimeoutnumberNo10000Passed through to each job’s cancelJob() call; currently unused — cancellation aborts runs immediately

This will:

  1. Signal the scheduler to stop accepting new triggers
  2. Wait for running jobs to complete (with timeout)
  3. If timeout is reached and cancelOnTimeout is true, cancel remaining jobs
  4. Persist all state before shutdown completes
  5. Emit 'stopped' event when complete
ErrorCondition
FleetManagerShutdownErrorShutdown times out and cancelOnTimeout is false
// Normal shutdown - wait for jobs with default 30s timeout
await manager.stop();
// Shutdown with custom timeout
await manager.stop({ timeout: 60000 });
// Shutdown without waiting for jobs (not recommended)
await manager.stop({ waitForJobs: false });
// Cancel jobs if they don't complete in time
await manager.stop({
timeout: 30000,
cancelOnTimeout: true,
cancelTimeout: 10000,
});

Hot-reloads configuration without restarting the fleet.

await manager.reload(): Promise<ConfigReloadedPayload>
interface ConfigReloadedPayload {
agentCount: number; // Number of agents in new config
agentNames: string[]; // Names of agents in new config
configPath: string; // Path to reloaded config file
changes: ConfigChange[]; // List of detected changes
timestamp: string; // ISO timestamp of reload
}
interface ConfigChange {
type: 'added' | 'removed' | 'modified';
category: 'agent' | 'schedule' | 'defaults';
name: string; // Agent name or "agent/schedule"
details?: string; // What changed (for modifications)
}

This method provides hot configuration reload capability:

  1. Loads and validates the new configuration
  2. If validation fails, keeps the old configuration (fails gracefully)
  3. Running jobs continue with their original configuration
  4. New jobs will use the new configuration
  5. Updates the scheduler with new agent definitions
  6. Emits a 'config:reloaded' event with change details
ErrorCondition
InvalidStateErrorFleet manager is not initialized
ConfigurationErrorNew configuration is invalid
// Reload configuration
const result = await manager.reload();
console.log(`Reloaded with ${result.changes.length} changes`);
for (const change of result.changes) {
console.log(` ${change.type} ${change.category}: ${change.name}`);
}
// Subscribe to reload events
manager.on('config:reloaded', (payload) => {
console.log(`Config reloaded: ${payload.agentCount} agents`);
});

Returns a comprehensive snapshot of fleet state.

await manager.getFleetStatus(): Promise<FleetStatus>
interface FleetStatus {
state: FleetManagerStatus; // Current state
uptimeSeconds: number | null; // Time since started
initializedAt: string | null; // ISO timestamp
startedAt: string | null; // ISO timestamp
stoppedAt: string | null; // ISO timestamp
counts: FleetCounts; // Summary counts
scheduler: {
status: 'stopped' | 'running' | 'stopping';
checkCount: number; // Total checks performed
triggerCount: number; // Total triggers fired
lastCheckAt: string | null; // ISO timestamp
checkIntervalMs: number;
};
lastError: string | null;
}
interface FleetCounts {
totalAgents: number;
idleAgents: number;
runningAgents: number;
errorAgents: number;
totalSchedules: number;
runningSchedules: number;
runningJobs: number;
}
const status = await manager.getFleetStatus();
console.log(`Fleet: ${status.state}`);
console.log(`Uptime: ${status.uptimeSeconds}s`);
console.log(`Agents: ${status.counts.totalAgents} total, ${status.counts.runningAgents} running`);
console.log(`Jobs: ${status.counts.runningJobs} running`);

Returns information about all configured agents.

await manager.getAgentInfo(): Promise<AgentInfo[]>
interface AgentInfo {
name: string; // Agent local name
qualifiedName: string; // Dot-separated qualified name (e.g., "myfleet.agent-name")
fleetPath: string[]; // Fleet hierarchy path (empty for root agents)
description?: string; // From configuration
status: 'idle' | 'running' | 'error';
currentJobId: string | null; // Currently running job
lastJobId: string | null; // Last completed job
maxConcurrent: number; // Max concurrent instances
runningCount: number; // Currently running instances
errorMessage: string | null; // Error if status is 'error'
scheduleCount: number; // Number of schedules
schedules: ScheduleInfo[]; // Schedule details
model?: string; // Model from config
working_directory?: string; // Working directory path
chat?: Record<string, AgentChatStatus>; // Chat connector statuses by platform
}
interface AgentChatStatus {
configured: boolean;
connectionStatus?: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'disconnecting' | 'error';
botUsername?: string;
lastError?: string;
}
const agents = await manager.getAgentInfo();
for (const agent of agents) {
console.log(`${agent.name}: ${agent.status}`);
console.log(` Running: ${agent.runningCount}/${agent.maxConcurrent}`);
console.log(` Schedules: ${agent.scheduleCount}`);
}

Returns information about a specific agent.

await manager.getAgentInfoByName(name: string): Promise<AgentInfo>
ParameterTypeRequiredDescription
namestringYesThe agent name to look up
ErrorCondition
AgentNotFoundErrorNo agent with that name exists
try {
const agent = await manager.getAgentInfoByName('my-agent');
console.log(`Status: ${agent.status}`);
console.log(`Running: ${agent.runningCount}/${agent.maxConcurrent}`);
} catch (error) {
if (error instanceof AgentNotFoundError) {
console.log(`Agent "${error.agentName}" not found`);
console.log(`Available: ${error.availableAgents?.join(', ')}`);
}
}

Returns all schedules across all agents.

await manager.getSchedules(): Promise<ScheduleInfo[]>
interface ScheduleInfo {
name: string; // Schedule name
agentName: string; // Owning agent
type: string; // 'interval', 'cron', 'webhook', 'chat'
interval?: string; // e.g., "5m", "1h" (interval schedules)
cron?: string; // Cron expression (cron schedules)
status: 'idle' | 'running' | 'disabled';
lastRunAt: string | null; // ISO timestamp
nextRunAt: string | null; // ISO timestamp
lastError: string | null;
}
const schedules = await manager.getSchedules();
for (const schedule of schedules) {
console.log(`${schedule.agentName}/${schedule.name}: ${schedule.status}`);
console.log(` Next run: ${schedule.nextRunAt}`);
}

Returns a specific schedule by agent and schedule name.

await manager.getSchedule(agentName: string, scheduleName: string): Promise<ScheduleInfo>
ErrorCondition
AgentNotFoundErrorAgent doesn’t exist
ScheduleNotFoundErrorSchedule doesn’t exist for agent
const schedule = await manager.getSchedule('my-agent', 'hourly');
console.log(`Status: ${schedule.status}`);
console.log(`Last run: ${schedule.lastRunAt}`);
console.log(`Next run: ${schedule.nextRunAt}`);

Enables a previously disabled schedule.

await manager.enableSchedule(agentName: string, scheduleName: string): Promise<ScheduleInfo>

Enables a schedule that was previously disabled, allowing it to trigger again on its configured interval. The enabled state is persisted and survives restarts.

const schedule = await manager.enableSchedule('my-agent', 'hourly');
console.log(`Schedule status: ${schedule.status}`); // 'idle'

Disables a schedule temporarily.

await manager.disableSchedule(agentName: string, scheduleName: string): Promise<ScheduleInfo>

Disables a schedule, preventing it from triggering on its configured interval. The schedule remains in the configuration but won’t run until re-enabled. The disabled state is persisted and survives restarts.

// Disable a schedule temporarily
const schedule = await manager.disableSchedule('my-agent', 'hourly');
console.log(`Schedule status: ${schedule.status}`); // 'disabled'
// Later, re-enable it
await manager.enableSchedule('my-agent', 'hourly');

Registers an agent at runtime without writing YAML or reloading configuration.

await manager.addAgent(
agent: AgentConfig | (Record<string, unknown> & { name: string }),
options?: AddAgentOptions
): Promise<AgentInfo>
ParameterTypeRequiredDescription
agentAgentConfigYesAgent configuration object
options.baseDirstringNoBase directory for resolving relative working_directory paths (defaults to config directory or cwd)
options.mergeDefaultsbooleanNoWhether to merge fleet defaults into the agent (default: true)
options.replacebooleanNoReplace existing agent with same name instead of throwing (default: false)
interface AgentInfo {
name: string;
qualifiedName: string;
status: 'idle' | 'running' | 'error';
// ... (see getAgentInfo for full type)
}

This method provides programmatic agent registration without the write-yaml-then-reload round trip. The agent configuration is validated against the agent schema, merged with fleet defaults (unless disabled), and integrated into the in-memory config. The resolved agent is immediately triggerable and visible in fleet status. A config:reloaded event is emitted describing the change.

Programmatic agents are always registered at the root fleet level (fleetPath: []), so their qualified name equals their local name.

ErrorCondition
InvalidStateErrorFleet manager not initialized
ConfigurationErrorValidation fails or qualified name collides with existing agent (and replace is not set)
// Register a new agent
const agent = await manager.addAgent({
name: 'my-agent',
prompt: 'You are a helpful assistant',
model: 'claude-opus-4-5',
working_directory: '/path/to/project',
schedules: [
{
name: 'hourly',
interval: '1h',
prompt: 'Check for updates',
},
],
});
console.log(`Added agent: ${agent.qualifiedName}`);
// Replace an existing agent
await manager.addAgent(
{
name: 'my-agent',
prompt: 'Updated prompt',
},
{ replace: true }
);
// Add agent without merging defaults
await manager.addAgent(
{
name: 'standalone-agent',
prompt: 'I have my own config',
},
{ mergeDefaults: false }
);

Unregisters an agent at runtime.

await manager.removeAgent(name: string): Promise<boolean>
ParameterTypeRequiredDescription
namestringYesAgent qualified name or local name to remove

Returns true if an agent was removed, false if no match was found.

Removes the agent from the in-memory config and the scheduler. Accepts a qualified name (e.g., "sub.agent") or a local name; qualified names are matched first. Running jobs are unaffected — the scheduler simply stops triggering the removed agent’s schedules.

ErrorCondition
InvalidStateErrorFleet manager not initialized
// Remove by name
const removed = await manager.removeAgent('my-agent');
if (removed) {
console.log('Agent removed');
} else {
console.log('Agent not found');
}
// Remove by qualified name
await manager.removeAgent('myfleet.my-agent');

Lists discovered Claude Code sessions for an agent.

await manager.getAgentSessions(
name: string,
options?: { limit?: number }
): Promise<DiscoveredSession[]>
ParameterTypeRequiredDescription
namestringYesAgent qualified name or local name
options.limitnumberNoOptional limit for top-N enrichment
interface DiscoveredSession {
sessionId: string;
workingDirectory: string; // Directory the session belongs to
mtime: string; // Last-modified time (ISO 8601)
origin: SessionOrigin; // 'web' | 'discord' | 'slack' | 'schedule' | 'native'
agentName: string | undefined; // Attributed agent, if known
resumable: boolean; // Whether the session can be resumed
customName: string | undefined; // Custom display name if set
autoName: string | undefined; // Auto-generated name (from JSONL summary)
preview: string | undefined; // First-message preview (only if metadata was loaded)
}

Derives the agent’s working directory and Docker mode from the loaded config. Sessions are returned sorted by modification time (newest first).

Note: Sessions are keyed by working directory. This method uses the agent’s configured working_directory. If you triggered the agent with a per-trigger workingDirectory override, the resulting sessions live under that override directory and will NOT appear here.

ErrorCondition
InvalidStateErrorFleet manager not initialized
AgentNotFoundErrorAgent doesn’t exist
const sessions = await manager.getAgentSessions('my-agent');
for (const session of sessions) {
console.log(`${session.sessionId}: ${session.customName ?? session.autoName ?? session.preview}`);
console.log(` Last modified: ${session.mtime}`);
console.log(` Origin: ${session.origin}, resumable: ${session.resumable}`);
}
// Get top 10 sessions
const recent = await manager.getAgentSessions('my-agent', { limit: 10 });

Gets the parsed chat messages for one of an agent’s sessions.

await manager.getAgentSessionMessages(
name: string,
sessionId: string
): Promise<ChatMessage[]>
ParameterTypeRequiredDescription
namestringYesAgent qualified name or local name
sessionIdstringYesSession ID to read
interface ChatMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
timestamp: string; // ISO 8601
toolCall?: ChatToolCall; // Present for role: 'tool' (paired tool_use/tool_result)
uuid?: string; // Stable id from the source transcript entry
}

The uuid field is the stable ID of the message’s source JSONL transcript entry. It is assigned when the line is written, is append-only, and remains stable across reloads and session forks — making it suitable for keying per-message UI state (e.g. React list keys, collapse state, deep links). For a paired tool message, it is the originating tool_use entry’s uuid, so the ID is deterministic even when several tool results share one user line. It is undefined when the source line carried no uuid.

ErrorCondition
InvalidStateErrorFleet manager not initialized
AgentNotFoundErrorAgent doesn’t exist
const messages = await manager.getAgentSessionMessages(
'my-agent',
'session-abc123'
);
for (const msg of messages) {
console.log(`[${msg.role}] ${msg.content}`);
}

Gets token usage data for one of an agent’s sessions.

await manager.getAgentSessionUsage(
name: string,
sessionId: string
): Promise<SessionUsage>
ParameterTypeRequiredDescription
namestringYesAgent qualified name or local name
sessionIdstringYesSession ID to read
interface SessionUsage {
inputTokens: number; // Last turn's input + cache tokens
turnCount: number; // Total turns in session
hasData: boolean; // Whether usage data exists
}

Reads the session transcript and returns the most recent context-window fill level (last assistant turn’s input + cache tokens) plus a turn count. This lets a UI show “context used” for a chat loaded from history — before any new turn streams a fresh usage value.

ErrorCondition
InvalidStateErrorFleet manager not initialized
AgentNotFoundErrorAgent doesn’t exist
const usage = await manager.getAgentSessionUsage('my-agent', 'session-abc123');
if (usage.hasData) {
console.log(`Context used: ${usage.inputTokens} tokens`);
console.log(`Turn count: ${usage.turnCount}`);
}

Deletes one of an agent’s Claude Code session transcripts from disk.

await manager.deleteSession(
name: string,
sessionId: string
): Promise<boolean>
ParameterTypeRequiredDescription
namestringYesAgent qualified name or local name
sessionIdstringYesSession ID whose transcript should be removed

Returns true if a file was removed, false if no transcript existed (or the agent has no working directory).

Resolves the agent’s working directory and Docker mode from the loaded config, computes the CLI (or Docker) transcript file path, deletes it, and invalidates the session-discovery cache so a subsequent getAgentSessions() no longer lists it.

The sessionId is validated (only [A-Za-z0-9-] is allowed) to prevent path traversal.

ErrorCondition
InvalidStateErrorFleet manager not initialized
AgentNotFoundErrorAgent doesn’t exist
ErrorsessionId contains invalid characters
const deleted = await manager.deleteSession('my-agent', 'session-abc123');
if (deleted) {
console.log('Session deleted');
} else {
console.log('Session not found');
}

setSessionName(name, sessionId, customName)

Section titled “setSessionName(name, sessionId, customName)”

Sets (or clears) the custom display name for one of an agent’s sessions.

await manager.setSessionName(
name: string,
sessionId: string,
customName: string | null
): Promise<void>
ParameterTypeRequiredDescription
namestringYesAgent qualified name or local name
sessionIdstringYesSession ID to (re)name
customNamestring | nullYesCustom name to set, or null/empty to clear it

Writes through the fleet’s shared session metadata store so a subsequent getAgentSessions() reflects the new customName immediately. Passing null or an empty/whitespace string clears any existing custom name.

ErrorCondition
InvalidStateErrorFleet manager not initialized
AgentNotFoundErrorAgent doesn’t exist
// Set custom name
await manager.setSessionName('my-agent', 'session-abc123', 'Bug Fix Discussion');
// Clear custom name
await manager.setSessionName('my-agent', 'session-abc123', null);

Drops the cached session listing for an agent so the next getAgentSessions() call rebuilds it from disk.

manager.invalidateSessions(name: string): void
ParameterTypeRequiredDescription
namestringYesAgent qualified name or local name

The underlying session discovery service caches each working directory’s listing for up to its TTL (default 30s). That cache is now mtime-aware, so newly created transcript files are normally picked up immediately. Call this when you want to force a fresh listing regardless — e.g., after each chat turn, or on filesystems whose directory mtime has coarse (1-second) granularity.

Resolves the agent’s working directory and Docker mode from the loaded config. A no-op when the agent has no working directory.

ErrorCondition
InvalidStateErrorFleet manager not initialized
AgentNotFoundErrorAgent doesn’t exist
// Force refresh session listing
manager.invalidateSessions('my-agent');
// Next call will rebuild from disk
const sessions = await manager.getAgentSessions('my-agent');

Opens a long-lived, multi-turn streaming chat session with an agent and returns a live RuntimeSession handle.

await manager.openChatSession(
agentName: string,
options?: ChatSessionOptions
): Promise<RuntimeSession>
ParameterTypeRequiredDescription
agentNamestringYesAgent qualified name or local name
options.promptstringNoOptional initial user turn to send when the session opens. When omitted, the session opens idle and the first turn is sent via session.send(...)
options.resumestring | nullNoSession ID to resume. string resumes that session; null explicitly starts fresh (skips agent-level fallback); undefined falls back to the agent’s stored session
options.workingDirectorystringNoOverride the agent’s configured working directory for this session only
options.injectedMcpServersRecord<string, InjectedMcpServerDef>NoMCP servers to inject at runtime (in-process SDK servers)
options.systemPromptAppendstringNoText to append to the agent’s system prompt for this session
options.manageLifecyclebooleanNoOpt in to herdctl-managed session lifecycle (reap-on-idle + durable wakes). See Session Lifecycle Methods. Default: false (caller owns close())

A live RuntimeSession:

interface RuntimeSession {
// The live SDK message stream for the session. Iterate to receive output.
readonly messages: AsyncIterable<SDKMessage>;
// Send a user turn. A leading-slash string (e.g. "/compact", "/clear") is
// dispatched by Claude Code as a slash command — commands are just user
// messages whose text is the command.
send(text: string): Promise<void>;
// Interrupt the current turn without closing the session.
// Further send() calls remain valid.
interrupt(): Promise<void>;
// List the slash commands available in this session
// (name, description, argument hint). Does not run anything.
listCommands(): Promise<SlashCommand[]>;
// Change the model used for subsequent turns in this session.
setModel(model?: string): Promise<void>;
// Close the session, ending the input stream and shutting down the query.
close(): Promise<void>;
}

Unlike trigger(), openChatSession() does not create a job record and does not drain the run to completion — the caller drives the session across turns and must call session.close() when finished. This is the primitive behind interactive chat UIs: multi-turn streaming input, mid-turn interrupt(), and slash-command discovery via listCommands().

The session always runs on the SDK runtime, regardless of the agent’s configured runtime. This is safe for cli-configured agents (same CLAUDE_CODE_OAUTH_TOKEN auth, shared on-disk session store) and is what unlocks the SDK control requests (interrupt, listCommands, streaming send), which require streaming input/output mode.

Session resume follows the same precedence as trigger(): an explicit options.resume string wins; null forces a fresh session; undefined falls back to the agent’s stored session.

The session ID is not a property of the handle — it arrives inside the message stream, on the system/init message.

ErrorCondition
InvalidStateErrorFleet manager not initialized
AgentNotFoundErrorAgent doesn’t exist
StreamingSessionUnsupportedErrorAgent is Docker-wrapped (docker.enabled: true) — streaming sessions require the SDK runtime
const session = await manager.openChatSession('my-agent', {
prompt: 'Summarize the open PRs',
});
// Consume the output stream
const reader = (async () => {
for await (const message of session.messages) {
if (message.type === 'system' && message.subtype === 'init') {
console.log(`Session started: ${message.session_id}`);
}
// ... render assistant text, tool calls, results
}
})();
// Send follow-up turns — slash commands are just user messages
await session.send('Now focus on the oldest one');
await session.send('/compact');
// Stop a runaway turn without losing the session
await session.interrupt();
await session.send('Never mind, try a shorter answer');
// Discover available slash commands
const commands = await session.listCommands();
// Always close when done
await session.close();
await reader;

Lists the slash commands available to an agent — for populating a command palette or autocomplete — in a single call.

await manager.listAgentCommands(
agentName: string,
options?: ChatSessionOptions
): Promise<SlashCommand[]>
ParameterTypeRequiredDescription
agentNamestringYesAgent qualified name or local name
options.workingDirectorystringNoPer-call working-directory override so the list reflects the intended project context
options.injectedMcpServersRecord<string, InjectedMcpServerDef>NoMCP servers to inject, so their commands appear in the list
interface SlashCommand {
name: string; // Command name, without the leading slash
description: string; // Human-readable description
argumentHint: string; // Hint for arguments (e.g. "<pr>"), "" if none
}

Returns the full command list — built-ins plus project .claude/commands plus any MCP-provided commands — exactly as the CLI reports them for the resolved session’s cwd and config.

A one-shot convenience over openChatSession(): it opens a streaming session, reads its command list, and always closes the session (in a finally, even if the listing throws), so consumers never have to manage the underlying claude subprocess lifecycle themselves.

Accepts the same ChatSessionOptions as openChatSession. The session runs on the SDK runtime regardless of the agent’s configured runtime, so this works for cli-runtime (Claude-subscription) agents too.

Cost: each call spawns and tears down a claude subprocess (~seconds). The command list is essentially static per project, so callers that query it repeatedly should cache the result.

ErrorCondition
InvalidStateErrorFleet manager not initialized
AgentNotFoundErrorAgent doesn’t exist
StreamingSessionUnsupportedErrorAgent is Docker-wrapped (surfaced unchanged from openChatSession)
import type { SlashCommand } from '@herdctl/core';
const commands: SlashCommand[] = await manager.listAgentCommands('my-agent');
for (const cmd of commands) {
console.log(`/${cmd.name} ${cmd.argumentHint}${cmd.description}`);
}

When a streaming chat session is opened with manageLifecycle: true, herdctl manages its lifecycle: the session is reaped (closed) the instant it goes idle — instead of accumulating warm claude processes at roughly 300 MB each — and any timer-class wakeups the agent scheduled in-session (ScheduleWakeup, CronCreate) are captured as durable wake entries and re-fired through the fleet’s scheduler, resuming the session with the wake’s prompt. Wake entries persist in state.yaml under session_wakes, so they survive fleet restarts. See State Persistence for the on-disk format and Sessions for the concept.

Returns the fleet’s session-lifecycle manager, or null before initialize().

manager.getSessionLifecycle(): SessionLifecycleManager | null

The SessionLifecycleManager is the facade over the session reaper and wake registry. It is constructed during initialize() and wired into the scheduler loop (due wakes are dispatched once per scheduler tick). Most applications only need setSessionWakeHandler(), but the manager is exposed for advanced use:

class SessionLifecycleManager {
readonly registry: WakeRegistry; // Durable wake entries (reconcile/remove/dispatchDue)
readonly reaper: SessionReaper; // Reap-on-idle policy over managed sessions
manage(session: RuntimeSession, agent: string): ManagedSession;
dispatchDue(now?: Date): Promise<SessionWakeEntry[]>;
setSessionWakeHandler(handler: SessionWakeHandler | undefined): void;
}

The reap policy is a single rule: a session is kept alive if and only if it holds live background work (running shells, subagents, monitors); otherwise it is reaped as soon as its turn ends. Pending wakeups do not keep a session alive — they are captured durably and re-triggered later. Resuming recovers the full conversation, and Claude’s prompt cache is server-side, so a reap costs only about half a second of respawn time on the next wake.

const lifecycle = manager.getSessionLifecycle();
if (lifecycle) {
// Inspect or force-dispatch due wakes (normally done by the scheduler tick)
const fired = await lifecycle.dispatchDue();
console.log(`Fired ${fired.length} due wakes`);
}

Registers the consumer hook that receives sessions resumed by a fired wake.

manager.setSessionWakeHandler(
handler: SessionWakeHandler | undefined
): void
type SessionWakeHandler = (
session: RuntimeSession,
entry: SessionWakeEntry,
) => void | Promise<void>;
ParameterTypeRequiredDescription
handlerSessionWakeHandler | undefinedYesCalled with the already-resumed, lifecycle-managed session and the wake entry that fired. Pass undefined to clear

When a wake fires, herdctl resumes the session via openChatSession({ resume, prompt, manageLifecycle: true }) — the wake’s prompt is already injected. If a handler is registered, it receives the live RuntimeSession and is responsible for consuming its message stream (e.g. delivering the woken turn to a chat UI). If no handler is registered, the manager drains the stream headlessly so recurring wakes keep firing.

Because the resumed session is itself lifecycle-managed, the reaper closes it again when the woken turn goes idle, so the handler should treat the message stream ending as normal. Safe to call before or after initialize().

The SessionWakeEntry describes the wake that fired:

interface SessionWakeEntry {
id: string; // SDK cron id — the reconciliation key across turns
agent: string; // Qualified agent name that owns the session
sessionId: string; // Session resumed when the wake fired
schedule: string; // Cron expression (a one-shot wakeup encodes a single fire time)
recurring: boolean; // false = one-shot; true = re-fires on every match
prompt: string; // Prompt injected into the resumed session
nextRunAt: string; // Resolved absolute next fire time (ISO)
createdAt: string; // ISO capture time — anchors the 7-day recurring expiry
}
manager.setSessionWakeHandler(async (session, entry) => {
console.log(`Wake ${entry.id} fired for ${entry.agent} (${entry.sessionId})`);
// Deliver the woken turn to your UI
for await (const message of session.messages) {
renderMessage(message);
}
});

trigger(agentName, scheduleName?, options?)

Section titled “trigger(agentName, scheduleName?, options?)”

Manually triggers an agent outside its normal schedule.

await manager.trigger(
agentName: string,
scheduleName?: string,
options?: TriggerOptions
): Promise<TriggerResult>
ParameterTypeRequiredDescription
agentNamestringYesName of the agent to trigger
scheduleNamestringNoSchedule to use for configuration (prompt, work source)
options.promptstringNoOverride the prompt for this trigger
options.resumestring | nullNoSession ID to resume for conversation continuity. string resumes that session; null explicitly starts fresh (skips agent-level fallback); undefined uses the agent-level session fallback
options.forkstringNoSession ID to fork: the run resumes this session’s transcript as context but writes all new turns to a brand-new session ID (Claude Code --fork-session), leaving the source session untouched. Mutually exclusive with resume — when both are set, fork takes precedence
options.forkedFromstringNoParent job ID recorded as the new job’s forked_from lineage. Only meaningful alongside fork; purely informational
options.workItemsWorkItem[]NoCustom work items instead of fetching from work source
options.bypassConcurrencyLimitbooleanNoForce trigger even if agent is at capacity (default: false)
options.workingDirectorystringNoOverride the agent’s configured working directory for this trigger only
options.triggerTypestringNoHow the trigger was initiated ("discord", "slack", "web", "manual", …) — connectors set this to identify the source platform (default: "manual")
options.systemPromptAppendstringNoText appended to the agent’s system prompt for this trigger only
options.injectedMcpServersRecord<string, InjectedMcpServerDef>NoMCP servers merged with the agent’s config-declared servers at execution time, for runtime tool injection
options.onMessage(message: SDKMessage) => void | Promise<void>NoCallback invoked for each SDK message during execution, for real-time streaming of output
options.onJobCreated(jobId: string) => void | Promise<void>NoCallback invoked as soon as the job ID is created — useful for immediate job control (e.g. enabling cancel while output streams)

The returned promise resolves only after the run completestrigger() executes the job to completion (streaming output via options.onMessage along the way), it does not return a handle to a still-running job. Use options.onJobCreated if you need the job ID while the run is still streaming.

interface TriggerResult {
jobId: string; // Unique job identifier
agentName: string;
scheduleName: string | null;
startedAt: string; // ISO timestamp
prompt?: string; // Prompt used for trigger
success: boolean; // Whether the job completed successfully
sessionId?: string; // Claude Agent SDK session ID — the continuity
// handle: pass it as options.resume on a later
// trigger to continue the conversation. Only
// trust it when success is true
error?: Error; // Error if the job failed
errorDetails?: RunnerErrorDetails; // Detailed error info for programmatic access
}
ErrorCondition
InvalidStateErrorFleet manager not initialized
AgentNotFoundErrorAgent doesn’t exist
ScheduleNotFoundErrorSpecified schedule doesn’t exist
ConcurrencyLimitErrorAgent at capacity and bypass not enabled
InvalidWorkingDirectoryOverrideErroroptions.workingDirectory is not a non-empty string

Note that a job that starts but fails during execution does not reject the promise — it resolves with success: false plus error/errorDetails.

// Trigger with agent defaults — the await resolves when the run completes
const job = await manager.trigger('my-agent');
console.log(`Job ${job.jobId} ${job.success ? 'completed' : 'failed'}`);
// Continue the same conversation later using the returned session ID
if (job.success && job.sessionId) {
await manager.trigger('my-agent', undefined, {
resume: job.sessionId,
prompt: 'Follow up on your last answer',
});
}
// Trigger a specific schedule
const job = await manager.trigger('my-agent', 'hourly');
// Trigger with custom prompt
const job = await manager.trigger('my-agent', undefined, {
prompt: 'Review the latest security updates',
});
// Force trigger even at capacity
const job = await manager.trigger('my-agent', undefined, {
bypassConcurrencyLimit: true,
});
// Trigger with custom working directory
const job = await manager.trigger('my-agent', undefined, {
workingDirectory: '/path/to/specific/project',
prompt: 'Analyze this project',
});
// Fork an existing session into an independent child conversation
const job = await manager.trigger('my-agent', undefined, {
fork: 'session-abc123',
prompt: 'Try a different approach from here',
});

Cancels a running job gracefully.

await manager.cancelJob(
jobId: string,
options?: { timeout?: number }
): Promise<CancelJobResult>
ParameterTypeRequiredDefaultDescription
jobIdstringYesID of the job to cancel
options.timeoutnumberNo10000Accepted for backwards compatibility but currently unused — cancellation aborts the run immediately (see Description)
interface CancelJobResult {
jobId: string;
success: boolean;
terminationType: 'graceful' | 'forced' | 'already_stopped';
canceledAt: string; // ISO timestamp
}

Cancels a running job by aborting its in-flight execution and marking the job record cancelled. The fleet manager keeps an AbortController registered for every job it is currently running; cancelJob() fires that controller’s abort signal, which genuinely interrupts the run:

  • CLI runtime — the claude subprocess is killed.
  • SDK runtime — the in-flight SDK query is aborted.

The aborted run is finalized with status: cancelled and exit_reason: cancelled (not failed), and a job:cancelled event is emitted.

If the job is already finished, the call returns early with terminationType: 'already_stopped'. If the job was started by a different process (the registry is per-process), only the job’s status file is updated — the cancellation is best-effort in that case.

ErrorCondition
InvalidStateErrorFleet manager not initialized
JobNotFoundErrorJob doesn’t exist
// Cancel with default timeout (10s)
const result = await manager.cancelJob('job-2024-01-15-abc123');
console.log(`Cancelled: ${result.terminationType}`);
// Cancel with custom timeout
const result = await manager.cancelJob('job-2024-01-15-abc123', {
timeout: 30000, // 30 seconds
});

Creates a new job based on an existing job’s configuration.

await manager.forkJob(
jobId: string,
modifications?: JobModifications
): Promise<ForkJobResult>
ParameterTypeRequiredDescription
jobIdstringYesID of the job to fork
modifications.promptstringNoOverride the prompt
modifications.schedulestringNoOverride the schedule name
modifications.workItemsWorkItem[]NoReplace work items
interface ForkJobResult {
jobId: string; // New job ID
forkedFromJobId: string; // Original job ID
agentName: string;
startedAt: string; // ISO timestamp
prompt?: string;
}

Creates a new job based on an existing job’s configuration. The new job will have the same agent and can optionally have modifications applied. If the original job has a session ID, the new job will fork from that session, preserving conversation context.

ErrorCondition
InvalidStateErrorFleet manager not initialized
JobNotFoundErrorOriginal job doesn’t exist
JobForkErrorJob cannot be forked
// Fork with same configuration
const result = await manager.forkJob('job-2024-01-15-abc123');
console.log(`Forked to: ${result.jobId}`);
// Fork with modified prompt
const result = await manager.forkJob('job-2024-01-15-abc123', {
prompt: 'Continue the previous task but focus on testing',
});
// Fork with different schedule
const result = await manager.forkJob('job-2024-01-15-abc123', {
schedule: 'nightly',
});

Streams all fleet logs as an async iterable.

manager.streamLogs(options?: LogStreamOptions): AsyncIterable<LogEntry>
ParameterTypeRequiredDefaultDescription
options.levelLogLevelNo'info'Minimum log level ('debug', 'info', 'warn', 'error')
options.agentNamestringNoFilter to specific agent
options.jobIdstringNoFilter to specific job
options.includeHistorybooleanNotrueReplay history before streaming
options.historyLimitnumberNo1000Max historical entries
interface LogEntry {
timestamp: string; // ISO timestamp
level: 'debug' | 'info' | 'warn' | 'error';
source: 'fleet' | 'agent' | 'job' | 'scheduler';
agentName?: string;
jobId?: string;
scheduleName?: string;
message: string;
data?: Record<string, unknown>;
}
// Stream all info+ logs
for await (const log of manager.streamLogs()) {
console.log(`[${log.level}] ${log.message}`);
}
// Stream only errors for a specific agent
for await (const log of manager.streamLogs({
level: 'error',
agentName: 'my-agent',
})) {
console.error(log.message);
}

Streams output from a specific job.

manager.streamJobOutput(jobId: string): AsyncIterable<LogEntry>

For completed jobs, this will replay the job’s history and then complete. For running jobs, it will continue streaming until the job completes.

ErrorCondition
JobNotFoundErrorJob doesn’t exist
for await (const log of manager.streamJobOutput('job-2024-01-15-abc123')) {
console.log(`[${log.level}] ${log.message}`);
}

Streams logs for a specific agent.

manager.streamAgentLogs(agentName: string): AsyncIterable<LogEntry>
ErrorCondition
AgentNotFoundErrorAgent doesn’t exist
for await (const log of manager.streamAgentLogs('my-agent')) {
console.log(`[${log.jobId}] ${log.message}`);
}

Returns the final output of a completed job as a string.

await manager.getJobFinalOutput(jobId: string): Promise<string>
ParameterTypeRequiredDescription
jobIdstringYesID of the job to get output from

Returns a Promise<string> containing the job’s final output — the last assistant message with text content, falling back to the last meaningful tool_result. This method never throws: if the job doesn’t exist, its output file can’t be read, or no meaningful output is found, it resolves to an empty string "" (a warning is logged).

const output = await manager.getJobFinalOutput('job-2024-01-15-abc123');
if (output) {
console.log(output);
// "I checked the prices for office chairs..."
} else {
console.log('No output found (unknown job or empty transcript)');
}

Returns the current fleet manager state (read-only property).

manager.state: FleetManagerState
interface FleetManagerState {
status: FleetManagerStatus;
initializedAt: string | null;
startedAt: string | null;
stoppedAt: string | null;
agentCount: number;
lastError: string | null;
}
type FleetManagerStatus =
| 'uninitialized' // Initial state
| 'initialized' // After initialize()
| 'starting' // During start()
| 'running' // Scheduler active
| 'stopping' // During stop()
| 'stopped' // After stop()
| 'error'; // Error occurred
console.log(`Status: ${manager.state.status}`);
console.log(`Agents: ${manager.state.agentCount}`);
console.log(`Started at: ${manager.state.startedAt}`);

Returns the loaded configuration.

manager.getConfig(): ResolvedConfig | null

Returns null if not initialized.


Returns the loaded agents array.

manager.getAgents(): ResolvedAgent[]

Returns empty array if not initialized.


The FleetManager extends EventEmitter and provides strongly-typed events.

interface FleetManagerEventMap {
// Lifecycle
'initialized': [];
'started': [];
'stopped': [];
'error': [error: Error];
'config:reloaded': [payload: ConfigReloadedPayload];
// Agent events
'agent:started': [payload: AgentStartedPayload];
'agent:stopped': [payload: AgentStoppedPayload];
// Schedule events
'schedule:triggered': [payload: ScheduleTriggeredPayload];
// Job events
'job:created': [payload: JobCreatedPayload];
'job:output': [payload: JobOutputPayload];
'job:completed': [payload: JobCompletedPayload];
'job:failed': [payload: JobFailedPayload];
'job:cancelled': [payload: JobCancelledPayload];
'job:forked': [payload: JobForkedPayload];
}
interface JobCreatedPayload {
job: JobMetadata;
agentName: string;
scheduleName?: string;
timestamp: string;
}
interface JobOutputPayload {
jobId: string;
agentName: string;
output: string;
outputType: 'stdout' | 'stderr' | 'assistant' | 'tool' | 'system';
timestamp: string;
}
interface JobCompletedPayload {
job: JobMetadata;
agentName: string;
exitReason: ExitReason;
durationSeconds: number;
timestamp: string;
}
interface JobFailedPayload {
job: JobMetadata;
agentName: string;
error: Error;
exitReason: ExitReason;
durationSeconds?: number;
timestamp: string;
}
interface JobCancelledPayload {
job: JobMetadata;
agentName: string;
terminationType: 'graceful' | 'forced' | 'already_stopped';
durationSeconds?: number;
timestamp: string;
}
interface JobForkedPayload {
job: JobMetadata;
originalJob: JobMetadata;
agentName: string;
timestamp: string;
}
const manager = new FleetManager({ stateDir: './.herdctl' });
// Lifecycle events
manager.on('initialized', () => {
console.log('Fleet initialized');
});
manager.on('started', () => {
console.log('Fleet started');
});
manager.on('stopped', () => {
console.log('Fleet stopped');
});
manager.on('error', (error) => {
console.error('Fleet error:', error.message);
});
// Job events
manager.on('job:created', (payload) => {
console.log(`Job ${payload.job.id} created for ${payload.agentName}`);
});
manager.on('job:output', (payload) => {
process.stdout.write(payload.output);
});
manager.on('job:completed', (payload) => {
console.log(`Job completed in ${payload.durationSeconds}s`);
});
manager.on('job:failed', (payload) => {
console.error(`Job failed: ${payload.error.message}`);
});
// Schedule events
manager.on('schedule:triggered', (payload) => {
console.log(`${payload.agentName}/${payload.scheduleName} triggered`);
});
// Config reload
manager.on('config:reloaded', (payload) => {
console.log(`Config reloaded with ${payload.changes.length} changes`);
});

All errors extend FleetManagerError and include error codes for programmatic handling.

FleetManagerError (base)
├── ConfigurationError // Config invalid/not found
├── AgentNotFoundError // Agent not in config
├── JobNotFoundError // Job doesn't exist
├── ScheduleNotFoundError // Schedule not configured
├── InvalidStateError // Operation invalid for state
├── ConcurrencyLimitError // Agent at max capacity
├── JobCancelError // Job cancellation failed
├── JobForkError // Job fork failed
├── InvalidWorkingDirectoryOverrideError // Bad per-trigger workingDirectory override
├── StreamingSessionUnsupportedError // Agent runtime can't open streaming sessions
├── FleetManagerStateDirError // State directory init failed
├── FleetManagerShutdownError // Shutdown failed
└── SchedulerError // Scheduler subsystem errors
├── IntervalParseError // Invalid interval expression (e.g. "5 minutes")
├── CronParseError // Invalid cron expression
├── ScheduleTriggerError // Schedule trigger failed
└── SchedulerShutdownError // Scheduler shutdown failed
const FleetManagerErrorCode = {
FLEET_MANAGER_ERROR: 'FLEET_MANAGER_ERROR',
CONFIGURATION_ERROR: 'CONFIGURATION_ERROR',
CONFIG_LOAD_ERROR: 'CONFIG_LOAD_ERROR',
AGENT_NOT_FOUND: 'AGENT_NOT_FOUND',
JOB_NOT_FOUND: 'JOB_NOT_FOUND',
SCHEDULE_NOT_FOUND: 'SCHEDULE_NOT_FOUND',
INVALID_STATE: 'INVALID_STATE',
STATE_DIR_ERROR: 'STATE_DIR_ERROR',
CONCURRENCY_LIMIT: 'CONCURRENCY_LIMIT',
SHUTDOWN_ERROR: 'SHUTDOWN_ERROR',
JOB_CANCEL_ERROR: 'JOB_CANCEL_ERROR',
JOB_FORK_ERROR: 'JOB_FORK_ERROR',
INVALID_WORKING_DIRECTORY_OVERRIDE: 'INVALID_WORKING_DIRECTORY_OVERRIDE',
STREAMING_SESSION_UNSUPPORTED: 'STREAMING_SESSION_UNSUPPORTED',
// ... plus agent-distribution codes (SOURCE_PARSE_ERROR, AGENT_INSTALL_ERROR, ...)
};
import {
isFleetManagerError,
isConfigurationError,
isAgentNotFoundError,
isJobNotFoundError,
isScheduleNotFoundError,
isInvalidStateError,
isConcurrencyLimitError,
isJobCancelError,
isJobForkError,
} from '@herdctl/core';
try {
await manager.trigger('unknown-agent');
} catch (error) {
if (isAgentNotFoundError(error)) {
console.log(`Agent "${error.agentName}" not found`);
console.log(`Available: ${error.availableAgents?.join(', ')}`);
} else if (isConcurrencyLimitError(error)) {
console.log(`At capacity: ${error.currentJobs}/${error.limit}`);
}
}
class AgentNotFoundError extends FleetManagerError {
agentName: string; // The agent that wasn't found
availableAgents?: string[]; // List of valid agents
}

Here’s a complete example showing typical usage patterns:

import { FleetManager, isAgentNotFoundError } from '@herdctl/core';
async function main() {
const manager = new FleetManager({
configPath: './herdctl.yaml',
stateDir: './.herdctl',
checkInterval: 5000,
});
// Set up event handlers
manager.on('initialized', () => console.log('Fleet initialized'));
manager.on('started', () => console.log('Fleet started'));
manager.on('stopped', () => console.log('Fleet stopped'));
manager.on('job:created', (payload) => {
console.log(`Job ${payload.job.id} created for ${payload.agentName}`);
});
manager.on('job:output', (payload) => {
process.stdout.write(payload.output);
});
manager.on('job:completed', (payload) => {
console.log(`Job ${payload.job.id} completed in ${payload.durationSeconds}s`);
});
manager.on('job:failed', (payload) => {
console.error(`Job ${payload.job.id} failed: ${payload.error.message}`);
});
// Initialize and start
await manager.initialize();
await manager.start();
// Get fleet status
const status = await manager.getFleetStatus();
console.log(`Running ${status.counts.totalAgents} agents`);
// List all agents
const agents = await manager.getAgentInfo();
for (const agent of agents) {
console.log(`${agent.name}: ${agent.status}`);
}
// Manually trigger an agent
try {
const result = await manager.trigger('my-agent', 'check-issues', {
prompt: 'Check for urgent issues only',
});
console.log(`Triggered job: ${result.jobId}`);
} catch (error) {
if (isAgentNotFoundError(error)) {
console.error(`Agent not found: ${error.agentName}`);
}
}
// Handle shutdown
process.on('SIGINT', async () => {
console.log('Shutting down...');
await manager.stop({
timeout: 30000,
cancelOnTimeout: true,
});
process.exit(0);
});
}
main().catch(console.error);

The @herdctl/core package is written in TypeScript and provides full type definitions. All types are exported from the main package:

import type {
// Options and configuration
FleetManagerOptions,
FleetConfigOverrides,
FleetManagerLogger,
// State types
FleetManagerState,
FleetManagerStatus,
// Query result types
FleetStatus,
FleetCounts,
AgentInfo,
ScheduleInfo,
// Action types
TriggerOptions,
TriggerResult,
JobModifications,
CancelJobResult,
ForkJobResult,
// Stop options
FleetManagerStopOptions,
// Log streaming types
LogLevel,
LogSource,
LogEntry,
LogStreamOptions,
// Event types
FleetManagerEventMap,
FleetManagerEventName,
ConfigReloadedPayload,
ConfigChange,
JobCreatedPayload,
JobOutputPayload,
JobCompletedPayload,
JobFailedPayload,
JobCancelledPayload,
JobForkedPayload,
ScheduleTriggeredPayload,
} from '@herdctl/core';