Agent Orchestration in Practice: How Nocturna Uses Hermes for Kanban Automation
Most AI agent demos stop at a chat window. Nocturna goes further — it's a full kanban control center that spawns Hermes subagents, runs scheduled tasks via cron, delegates work to peer agents, and manages it all through a real-time board UI. This post walks through the actual integration architecture, with code from the Nocturna repo.
Why Orchestration Matters
Running a single LLM call is easy. Running reliable, scheduled, multi-agent workflows — where tasks have lifecycles, failures are surfaced, and humans can intervene — that's the hard part. Nocturna solves this by wrapping the Hermes Agent CLI and Gateway API in a structured kanban board that treats AI tasks as first-class citizens with states, priorities, and lifecycle transitions.
The core insight: an AI agent is not a chatbot. It's a worker process. And worker processes need queues, supervisors, and dashboards.
The Three Integration Points
Nocturna talks to Hermes through three distinct channels, each with its own code path:
1. Local CLI — Spawning the hermes Binary
When Hermes runs on the same machine, Nocturna spawns the hermes CLI binary directly via execFile:
// server/kanbanCli.ts
export async function runCliRaw(args: string[], options: ExecCliOptions = {}): Promise<string> {
const bin = options.binPath || getHermesBin();
const timeout = options.timeoutMs ?? 10000;
return new Promise((resolve, reject) => {
const child = execFile(bin, args, {
timeout,
cwd: options.cwd || process.cwd(),
env: { ...process.env, ...options.env },
maxBuffer: 10 * 1024 * 1024,
}, (error, stdout, stderr) => {
if (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return reject(new CliError(
'engine_unreachable',
`Hermes CLI binary not found at '${bin}'.`,
error.message
));
}
if ((error as { killed?: boolean }).killed) {
return reject(new CliError(
'engine_unreachable',
`Hermes CLI timed out after ${timeout}ms.`,
stderr || error.message
));
}
return reject(new CliError(
'cli_error',
`Hermes CLI exited with code ${error.code ?? 1}`,
stderr || stdout || error.message
));
}
resolve(stdout.toString());
});
});
}
The binary detection logic searches multiple candidate paths:
export function getHermesBin(): string {
if (process.env.NOCTURNA_HERMES_BIN) return process.env.NOCTURNA_HERMES_BIN;
const candidatePaths = [
'/usr/local/bin/hermes',
'/usr/bin/hermes',
path.join(process.env.HOME || '', '.local', 'bin', 'hermes'),
path.join(process.env.HOME || '', '.hermes', 'hermes-agent', 'hermes'),
path.join(process.cwd(), 'tests', 'fake-hermes'),
];
for (const candidate of candidatePaths) {
if (candidate && fs.existsSync(candidate)) {
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {}
}
}
return 'hermes';
}
Every CLI call goes through runCliJson, which parses the output as JSON and wraps parse failures in a typed CliError:
export async function runCliJson<T = unknown>(args: string[], options: ExecCliOptions = {}): Promise<T> {
const raw = await runCliRaw(args, options);
try {
return JSON.parse(raw.trim()) as T;
} catch (err) {
throw new CliError(
'cli_error',
'Failed to parse JSON output from Hermes CLI',
`Raw output: ${raw.slice(0, 500)}. Error: ${(err as Error).message}`
);
}
}
2. Gateway Mode — HTTP API to a Remote Hermes Instance
When Hermes is running elsewhere (another Pi on the LAN, or a remote server), Nocturna switches to Gateway mode — an HTTP client with authentication, timeout handling, and fallback auth strategies:
// server/gatewayClient.ts
export async function gatewayFetch<T = unknown>(
endpoint: string,
options: GatewayFetchOptions = {}
): Promise<T> {
const config = options.config || getGatewayConfig();
if (!config || !config.baseUrl) {
throw new CliError('engine_unreachable', 'Hermes Gateway URL is not configured.');
}
const fullUrl = `${config.baseUrl}${endpoint}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5000);
const headers = new Headers(options.headers || {});
if (config.password) {
headers.set('Authorization', `Bearer ${config.password}`);
headers.set('X-Hermes-Password', config.password);
}
let res = await fetch(fullUrl, { ...options, headers, signal: controller.signal });
// Fallback: If 401 with Bearer token, attempt Basic Auth
if (res.status === 401 && config.password) {
const basicAuth = Buffer.from(`admin:${config.password}`).toString('base64');
const retryHeaders = new Headers(headers);
retryHeaders.set('Authorization', `Basic ${basicAuth}`);
res = await fetch(fullUrl, { ...options, headers: retryHeaders, signal: controller.signal });
}
// ...
}
The branching logic is visible in every route. Here's the task listing endpoint:
// server/routes/tasks.ts
tasksRouter.get('/', async (req, res, next) => {
const config = configFromInstance(req.activeInstance);
if (isGatewayActive(req.activeInstance)) {
const gatewayRes = await gatewayFetch<unknown>(`/api/tasks${gatewayQuery}`, { config });
// ... transform gateway response
return res.json({ tasks });
}
// CLI fallback
const args = appendBoardArg(['kanban', 'list', '--json'], board);
const rawTasks = await runCliJson<TaskDto[]>(args);
// ...
});
3. SSH Remote — Running Hermes on a Distant Host
For instances accessible via SSH, Nocturna uses the ssh2 library to execute Hermes commands remotely:
// server/instanceRunner.ts
export async function executeSshCommand(
instance: InstanceRecord,
command: string,
timeoutMs = 10000
): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
const conn = new SshClient();
const timer = setTimeout(() => {
conn.end();
reject(new Error(`SSH connection timed out after ${timeoutMs}ms`));
}, timeoutMs);
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) { clearTimeout(timer); conn.end(); return reject(err); }
let stdout = '', stderr = '';
stream.on('close', (code: number) => {
clearTimeout(timer); conn.end();
resolve({ stdout, stderr, code: code ?? 0 });
});
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
});
});
// ...
});
}
Subagent Delegation
One of Nocturna's most powerful features is delegation — the ability to spawn subagent processes for specific tasks. This is a Gateway-only feature (the CLI doesn't support it), which means the route returns a clear 501 when running in CLI mode:
// server/routes/delegate.ts
delegateRouter.post('/', async (req, res, next) => {
if (!isGatewayActive(req.activeInstance)) {
return res.status(501).json({
error: {
code: 'not_found',
message: 'Delegation requires Gateway connection',
detail: 'Subagent delegation is a Gateway tool, not available via CLI',
},
});
}
const config = configFromInstance(req.activeInstance);
const result = await gatewayFetch('/api/delegate', {
method: 'POST',
body: JSON.stringify(req.body),
config,
});
return res.json(result);
});
The delegation API supports three operations beyond the initial spawn:
- Status check —
GET /api/delegate/status/:idpolls the subagent's progress - Steer —
POST /api/delegate/:id/steerinjects a mid-run course correction - Stop —
POST /api/delegate/:id/stopterminates the subagent
This mirrors the Hermes Agent's own A2A (agent-to-agent) protocol. Nocturna acts as the control plane, while the actual agent work happens in spawned processes.
Cron: Scheduled Agent Work
Not every task needs to be triggered manually. Nocturna exposes Hermes's cron system through a full REST API. Here's how a cron job is created:
// server/routes/cron.ts
cronRouter.post('/', async (req, res, next) => {
if (isGatewayActive(req.activeInstance)) {
const result = await gatewayFetch('/api/cron', {
method: 'POST',
body: JSON.stringify(req.body || {}),
config,
});
return res.status(201).json(result);
}
// CLI mode: build args from request body
const body = req.body || {};
const args: string[] = ['cron', 'create', body.schedule, body.prompt];
if (body.name) args.push('--name', body.name);
if (body.skill && body.skill.length > 0) {
for (const s of body.skill) args.push('--skill', s);
}
if (body.workdir) args.push('--workdir', body.workdir);
if (body.model) args.push('--model', body.model);
args.push('--json');
const result = await runCliJson(args, { env: buildBoardEnv(board) });
return res.status(201).json(result);
});
The --skill flag is particularly important — it lets you attach Hermes skills to a cron job, so the scheduled agent has the right tools loaded. For example, a nightly backup verification cron might use --skill hermes-agent --skill disk-space-management.
The cron API supports the full lifecycle: list, create, pause, resume, run (manual trigger), remove, and runs (execution history).
Loops: Session-Scoped Recurring Tasks
Beyond cron (which persists across sessions), Nocturna also exposes loops — interval-based recurring tasks that exist within a Hermes Gateway session. These are Gateway-only:
// server/routes/loops.ts
loopsRouter.post('/', async (req, res, next) => {
const { interval, prompt } = req.body || {};
if (!interval || !prompt) {
return res.status(422).json({
error: { code: 'validation', message: 'interval and prompt are required' },
});
}
if (isGatewayActive(req.activeInstance)) {
const body: Record<string, unknown> = { interval, prompt };
if (req.body.times !== undefined) body.times = req.body.times;
if (req.body.until !== undefined) body.until = req.body.until;
const result = await gatewayFetch('/api/loops', {
method: 'POST',
body: JSON.stringify(body),
config,
});
return res.status(201).json(result);
}
return res.status(501).json({
error: {
code: 'not_found',
message: 'Loop management requires Gateway connection',
},
});
});
The difference: cron jobs survive restarts; loops don't. Loops are ideal for "check every 5 minutes while I'm watching the board" scenarios.
The Honest State Model
Nocturna doesn't fake success. When a task fails, it stays failed on the board. Direct status moves are explicitly rejected:
// server/routes/tasks.ts
tasksRouter.patch('/:id/status', (req, res) => {
return res.status(409).json({
error: {
code: 'forbidden_move',
message: 'Direct status moves are forbidden by Hermes state machine',
detail: 'Use specific lifecycle action endpoints (e.g. /complete, /block, /unblock, /promote, /archive).',
},
});
});
Instead, tasks move through lifecycle actions: complete, block, unblock, promote, archive. Each emits a hook event:
// server/routes/actions.ts
actionsRouter.post('/:id/complete', async (req, res, next) => {
const id = req.params.id;
const { summary } = req.body || {};
const args = ['kanban', 'complete', id];
if (summary) args.push('--summary', summary);
await runCliJson(args);
await emitHook('kanban_task_completed', {
task_id: id, status: 'done', timestamp: Math.floor(Date.now() / 1000)
});
return res.json({ ok: true, id, status: 'done' });
});
The hook system allows side effects — for example, desktop notifications when a task is blocked:
// server/hooks.ts
if (process.env.HERMES_HOOK_NOTIFY === '1') {
registerHook('kanban_task_blocked', async (data) => {
try {
await execFileAsync('notify-send', [
'Hermes Task Blocked',
`Task ${data.task_id} was blocked at ${new Date(data.timestamp * 1000).toLocaleString()}`,
]);
} catch { /* notify-send not available */ }
});
}
Board Scope: Multi-Board Isolation
Nocturna supports multiple kanban boards through the HERMES_KANBAN_BOARD environment variable. The board scope is resolved per-request:
// server/boardScope.ts
export function resolveBoardSlug(slug?: string | null): string {
if (slug && slug.trim()) {
const cleaned = slug.trim();
if (!SLUG_REGEX.test(cleaned)) {
throw new CliError('validation', `Invalid board slug format: '${cleaned}'.`);
}
return cleaned;
}
return process.env.HERMES_KANBAN_BOARD || 'default';
}
export function buildBoardEnv(slug?: string): Record<string, string> {
return slug ? { HERMES_KANBAN_BOARD: slug } : {};
}
This means every CLI invocation carries the board context as an env var, while Gateway requests pass it as a query parameter. The board abstraction lets you run separate boards for different projects — nocturna for agent tasks, thuis for media automation, etc.
LAN Discovery: Finding Hermes on Your Network
One of Nocturna's standout features is automatic LAN discovery. It scans your local network for running Hermes instances:
// server/instanceRunner.ts
export async function scanLanForHermes(
targetIp?: string,
targetPorts: number[] = [8080, 8000, 3000, 5000, 8088, 9090]
): Promise<DiscoveredLanInstance[]> {
// Build IP list from network interfaces
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name] || []) {
if (iface.family === 'IPv4' && !iface.internal) {
const parts = iface.address.split('.');
const subnetPrefix = `${parts[0]}.${parts[1]}.${parts[2]}`;
const commonOffsets = [1, 2, 5, 10, 20, 50, 64, 100, 105, 150];
for (const offset of commonOffsets) {
ipsToProbe.push(`${subnetPrefix}.${offset}`);
}
}
}
}
// Probe candidates in parallel with 1.2s timeout
for (const ip of ipsToProbe) {
for (const port of targetPorts) {
probeTasks.push(
(async () => {
const res = await fetch(`http://${ip}:${port}/api/tasks?limit=1`, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if ([200, 401, 403, 404].includes(res.status)) {
discovered.push({ ip, port, url: `http://${ip}:${port}`, ... });
}
})()
);
}
}
await Promise.allSettled(probeTasks);
return discovered;
}
This is how Nocturna finds a Hermes instance running on another Raspberry Pi at 192.168.0.5:8787 without any manual configuration.
Data Flow: SQLite + Hermes kanban.db
Nocturna stores its own state (users, sessions, instances) in a local SQLite database at data/nocturna.db, using Node 22's built-in node:sqlite module — no external database server needed. The actual task data lives in Hermes's kanban.db.
The data flow is:
Browser → Nocturna API → { gatewayFetch or runCliJson } → Hermes
↓
data/nocturna.db (auth, sessions, instances)
Nocturna never duplicates task state. Every board view is a live read from Hermes. This means the board always reflects the true state of the agent workforce — no stale caches, no sync issues.
Error Handling: What Happens When Agents Fail
Every error in Nocturna follows a structured shape: { error: { code, message, detail } }. The error codes are:
engine_unreachable— Hermes is down or the binary is missingcli_error— Hermes ran but returned a non-zero exit codenot_found— Resource doesn't existvalidation— Bad request inputforbidden_move— Attempted direct status change (409)
When the engine is unreachable, Nocturna marks the instance as offline:
if (err instanceof CliError && err.code === 'engine_unreachable') {
updateInstance(req.activeInstance.id, req.user.id, {
status: 'unreachable',
last_error: err.message,
last_checked: Date.now(),
});
}
The board UI then shows the instance as disconnected, and the user can switch to another instance or fix the connection — rather than seeing opaque errors.
Extending the Pattern
The architecture Nocturna demonstrates is applicable beyond kanban:
- Wrap, don't replace — Nocturna doesn't reimplement Hermes; it wraps the existing CLI and API with a structured UI.
- Dual-mode by default — Every route supports both CLI and Gateway. This means the app works locally (zero network config) and remotely (zero local install).
- Honest state — Don't hide failures. Blocked tasks stay blocked. The UI shows reality, not aspiration.
- Hookable lifecycle — Every state transition emits an event. This lets you add notifications, logging, or integrations without modifying core logic.
- Board isolation — Multiple boards via environment variable, not config files. One Hermes instance, many projects.
Running It Yourself
Nocturna deploys with a single Docker command on a Raspberry Pi 5:
git clone https://github.com/Aldo-f/Nocturna.git
cd nocturna
docker compose up -d --build
The docker-compose.yml mounts ~/.hermes read-only (so the in-container CLI works), persists the SQLite database in a named volume, and integrates with Traefik for automatic TLS.
If Hermes is already installed, Nocturna auto-detects it. If not, the Setup Wizard walks you through connecting via Gateway URL, LAN discovery, or SSH.
Next Steps
- Board templates (scrum, kanban, custom columns)
- Webhook receiver for external triggers
- Mobile-responsive board view
- Export/import board JSON