Hook System

The Hook System

Every significant system has a question it must answer honestly: what happens when something you didn’t anticipate needs to happen?

Most systems answer this question with modification — you change the code, rebuild, redeploy. Obsidian answers it with hooks. The hook system is the event-driven backbone that connects plugins to the system’s lifecycle — every task completion, every agent spawn, every deployment, every configuration change emits events that plugins can intercept, observe, and transform.

This is Constitution Principle 11 made operational: extend without modifying core. Hooks are the mechanism by which that principle becomes architecture rather than aspiration.

Architecture

Hook System Architecture

┌────────────────────────────────────────────────────────┐
│                    OBSIDIAN Core                        │
│                                                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │                 Hook Registry                     │  │
│  │  ┌────────────┐ ┌────────────┐ ┌─────────────┐  │  │
│  │  │  Register  │ │  Priority  │ │  Lifecycle   │  │  │
│  │  │  Handlers  │ │  Ordering  │ │  Management  │  │  │
│  │  └────────────┘ └────────────┘ └─────────────┘  │  │
│  └──────────────────────┬───────────────────────────┘  │
│                         │                               │
│  ┌──────────────────────▼───────────────────────────┐  │
│  │              Event Dispatcher                     │  │
│  │                                                   │  │
│  │  agent:*  │  task:*  │  deploy:*  │  config:*    │  │
│  │  file:*   │  secret:*│  plugin:*  │  system:*    │  │
│  └──────┬────────┬────────┬────────┬────────────────┘  │
│         │        │        │        │                    │
│  ┌──────▼──┐ ┌───▼────┐ ┌▼──────┐ ┌▼───────┐          │
│  │Plugin A │ │Plugin B│ │Plugin C│ │Plugin D│          │
│  │priority │ │priority│ │priority│ │priority│          │
│  │  10     │ │  50    │ │  50   │ │  90    │          │
│  └─────────┘ └────────┘ └───────┘ └────────┘          │
└────────────────────────────────────────────────────────┘

The hook system has three components that matter:

  1. Hook Registry — tracks which handlers are registered for which events, maintains priority ordering, handles lifecycle (registration, deregistration, cleanup)
  2. Event Dispatcher — routes emitted events to registered handlers in priority order, manages execution timeouts, and handles failures gracefully
  3. Event Namespaces — a hierarchical naming convention (namespace:action) that allows both specific subscriptions and wildcard matching

Hook Types

Obsidian defines eight hook namespaces, each corresponding to a domain of system activity. This is not arbitrary taxonomy — each namespace maps to a real operational boundary where external code might legitimately need to intervene.

Agent Hooks

Events emitted during the Agent lifecycle. Every state transition — spawn, claim, complete, fail — fires a hook.

EventFires WhenPayload
agent:spawnedNew agent process startsAgent ID, config, role
agent:claimedAgent claims a taskAgent ID, task ID
agent:completedAgent finishes workAgent ID, result, metrics
agent:failedAgent encounters unrecoverable errorAgent ID, error, retry count
agent:recycledAgent is cleaned up and returned to poolAgent ID, lifetime stats

Task Hooks

Events in the task lifecycle — from creation through completion or failure.

EventFires WhenPayload
task:createdNew task enters the queueTask ID, priority, source
task:assignedTask is assigned to an agentTask ID, agent ID
task:completedTask finishes successfullyTask ID, result, duration
task:failedTask fails after retries exhaustedTask ID, error, attempts
task:escalatedTask is escalated to a humanTask ID, reason, context

Deploy Hooks

Deployment lifecycle events — the critical path where hooks can gate, audit, or transform deployments.

EventFires WhenPayload
deploy:startedDeployment beginsDeploy ID, target, version
deploy:validatedPre-deploy checks passDeploy ID, check results
deploy:completedDeployment succeedsDeploy ID, duration, URL
deploy:rolled-backRollback triggeredDeploy ID, reason, previous version
deploy:failedDeployment failsDeploy ID, error, stage

Config Hooks

Configuration changes — because in a system governed by a Constitution , configuration changes are governance events.

EventFires WhenPayload
config:changedAny configuration value changesKey, old value, new value
config:validatedConfiguration passes validationFull config snapshot
config:reloadedHot-reload of configurationChanged keys, source

File Hooks

Filesystem events within the project workspace.

EventFires WhenPayload
file:createdNew file writtenPath, size, creator
file:modifiedExisting file changedPath, diff summary
file:deletedFile removedPath, last modifier

Secret Hooks

Secret access events — every secret read is auditable, every rotation is observable.

EventFires WhenPayload
secret:accessedSecret value readKey pattern, accessor, timestamp
secret:rotatedSecret value changedKey pattern, rotation source
secret:expiredSecret TTL elapsedKey pattern, expiry time

Plugin Hooks

Meta-hooks — events about the plugin system itself.

EventFires WhenPayload
plugin:loadedPlugin successfully loadsPlugin name, version, type
plugin:enabledPlugin activatedPlugin name, hooks registered
plugin:disabledPlugin deactivatedPlugin name, reason
plugin:errorPlugin throws unhandled errorPlugin name, error, context

System Hooks

System-level events — startup, shutdown, health changes.

EventFires WhenPayload
system:startupObsidian process startsVersion, config, environment
system:shutdownGraceful shutdown initiatedReason, active tasks count
system:health-changedHealth status transitionsPrevious state, new state, cause

Hook Registration

Hooks are registered through the plugin manifest or programmatically via the Plugin API. Both paths converge on the same registry — the manifest approach is declarative and preferred; the programmatic approach exists for dynamic registration patterns.

Manifest Registration

The static approach. Declare hooks in obsidian-plugin.json and they are registered when the plugin loads:

{
  "hooks": [
    {
      "event": "task:completed",
      "handler": "onTaskCompleted",
      "priority": 50
    },
    {
      "event": "deploy:started",
      "handler": "onDeployStarted",
      "priority": 10
    },
    {
      "event": "agent:*",
      "handler": "onAnyAgentEvent",
      "priority": 90
    }
  ]
}

The wildcard pattern (agent:*) subscribes to every event in the namespace. This is useful for monitoring and audit plugins that need comprehensive visibility without knowing every specific event type in advance.

Programmatic Registration

The dynamic approach. Use the Plugin API when registration depends on runtime conditions:

export function activate(api: ObsidianAPI) {
  // Register a specific hook
  api.hooks.on('task:completed', async (event) => {
    await notifySlack(event.payload.taskId, event.payload.result);
  }, { priority: 50 });

  // Register a wildcard hook
  api.hooks.on('deploy:*', async (event) => {
    await auditLog.record(event);
  }, { priority: 10 });

  // Conditional registration
  if (api.config.get('features.codeReview')) {
    api.hooks.on('file:modified', async (event) => {
      if (event.payload.path.endsWith('.rs')) {
        await triggerCodeReview(event.payload);
      }
    }, { priority: 50 });
  }
}

Execution Model

Hook execution follows rules that prioritize system stability over plugin convenience. This is a deliberate tradeoff — a hook system that can crash the host is worse than no hook system at all.

Priority Ordering

Handlers execute in priority order — lower numbers run first. The scale is 0–100:

  • 0–20: System-critical hooks (audit, security, compliance)
  • 21–50: Standard processing hooks (notifications, transformations)
  • 51–80: Enhancement hooks (analytics, logging, telemetry)
  • 81–100: Cleanup hooks (resource release, cache invalidation)

Handlers at the same priority level execute in registration order. This is deterministic — the same set of plugins will always execute in the same order.

Timeout Enforcement

Every hook handler has a maximum execution time, configurable per-hook and globally:

hooks:
  default_timeout_ms: 5000
  max_timeout_ms: 30000
  overrides:
    "deploy:*": 30000
    "task:completed": 10000

A handler that exceeds its timeout is killed and logged. The event continues dispatching to remaining handlers. A single slow plugin does not block the system. This is Constitution Principle 1 in miniature: failures are inevitable; design for survival.

Error Isolation

When a hook handler throws an error:

  1. The error is caught and logged with full context (plugin name, event, payload, stack trace)
  2. The handler is marked as failed for this invocation
  3. Remaining handlers continue executing
  4. The originating operation is not affected — a failed hook never crashes the operation that triggered it

If a handler fails repeatedly (configurable threshold, default: 5 failures in 10 minutes), the hook registry automatically disables it and emits a plugin:error event. The Warden can then decide whether to restart the plugin or escalate.

Synchronous vs. Asynchronous

Hooks execute asynchronously by default — the operation that emits the event does not wait for handlers to complete. This is the correct default because most hooks are observational (logging, metrics, notifications).

For hooks that must gate an operation — pre-deploy validation, for instance — handlers can be registered as synchronous:

{
  "event": "deploy:started",
  "handler": "validateDeploy",
  "priority": 5,
  "sync": true
}

Synchronous handlers can return a result that affects the operation. A deploy:started sync handler that returns { abort: true, reason: "..." } will cancel the deployment. This is power with accountability — sync hooks are logged more aggressively and have stricter timeout enforcement.

The Hook Lifecycle

Hook Event Flow

Operation (e.g., task completes)
     │
     ▼
┌─────────────┐
│ Emit Event  │──── event: "task:completed"
└──────┬──────┘     payload: { taskId, result, duration }
       │
       ▼
┌─────────────┐
│  Registry   │──── lookup handlers for "task:completed"
│  Lookup     │     + handlers for "task:*"
└──────┬──────┘
       │
       ▼
┌─────────────┐
│   Sort by   │──── priority 10, 50, 50, 90
│  Priority   │
└──────┬──────┘
       │
       ▼
┌─────────────┐     ┌─────────┐
│  Execute    │────▶│Handler 1│──── priority 10 (sync: gate)
│  Pipeline   │     └────┬────┘
│             │          ▼
│             │     ┌─────────┐
│             │────▶│Handler 2│──── priority 50 (async: notify)
│             │     └────┬────┘
│             │          ▼
│             │     ┌─────────┐
│             │────▶│Handler 3│──── priority 50 (async: log)
│             │     └────┬────┘
│             │          ▼
│             │     ┌─────────┐
│             │────▶│Handler 4│──── priority 90 (async: cleanup)
│             │     └─────────┘
└─────────────┘

Writing Hook Handlers

A hook handler is a function that receives an event object and optionally returns a result. The event object is standardized across all hook types:

interface HookEvent<T = unknown> {
  /** The event name, e.g. "task:completed" */
  name: string;

  /** ISO 8601 timestamp of emission */
  timestamp: string;

  /** Unique event ID for correlation */
  id: string;

  /** The event-specific data */
  payload: T;

  /** Source of the event (system, plugin name, agent ID) */
  source: string;
}

A practical example — a quality gate that blocks deployments unless all tests pass:

import type { ObsidianAPI, HookEvent } from '@obsidian/types';

interface DeployPayload {
  deployId: string;
  target: string;
  version: string;
  testResults?: { passed: number; failed: number };
}

export function activate(api: ObsidianAPI) {
  api.hooks.on('deploy:started', async (event: HookEvent<DeployPayload>) => {
    const { deployId, testResults } = event.payload;

    if (!testResults) {
      return { abort: true, reason: 'No test results found — refusing to deploy blind.' };
    }

    if (testResults.failed > 0) {
      api.log.warn(`Deploy ${deployId} blocked: ${testResults.failed} failing tests`);
      return { abort: true, reason: `${testResults.failed} tests failing.` };
    }

    api.log.info(`Deploy ${deployId} cleared: ${testResults.passed} tests passing`);
  }, { priority: 5, sync: true });
}

Why This Matters

The hook system is where Constitution Principle 2 (if it isn’t observable, it doesn’t exist) and Principle 11 (extend without modifying core) converge. Every significant operation emits events. Every event can be observed. Every observation can trigger action. And none of this requires changing a single line of core code.

This is not a convenience feature. This is the architectural guarantee that Obsidian can grow in directions its creators never anticipated — because the extension points are not afterthoughts bolted onto specific features, but a universal event fabric woven through the entire system. The same pattern at every scale. Fractal Delegation applied to extensibility itself.