Skip to main content

Overview

Polpo has 15 hook points that let you intercept, modify, or observe every stage of the orchestration lifecycle. Hooks are the primary extension mechanism — use them to add custom validation, logging, notifications, SLA enforcement, and more.

Hook Phases

Each hook point supports two phases:
PhaseBehavior
beforeRuns before the operation. Can cancel the operation or modify the payload. Handlers are awaited sequentially.
afterRuns after the operation completes. Fire-and-forget — cannot cancel or modify anything. Errors are logged but never propagate.
There is also a synchronous variant:
PhaseBehavior
beforeSyncSynchronous version of before for hot paths where async is not feasible. Async handlers are skipped with a warning.

All 15 Hook Points

Task Lifecycle (6 hooks)

HookPayloadDescription
task:create{ title, description, assignTo, expectations?, dependsOn?, group?, maxDuration?, retryPolicy? }Fired when a task is about to be created. before can modify fields or cancel creation.
task:spawn{ task, agent }Fired before a runner is spawned for a task. before can cancel spawning.
task:transition{ taskId, from, to, task }Fired on every state transition. before can cancel the transition.
task:complete{ taskId, task, result?, assessment? }Fired when a task reaches done.
task:fail{ taskId, task, result?, reason? }Fired when a task reaches failed.
task:retry{ taskId, task, attempt, maxRetries }Fired when a task is about to be retried. before can cancel the retry.

Mission Lifecycle (2 hooks)

HookPayloadDescription
mission:execute{ missionId, mission, taskCount }Fired before a mission starts execution. before can cancel the mission.
mission:complete{ missionId, mission, allPassed, report }Fired when all tasks in a mission are finished.

Assessment (2 hooks)

HookPayloadDescription
assessment:run{ taskId, task }Fired before assessment begins on a completed task.
assessment:complete{ taskId, task, assessment, passed }Fired when assessment finishes with results.

Quality & SLA (2 hooks)

HookPayloadDescription
quality:gate{ missionId, gateName, avgScore?, allPassed, tasks[] }Fired when a quality gate is evaluated. tasks includes each task’s ID, title, status, and score.
quality:sla{ entityId, entityType, deadline, status, percentUsed }Fired on SLA warning or violation. entityType is "task" or "mission". status is "warning" or "violated".

Scheduling (1 hook)

HookPayloadDescription
schedule:trigger{ scheduleId, missionId, expression }Fired when a cron schedule triggers a mission execution. before can cancel the trigger.

Orchestrator (2 hooks)

HookPayloadDescription
orchestrator:tick{ pending, running, done, failed }Fired on every tick of the supervisor loop (every 5 seconds).
orchestrator:shutdown{}Fired when the orchestrator is shutting down.
The orchestrator:shutdown hook payload is an empty object (Record<string, never>). It exists primarily for cleanup logic in after handlers.

Priority System

Handlers are sorted by priority (ascending) and run sequentially. Lower numbers run first.
PriorityConvention
1–49System-critical hooks (validation, security)
50–99High-priority user hooks
100Default priority
101–199Normal user hooks
200+Low-priority hooks (logging, analytics)

HookRegistry API

Registering a Hook

import { HookRegistry, type HookRegistration } from "./core/hooks.js";

const hooks = new HookRegistry();

const unsubscribe = hooks.register({
  hook: "task:create",
  phase: "before",
  priority: 50,
  name: "validate-task-title",
  handler(ctx) {
    if (ctx.data.title.length < 5) {
      ctx.cancel("Task title must be at least 5 characters");
    }
  },
});

// Later: remove the hook
unsubscribe();
The register() method returns an unsubscribe function that removes the handler when called.

Running Hooks

The orchestrator calls these methods internally:
// Async before — returns { cancelled, cancelReason?, data }
const result = await hooks.runBefore("mission:execute", {
  missionId: "abc",
  mission,
  taskCount: 5,
});

if (result.cancelled) {
  console.log(`Mission blocked: ${result.cancelReason}`);
  return;
}

// ... execute the mission ...

// Async after — fire-and-forget
await hooks.runAfter("mission:complete", {
  missionId: "abc",
  mission,
  allPassed: true,
  report,
});

// Sync before — for hot paths (skips async handlers)
const tickResult = hooks.runBeforeSync("orchestrator:tick", {
  pending: 3, running: 2, done: 10, failed: 1,
});

Diagnostics

hooks.size;    // number of registered handlers
hooks.list();  // array of { hook, phase, priority, name? }
hooks.clear(); // remove all handlers

HookContext

Every handler receives a HookContext<T> object:
PropertyTypeDescription
hookLifecycleHookWhich hook point fired
phase"before" | "after"Current phase
dataTThe payload — mutable in before hooks
cancel(reason?)functionCancel the operation (only works in before hooks; no-op in after)
cancelledbooleanWhether any handler has called cancel()
cancelReasonstring?Reason provided to cancel()
timestampstringISO timestamp when the hook was triggered
A throwing hook does not cancel the operation — it’s treated as a hook bug. The error is logged and the remaining handlers continue running. If you want to block an operation, use ctx.cancel().

Examples

Blocking a Mission Execution

Prevent missions from executing during maintenance windows:
hooks.register({
  hook: "mission:execute",
  phase: "before",
  priority: 10,
  name: "maintenance-window",
  handler(ctx) {
    const hour = new Date().getHours();
    if (hour >= 2 && hour < 4) {
      ctx.cancel("Maintenance window: 2-4 AM. Missions are paused.");
    }
  },
});

Logging Assessment Results

hooks.register({
  hook: "assessment:complete",
  phase: "after",
  priority: 200,
  name: "log-assessment",
  handler(ctx) {
    const { taskId, assessment, passed } = ctx.data;
    console.log(
      `[Assessment] Task ${taskId}: ${passed ? "PASSED" : "FAILED"} ` +
      `(score: ${assessment.globalScore?.toFixed(2)})`
    );
  },
});

Custom SLA Behavior

Escalate to a webhook when an SLA is violated:
hooks.register({
  hook: "quality:sla",
  phase: "after",
  priority: 50,
  name: "sla-webhook",
  async handler(ctx) {
    if (ctx.data.status === "violated") {
      await fetch("https://hooks.slack.com/services/...", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          text: `SLA violated for ${ctx.data.entityType} ${ctx.data.entityId}. ` +
                `Deadline: ${ctx.data.deadline}, usage: ${(ctx.data.percentUsed * 100).toFixed(0)}%`,
        }),
      });
    }
  },
});

Modifying Task Fields Before Creation

Add a default deadline to all tasks:
hooks.register({
  hook: "task:create",
  phase: "before",
  priority: 100,
  name: "default-deadline",
  handler(ctx) {
    if (!ctx.data.maxDuration) {
      ctx.data.maxDuration = 30 * 60 * 1000; // 30 minutes
    }
  },
});

Preventing Retries on Specific Failures

hooks.register({
  hook: "task:retry",
  phase: "before",
  priority: 50,
  name: "no-retry-auth-errors",
  handler(ctx) {
    if (ctx.data.task.lastError?.includes("AUTHENTICATION_FAILED")) {
      ctx.cancel("Auth errors should not be retried — fix credentials first");
    }
  },
});