Common Recipes & Patterns
This guide provides ready-to-use code examples for common scenarios when using @herdctl/core as a library. Each recipe is self-contained and can be adapted for your specific needs.
Simple One-Shot Agent Execution
Section titled “Simple One-Shot Agent Execution”Run a single agent job and exit when complete. Perfect for scripts and one-time tasks.
import { FleetManager } from "@herdctl/core";
async function runOnce(agentName: string, prompt?: string) { const manager = new FleetManager({ configPath: "./herdctl.yaml", stateDir: "./.herdctl", });
try { await manager.initialize(); await manager.start();
// trigger() resolves when the job has finished, so no event // subscription is needed — inspect the result directly const result = await manager.trigger(agentName, undefined, { prompt });
if (!result.success) { throw result.error ?? new Error(`Job ${result.jobId} failed`); }
console.log(`Job ${result.jobId} completed`);
// Optionally fetch the agent's final response const output = await manager.getJobFinalOutput(result.jobId); console.log(output); } finally { await manager.stop(); }}
// UsagerunOnce("my-agent", "Process the latest data").catch(console.error);Long-Running Daemon with Graceful Shutdown
Section titled “Long-Running Daemon with Graceful Shutdown”Run herdctl as a background service with proper signal handling for SIGINT and SIGTERM.
import { FleetManager } from "@herdctl/core";
async function startDaemon() { const manager = new FleetManager({ configPath: "./herdctl.yaml", stateDir: "./.herdctl", checkInterval: 5000, });
// Track shutdown state let isShuttingDown = false;
// Graceful shutdown handler async function shutdown(signal: string) { if (isShuttingDown) { console.log("Shutdown already in progress..."); return; } isShuttingDown = true; console.log(`\nReceived ${signal}, shutting down gracefully...`);
try { await manager.stop({ timeout: 30000, // Wait 30s for jobs to complete cancelOnTimeout: true, // Cancel remaining jobs after timeout cancelTimeout: 10000, // Give jobs 10s to respond to SIGTERM }); console.log("Shutdown complete"); process.exit(0); } catch (error) { console.error("Shutdown failed:", error); process.exit(1); } }
// Register signal handlers process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM"));
// Set up event logging manager.on("job:created", (p) => console.log(`Job started: ${p.job.id}`)); manager.on("job:completed", (p) => console.log(`Job completed: ${p.job.id}`)); manager.on("job:failed", (p) => console.error(`Job failed: ${p.job.id}`, p.error.message)); manager.on("error", (error) => console.error("Fleet error:", error.message));
// Start the fleet await manager.initialize(); await manager.start();
console.log(`Fleet running with ${manager.state.agentCount} agents`); console.log("Press Ctrl+C to stop");}
startDaemon().catch((error) => { console.error("Failed to start daemon:", error); process.exit(1);});Building a Simple CLI Wrapper
Section titled “Building a Simple CLI Wrapper”Create a custom CLI tool that wraps herdctl functionality.
import { Command } from "commander";import { FleetManager, isAgentNotFoundError, isConcurrencyLimitError } from "@herdctl/core";
const program = new Command();
program .name("my-fleet") .description("Custom fleet management CLI") .version("1.0.0");
// Shared manager factoryasync function createManager() { const manager = new FleetManager({ configPath: process.env.HERDCTL_CONFIG || "./herdctl.yaml", stateDir: process.env.HERDCTL_STATE || "./.herdctl", }); await manager.initialize(); return manager;}
// Status commandprogram .command("status") .description("Show fleet status") .action(async () => { const manager = await createManager(); const status = await manager.getFleetStatus();
console.log(`Fleet: ${status.state}`); console.log(`Agents: ${status.counts.totalAgents}`); console.log(`Running Jobs: ${status.counts.runningJobs}`);
if (status.uptimeSeconds !== null) { const hours = Math.floor(status.uptimeSeconds / 3600); const mins = Math.floor((status.uptimeSeconds % 3600) / 60); console.log(`Uptime: ${hours}h ${mins}m`); } });
// Trigger commandprogram .command("trigger <agent>") .description("Trigger an agent") .option("-s, --schedule <name>", "Use specific schedule") .option("-p, --prompt <text>", "Custom prompt") .option("-f, --force", "Bypass concurrency limit") .action(async (agent, options) => { const manager = await createManager(); await manager.start();
try { const result = await manager.trigger(agent, options.schedule, { prompt: options.prompt, bypassConcurrencyLimit: options.force, }); console.log(`Triggered job: ${result.jobId}`); } catch (error) { if (isAgentNotFoundError(error)) { console.error(`Agent not found: ${error.agentName}`); console.error(`Available: ${error.availableAgents?.join(", ")}`); process.exit(1); } if (isConcurrencyLimitError(error)) { console.error(`Agent at capacity (${error.currentJobs}/${error.limit})`); console.error("Use --force to bypass"); process.exit(1); } throw error; } finally { await manager.stop(); } });
// List agents commandprogram .command("agents") .description("List all agents") .action(async () => { const manager = await createManager(); const agents = await manager.getAgentInfo();
for (const agent of agents) { const statusIcon = agent.status === "running" ? "🟢" : agent.status === "error" ? "🔴" : "⚪"; console.log(`${statusIcon} ${agent.name} (${agent.runningCount}/${agent.maxConcurrent})`); for (const schedule of agent.schedules) { console.log(` └─ ${schedule.name}: ${schedule.status}`); } } });
program.parse();Building a Simple Web Dashboard (Express)
Section titled “Building a Simple Web Dashboard (Express)”Create a REST API and simple dashboard for monitoring your fleet.
import express from "express";import { FleetManager, isAgentNotFoundError } from "@herdctl/core";
const app = express();app.use(express.json());
// Create and initialize FleetManagerconst manager = new FleetManager({ configPath: "./herdctl.yaml", stateDir: "./.herdctl",});
// In-memory event store for recent activityconst recentEvents: Array<{ time: string; type: string; data: unknown }> = [];const MAX_EVENTS = 100;
function recordEvent(type: string, data: unknown) { recentEvents.unshift({ time: new Date().toISOString(), type, data }); if (recentEvents.length > MAX_EVENTS) recentEvents.pop();}
// Subscribe to eventsmanager.on("job:created", (p) => recordEvent("job:created", { jobId: p.job.id, agent: p.agentName }));manager.on("job:completed", (p) => recordEvent("job:completed", { jobId: p.job.id, duration: p.durationSeconds }));manager.on("job:failed", (p) => recordEvent("job:failed", { jobId: p.job.id, error: p.error.message }));
// API Routesapp.get("/api/status", async (req, res) => { try { const status = await manager.getFleetStatus(); res.json(status); } catch (error) { res.status(500).json({ error: String(error) }); }});
app.get("/api/agents", async (req, res) => { try { const agents = await manager.getAgentInfo(); res.json(agents); } catch (error) { res.status(500).json({ error: String(error) }); }});
app.get("/api/agents/:name", async (req, res) => { try { const agent = await manager.getAgentInfoByName(req.params.name); res.json(agent); } catch (error) { if (isAgentNotFoundError(error)) { res.status(404).json({ error: `Agent not found: ${error.agentName}` }); } else { res.status(500).json({ error: String(error) }); } }});
app.post("/api/trigger/:agent", async (req, res) => { try { const { schedule, prompt } = req.body; const result = await manager.trigger(req.params.agent, schedule, { prompt }); res.json(result); } catch (error) { if (isAgentNotFoundError(error)) { res.status(404).json({ error: `Agent not found: ${error.agentName}` }); } else { res.status(500).json({ error: String(error) }); } }});
app.get("/api/events", (req, res) => { res.json(recentEvents);});
// Simple HTML dashboardapp.get("/", (req, res) => { res.send(` <!DOCTYPE html> <html> <head> <title>Fleet Dashboard</title> <style> body { font-family: system-ui; max-width: 800px; margin: 2rem auto; padding: 0 1rem; } .card { border: 1px solid #ddd; padding: 1rem; margin: 1rem 0; border-radius: 8px; } .status { display: flex; gap: 2rem; } .stat { text-align: center; } .stat-value { font-size: 2rem; font-weight: bold; } .agent { display: flex; justify-content: space-between; align-items: center; } button { padding: 0.5rem 1rem; cursor: pointer; } .running { color: green; } .idle { color: gray; } .error { color: red; } </style> </head> <body> <h1>Fleet Dashboard</h1> <div id="status" class="card">Loading...</div> <h2>Agents</h2> <div id="agents">Loading...</div> <script> async function refresh() { const status = await fetch('/api/status').then(r => r.json()); document.getElementById('status').innerHTML = \` <div class="status"> <div class="stat"><div class="stat-value">\${status.state}</div><div>State</div></div> <div class="stat"><div class="stat-value">\${status.counts.totalAgents}</div><div>Agents</div></div> <div class="stat"><div class="stat-value">\${status.counts.runningJobs}</div><div>Running Jobs</div></div> </div> \`;
const agents = await fetch('/api/agents').then(r => r.json()); document.getElementById('agents').innerHTML = agents.map(a => \` <div class="card agent"> <div> <strong>\${a.name}</strong> <span class="\${a.status}">\${a.status}</span> <small>(\${a.runningCount}/\${a.maxConcurrent})</small> </div> <button onclick="trigger('\${a.name}')">Trigger</button> </div> \`).join(''); }
async function trigger(agent) { await fetch('/api/trigger/' + agent, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); refresh(); }
refresh(); setInterval(refresh, 5000); </script> </body> </html> `);});
// Start serverasync function start() { await manager.initialize(); await manager.start();
const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`Dashboard running at http://localhost:${port}`); });
// Graceful shutdown process.on("SIGTERM", async () => { await manager.stop(); process.exit(0); });}
start().catch(console.error);Integrating with Existing Fastify Server
Section titled “Integrating with Existing Fastify Server”Add herdctl endpoints to an existing Fastify application.
import Fastify, { FastifyInstance } from "fastify";import { FleetManager, isAgentNotFoundError, isJobNotFoundError } from "@herdctl/core";
// Fastify plugin for herdctlasync function herdctlPlugin(fastify: FastifyInstance, options: { configPath?: string; stateDir?: string }) { const manager = new FleetManager({ configPath: options.configPath || "./herdctl.yaml", stateDir: options.stateDir || "./.herdctl", });
await manager.initialize(); await manager.start();
// Decorate fastify with the manager fastify.decorate("fleet", manager);
// Register routes fastify.get("/fleet/status", async () => { return manager.getFleetStatus(); });
fastify.get("/fleet/agents", async () => { return manager.getAgentInfo(); });
fastify.get<{ Params: { name: string } }>("/fleet/agents/:name", async (request, reply) => { try { return await manager.getAgentInfoByName(request.params.name); } catch (error) { if (isAgentNotFoundError(error)) { return reply.status(404).send({ error: `Agent not found: ${error.agentName}` }); } throw error; } });
fastify.post<{ Params: { agent: string }; Body: { schedule?: string; prompt?: string }; }>("/fleet/trigger/:agent", async (request, reply) => { try { const { schedule, prompt } = request.body || {}; return await manager.trigger(request.params.agent, schedule, { prompt }); } catch (error) { if (isAgentNotFoundError(error)) { return reply.status(404).send({ error: `Agent not found: ${error.agentName}` }); } throw error; } });
fastify.delete<{ Params: { jobId: string } }>("/fleet/jobs/:jobId", async (request, reply) => { try { return await manager.cancelJob(request.params.jobId); } catch (error) { if (isJobNotFoundError(error)) { return reply.status(404).send({ error: `Job not found: ${error.jobId}` }); } throw error; } });
// Cleanup on server close fastify.addHook("onClose", async () => { await manager.stop(); });}
// Usage with existing Fastify appconst fastify = Fastify({ logger: true });
// Register your existing plugins and routes...// fastify.register(someOtherPlugin);
// Register herdctl pluginfastify.register(herdctlPlugin, { configPath: "./herdctl.yaml", stateDir: "./.herdctl",});
// Start serverfastify.listen({ port: 3000 }, (err) => { if (err) { fastify.log.error(err); process.exit(1); }});Running in CI/CD Pipeline (GitHub Actions)
Section titled “Running in CI/CD Pipeline (GitHub Actions)”Run herdctl as part of your CI/CD pipeline with proper exit codes.
name: Run Agent Task
on: workflow_dispatch: inputs: agent: description: 'Agent to trigger' required: true type: string prompt: description: 'Task prompt' required: false type: string schedule: # Run daily at midnight UTC - cron: '0 0 * * *'
jobs: run-agent: runs-on: ubuntu-latest timeout-minutes: 30
steps: - uses: actions/checkout@v4
- uses: actions/setup-node@v4 with: node-version: '20'
- name: Install dependencies run: npm ci
- name: Run agent task env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx tsx scripts/ci-runner.ts timeout-minutes: 25import { FleetManager, isAgentNotFoundError } from "@herdctl/core";
async function main() { // Get inputs from environment (set by GitHub Actions) const agentName = process.env.INPUT_AGENT || process.env.DEFAULT_AGENT || "ci-agent"; const prompt = process.env.INPUT_PROMPT; const timeout = parseInt(process.env.JOB_TIMEOUT || "1200000"); // 20 min default
console.log(`Running agent: ${agentName}`); if (prompt) console.log(`Prompt: ${prompt}`);
const manager = new FleetManager({ configPath: "./herdctl.yaml", stateDir: "./.herdctl", });
let exitCode = 0;
try { await manager.initialize(); await manager.start();
// trigger() resolves when the job has finished. Stream output to the // CI log as it happens via the onMessage callback, and enforce a // timeout by cancelling the job once its ID is known. let timeoutHandle: NodeJS.Timeout | undefined;
const result = await manager.trigger(agentName, undefined, { prompt, onJobCreated: (jobId) => { console.log(`Job started: ${jobId}`); timeoutHandle = setTimeout(() => { console.error(`\n✗ Job timed out after ${timeout / 1000}s, cancelling`); manager.cancelJob(jobId).catch((error) => { console.error("Failed to cancel job:", error); }); }, timeout); }, onMessage: (message) => { // Print the agent's text output as it streams if (message.type === "assistant" && message.content) { console.log(message.content); } }, });
clearTimeout(timeoutHandle);
if (result.success) { console.log(`\n✓ Job ${result.jobId} completed`); } else { console.error(`\n✗ Job ${result.jobId} failed: ${result.error?.message ?? "unknown error"}`); }
exitCode = result.success ? 0 : 1; } catch (error) { if (isAgentNotFoundError(error)) { console.error(`Agent not found: ${error.agentName}`); console.error(`Available agents: ${error.availableAgents?.join(", ")}`); } else { console.error("Error:", error); } exitCode = 1; } finally { await manager.stop({ timeout: 10000, cancelOnTimeout: true }); }
process.exit(exitCode);}
main();Hot-Reloading Configuration Changes
Section titled “Hot-Reloading Configuration Changes”Watch for configuration changes and reload without restarting.
import { watch } from "fs";import { FleetManager } from "@herdctl/core";import { debounce } from "./utils"; // See helper below
async function startWithHotReload() { const configPath = "./herdctl.yaml"; const agentsDir = "./agents";
const manager = new FleetManager({ configPath, stateDir: "./.herdctl", });
// Handle reload events manager.on("config:reloaded", (payload) => { console.log(`Config reloaded: ${payload.agentCount} agents`); for (const change of payload.changes) { console.log(` ${change.type} ${change.category}: ${change.name}`); } });
await manager.initialize(); await manager.start();
// Debounced reload function const reload = debounce(async () => { console.log("Configuration changed, reloading..."); try { await manager.reload(); } catch (error) { console.error("Reload failed:", error); } }, 500);
// Watch main config file watch(configPath, (eventType) => { if (eventType === "change") reload(); });
// Watch agents directory watch(agentsDir, { recursive: true }, (eventType, filename) => { if (filename?.endsWith(".yaml") || filename?.endsWith(".yml")) { reload(); } });
console.log("Watching for config changes..."); console.log(` ${configPath}`); console.log(` ${agentsDir}/**/*.yaml`);
// Graceful shutdown process.on("SIGINT", async () => { await manager.stop(); process.exit(0); });}
startWithHotReload().catch(console.error);// Simple debounce helperexport function debounce<T extends (...args: unknown[]) => unknown>( fn: T, delay: number): (...args: Parameters<T>) => void { let timeoutId: NodeJS.Timeout; return (...args: Parameters<T>) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); };}TypeScript Project Setup from Scratch
Section titled “TypeScript Project Setup from Scratch”Complete setup for a new TypeScript project using herdctl.
Project Structure
Section titled “Project Structure”my-fleet/├── src/│ └── index.ts├── agents/│ └── example-agent.yaml├── herdctl.yaml├── package.json└── tsconfig.jsonpackage.json
Section titled “package.json”{ "name": "my-fleet", "version": "1.0.0", "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "build": "tsc", "typecheck": "tsc --noEmit" }, "dependencies": { "@herdctl/core": "^5.0.0" }, "devDependencies": { "@types/node": "^20.0.0", "tsx": "^4.0.0", "typescript": "^5.0.0" }}tsconfig.json
Section titled “tsconfig.json”{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, "sourceMap": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]}herdctl.yaml
Section titled “herdctl.yaml”agents: - path: ./agents/example-agent.yaml
defaults: workspace: . max_concurrent: 1agents/example-agent.yaml
Section titled “agents/example-agent.yaml”name: example-agentdescription: Example agent for demonstration
schedules: - name: hourly trigger: type: interval interval: 1h prompt: | Check the project status and report any issues.src/index.ts
Section titled “src/index.ts”import { FleetManager } from "@herdctl/core";
const manager = new FleetManager({ configPath: "./herdctl.yaml", stateDir: "./.herdctl",});
manager.on("initialized", () => console.log("Fleet initialized"));manager.on("started", () => console.log("Fleet started"));manager.on("job:created", (p) => console.log(`Job created: ${p.job.id}`));manager.on("job:completed", (p) => console.log(`Job completed: ${p.job.id}`));
async function main() { await manager.initialize(); await manager.start();
process.on("SIGINT", async () => { await manager.stop(); process.exit(0); });}
main().catch(console.error);Getting Started
Section titled “Getting Started”# Install dependenciespnpm install
# Run in development mode (with hot reload)pnpm dev
# Build for productionpnpm build
# Run production buildpnpm startMonorepo Integration Patterns
Section titled “Monorepo Integration Patterns”Integrate herdctl into a monorepo managed by pnpm, npm workspaces, or Turborepo.
Project Structure
Section titled “Project Structure”my-monorepo/├── apps/│ ├── web/│ └── fleet-service/ # herdctl service│ ├── src/│ │ └── index.ts│ ├── agents/│ ├── herdctl.yaml│ └── package.json├── packages/│ ├── shared/ # Shared utilities│ └── agent-prompts/ # Shared prompts├── package.json├── pnpm-workspace.yaml└── turbo.jsonpnpm-workspace.yaml
Section titled “pnpm-workspace.yaml”packages: - 'apps/*' - 'packages/*'Root package.json
Section titled “Root package.json”{ "name": "my-monorepo", "private": true, "scripts": { "dev:fleet": "pnpm --filter fleet-service dev", "build": "turbo build", "typecheck": "turbo typecheck" }, "devDependencies": { "turbo": "^2.0.0" }}Fleet service package.json
Section titled “Fleet service package.json”{ "name": "fleet-service", "version": "1.0.0", "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", "typecheck": "tsc --noEmit" }, "dependencies": { "@herdctl/core": "^5.0.0", "@repo/shared": "workspace:*", "@repo/agent-prompts": "workspace:*" }}Using shared packages in prompts
Section titled “Using shared packages in prompts”import { FleetManager } from "@herdctl/core";import { formatPrompt } from "@repo/agent-prompts";import { logger } from "@repo/shared";
const manager = new FleetManager({ configPath: "./herdctl.yaml", stateDir: "./.herdctl", logger,});
// Use shared prompt templatesmanager.on("schedule:triggered", async (payload) => { const enhancedPrompt = formatPrompt(payload.scheduleName, { timestamp: new Date().toISOString(), environment: process.env.NODE_ENV, }); // Prompts are defined in the YAML config, but you can log enhanced versions logger.info(`Triggered with context: ${enhancedPrompt}`);});
// ... rest of setupTurborepo configuration
Section titled “Turborepo configuration”{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "dev": { "cache": false, "persistent": true }, "typecheck": { "dependsOn": ["^typecheck"] } }}Testing Patterns (Unit Testing with Mocks)
Section titled “Testing Patterns (Unit Testing with Mocks)”Test your herdctl integration with mocked FleetManager.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";import { FleetManager } from "@herdctl/core";import type { FleetStatus, AgentInfo, TriggerResult } from "@herdctl/core";
// Mock the FleetManagervi.mock("@herdctl/core", () => { const mockManager = { initialize: vi.fn(), start: vi.fn(), stop: vi.fn(), getFleetStatus: vi.fn(), getAgentInfo: vi.fn(), trigger: vi.fn(), on: vi.fn(), off: vi.fn(), state: { status: "initialized", agentCount: 2 }, };
return { FleetManager: vi.fn(() => mockManager), isAgentNotFoundError: (e: unknown) => e instanceof Error && e.message.includes("not found"), };});
// Your application code that uses FleetManagerclass FleetService { private manager: FleetManager;
constructor(configPath: string) { this.manager = new FleetManager({ configPath, stateDir: "./.herdctl" }); }
async start() { await this.manager.initialize(); await this.manager.start(); }
async stop() { await this.manager.stop(); }
async getStatus(): Promise<FleetStatus> { return this.manager.getFleetStatus(); }
async runAgent(name: string, prompt?: string): Promise<TriggerResult> { return this.manager.trigger(name, undefined, { prompt }); }}
describe("FleetService", () => { let service: FleetService; let mockManager: ReturnType<typeof vi.fn> & { initialize: ReturnType<typeof vi.fn>; start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; getFleetStatus: ReturnType<typeof vi.fn>; trigger: ReturnType<typeof vi.fn>; };
beforeEach(() => { vi.clearAllMocks(); service = new FleetService("./herdctl.yaml"); // Get the mock instance mockManager = (FleetManager as unknown as ReturnType<typeof vi.fn>).mock.results[0].value; });
describe("start", () => { it("should initialize and start the manager", async () => { mockManager.initialize.mockResolvedValue(undefined); mockManager.start.mockResolvedValue(undefined);
await service.start();
expect(mockManager.initialize).toHaveBeenCalledOnce(); expect(mockManager.start).toHaveBeenCalledOnce(); }); });
describe("getStatus", () => { it("should return fleet status", async () => { const mockStatus: FleetStatus = { state: "running", uptimeSeconds: 3600, initializedAt: "2024-01-01T00:00:00Z", startedAt: "2024-01-01T00:00:00Z", stoppedAt: null, lastError: null, counts: { totalAgents: 2, idleAgents: 1, runningAgents: 1, errorAgents: 0, totalSchedules: 4, runningSchedules: 1, runningJobs: 1, }, scheduler: { status: "running", checkCount: 100, triggerCount: 5, lastCheckAt: "2024-01-01T01:00:00Z", checkIntervalMs: 5000, }, };
mockManager.getFleetStatus.mockResolvedValue(mockStatus);
const status = await service.getStatus();
expect(status.state).toBe("running"); expect(status.counts.totalAgents).toBe(2); }); });
describe("runAgent", () => { it("should trigger agent with prompt", async () => { const mockResult: TriggerResult = { jobId: "job-123", agentName: "my-agent", scheduleName: null, startedAt: "2024-01-01T00:00:00Z", prompt: "Test prompt", };
mockManager.trigger.mockResolvedValue(mockResult);
const result = await service.runAgent("my-agent", "Test prompt");
expect(mockManager.trigger).toHaveBeenCalledWith("my-agent", undefined, { prompt: "Test prompt", }); expect(result.jobId).toBe("job-123"); }); });});Integration Test with Real FleetManager
Section titled “Integration Test with Real FleetManager”import { describe, it, expect, beforeAll, afterAll } from "vitest";import { FleetManager } from "@herdctl/core";import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";import { tmpdir } from "os";import { join } from "path";
describe("FleetManager Integration", () => { let testDir: string; let manager: FleetManager;
beforeAll(async () => { // Create temporary test directory testDir = await mkdtemp(join(tmpdir(), "herdctl-test-"));
// Write test configuration await writeFile( join(testDir, "herdctl.yaml"), `agents: - path: ./agents/test-agent.yaml
defaults: workspace: ${testDir} max_concurrent: 1` );
// Create agents directory and test agent await mkdir(join(testDir, "agents")); await writeFile( join(testDir, "agents", "test-agent.yaml"), `name: test-agentdescription: Test agent
schedules: - name: manual trigger: type: webhook prompt: Test prompt` );
manager = new FleetManager({ configPath: join(testDir, "herdctl.yaml"), stateDir: join(testDir, ".herdctl"), }); });
afterAll(async () => { if (manager.state.status === "running") { await manager.stop(); } await rm(testDir, { recursive: true, force: true }); });
it("should initialize with test config", async () => { await manager.initialize(); expect(manager.state.status).toBe("initialized"); expect(manager.state.agentCount).toBe(1); });
it("should start and show running status", async () => { await manager.start(); const status = await manager.getFleetStatus(); expect(status.state).toBe("running"); });
it("should list agents", async () => { const agents = await manager.getAgentInfo(); expect(agents).toHaveLength(1); expect(agents[0].name).toBe("test-agent"); });
it("should stop gracefully", async () => { await manager.stop(); expect(manager.state.status).toBe("stopped"); });});Programmatic Schedule Management
Section titled “Programmatic Schedule Management”Manage agent schedules at runtime without rewriting herdctl.yaml. This is useful for building dynamic UIs or admin panels where users can create, modify, or remove schedules on the fly.
Enabling Schedule Mutation
Section titled “Enabling Schedule Mutation”Schedule mutation must be explicitly enabled when constructing the FleetManager:
import { FleetManager } from "@herdctl/core";
const manager = new FleetManager({ configPath: "./herdctl.yaml", stateDir: "./.herdctl", allowScheduleMutation: true, // Required for runtime schedule changes});
await manager.initialize();await manager.start();Adding a Schedule at Runtime
Section titled “Adding a Schedule at Runtime”Use setAgentSchedule() to add a new schedule or replace an existing one:
// Add a new interval scheduleawait manager.setAgentSchedule("my-agent", "hourly-check", { type: "interval", interval: "1h", prompt: "Check for updates every hour",});
// Add a cron scheduleawait manager.setAgentSchedule("my-agent", "daily-report", { type: "cron", cron: "0 9 * * *", prompt: "Generate the daily report at 9am",});The schedule starts immediately — no restart required.
Replacing a Schedule
Section titled “Replacing a Schedule”Calling setAgentSchedule() with an existing schedule name replaces it:
// Original scheduleawait manager.setAgentSchedule("my-agent", "check", { type: "interval", interval: "5m", prompt: "Check for new issues",});
// Update the interval and promptawait manager.setAgentSchedule("my-agent", "check", { type: "interval", interval: "10m", prompt: "Check for high-priority issues only",});The old schedule is removed and the new one is activated.
Removing a Schedule
Section titled “Removing a Schedule”Use removeAgentSchedule() to delete a schedule by name:
const removed = await manager.removeAgentSchedule("my-agent", "hourly-check");if (removed) { console.log("Schedule removed successfully");}Returns true if the schedule existed and was removed, false if the schedule was not found.
When to Use Runtime Mutation
Section titled “When to Use Runtime Mutation”Use runtime schedule mutation when:
- Building admin dashboards where users manage agent schedules via UI
- Implementing dynamic scheduling based on external events
- Creating self-modifying agents that adjust their own schedules
- Prototyping schedules before committing them to config files
Use config-file schedules when:
- Schedules are part of the agent’s core definition
- You want version control and GitOps-style deployments
- Schedules are static and don’t need runtime changes
- You need schedules to survive fleet restarts without persistence logic
Runtime schedules exist only in memory — they are not written back to herdctl.yaml. If you restart the fleet without re-creating them programmatically, they disappear. For persistent runtime schedules, you need to store them externally (e.g., in a database) and re-apply them on fleet startup.
Complete Example: Dynamic Schedule Manager
Section titled “Complete Example: Dynamic Schedule Manager”import { FleetManager } from "@herdctl/core";
class ScheduleManager { private manager: FleetManager;
constructor() { this.manager = new FleetManager({ configPath: "./herdctl.yaml", stateDir: "./.herdctl", allowScheduleMutation: true, }); }
async start() { await this.manager.initialize(); await this.manager.start(); }
async addIntervalSchedule( agent: string, name: string, interval: string, prompt: string ) { await this.manager.setAgentSchedule(agent, name, { type: "interval", interval, prompt, }); console.log(`Added interval schedule "${name}" for ${agent}`); }
async addCronSchedule( agent: string, name: string, cron: string, prompt: string ) { await this.manager.setAgentSchedule(agent, name, { type: "cron", cron, prompt, }); console.log(`Added cron schedule "${name}" for ${agent}`); }
async removeSchedule(agent: string, name: string) { const removed = await this.manager.removeAgentSchedule(agent, name); if (removed) { console.log(`Removed schedule "${name}" from ${agent}`); } else { console.warn(`Schedule "${name}" not found for ${agent}`); } }
async listSchedules(agent: string) { const agentInfo = await this.manager.getAgentInfo(agent); return agentInfo.schedules; }}
// Usageconst scheduleManager = new ScheduleManager();await scheduleManager.start();
// Add dynamic schedulesawait scheduleManager.addIntervalSchedule( "my-agent", "check-every-30m", "30m", "Check for new work");
await scheduleManager.addCronSchedule( "my-agent", "daily-summary", "0 17 * * *", "Generate daily summary at 5pm");
// List all schedules for the agentconst schedules = await scheduleManager.listSchedules("my-agent");console.log(schedules);
// Remove a scheduleawait scheduleManager.removeSchedule("my-agent", "check-every-30m");Next Steps
Section titled “Next Steps”- Check out the FleetManager API Reference for complete method documentation
- Learn about Error Handling patterns
- Explore Event Types for real-time monitoring