Skip to content

Hooks & Extensions

The hook system is the primary extensibility surface of Thinkroid Space. It lets you observe, react to, and in some cases cancel any significant event in the system — without modifying core server code.

Overview

Every meaningful server-side action emits one or more named hook events. Examples include an agent being created, a task completing, an AI call returning, a governance review firing, or a message being sent to an external channel. Extensions subscribe to these events using a simple register(hooks) function and react to them with async handlers.

HookManager architecture

The HookManager (singleton exported as hooks from hookManager.js) maintains a Map of hook name to an ordered list of registered handlers. Each entry in the list carries the handler function, a numeric priority, and a human-readable label.

When an event fires, handlers are called in ascending priority order — lower numbers execute first. The default priority is 100, so internal system handlers that must run first are typically registered at priority 10.

There are two execution strategies:

StrategyMethodBehaviour
Fire-and-forgetemit()Calls every handler in priority order. A handler that throws is logged but does not prevent subsequent handlers from running.
Cancellable chainemitWaterfall()Calls handlers in priority order. If any handler returns { cancel: true, reason } the chain stops immediately and the caller receives { cancelled: true, reason }. Errors in a handler are logged and skipped — they do not cancel the chain.

HookManager API

hooks.on(name, handler, options?)

Register a handler for a named hook.

js
import { hooks } from '../services/hookManager.js';

hooks.on('task:complete:after', async (data) => {
  console.log('Task finished:', data.task.title);
}, { priority: 50, label: 'my-logger' });

Parameters

ParameterTypeDefaultDescription
namestringHook name (see Complete Hook Reference below)
handlerasync FunctionCalled with the hook's data payload
options.prioritynumber100Execution order — lower runs first
options.labelstring''Human-readable identifier used in error logs

hooks.off(name, handler)

Deregister a previously registered handler. You must pass the same function reference that was passed to on().

js
const myHandler = async (data) => { /* ... */ };
hooks.on('agent:create:after', myHandler);
// later...
hooks.off('agent:create:after', myHandler);

hooks.emit(name, data)

Fire all handlers for a hook. Returns a Promise<void>. Individual handler errors are caught, logged to stderr, and do not abort the remaining handlers.

js
await hooks.emit('task:complete:after', { task });

hooks.emitWaterfall(name, data)

Fire handlers in order. Any handler may cancel the operation by returning { cancel: true, reason: string }. Returns Promise<{ cancelled: boolean, reason?: string }>.

js
const result = await hooks.emitWaterfall('task:execute:before', { task });
if (result.cancelled) {
  throw new Error(`Blocked by hook: ${result.reason}`);
}

A handler that wants to allow the operation should return undefined (or any value without cancel: true).

hooks.listAll()

Returns an array of all registered handler descriptors — useful for debugging.

js
const all = hooks.listAll();
// [{ hook: 'task:complete:after', label: 'my-logger', priority: 50 }, ...]

Complete Hook Reference

Hook names follow a consistent noun:verb:timing convention. :before hooks are waterfalls (can cancel). :after hooks are fire-and-forget (observational).

Agent

Hook NameWaterfall?Data Shape
agent:create:afterNo{ agent } — the newly created agent row
agent:update:afterNo{ agent } — updated agent row
agent:delete:beforeYes{ agentId } — id of agent about to be deleted
agent:delete:afterNo{ agentId }
agent:offboard:afterNo{ agent } — agent that was offboarded
agent:learn:afterNo{ agentId, memory } — extracted memory entry
agent:morale:changedNo{ agentId, morale, delta }
agent:status:changedNo{ agentId, status } — new status string

Task

Hook NameWaterfall?Data Shape
task:create:afterNo{ task } — newly created task row
task:execute:beforeYes{ task } — task about to begin execution
task:execute:afterNo{ task } — task execution started
task:complete:afterNo{ task } — task finished successfully
task:fail:afterNo{ task, error }
task:interrupt:afterNo{ task } — task was interrupted

AI — Simple Calls

Hook NameWaterfall?Data Shape
ai:call:beforeYes{ scope, agentName, messages }scope is brain, cerebellum, or context_engine
ai:call:afterNo{ scope, agentName, response, tokensUsed }

AI — Tool Loop

Hook NameWaterfall?Data Shape
ai:toolloop:startNo{ agentName, taskId }
ai:toolloop:endNo{ agentName, taskId, rounds, result }
ai:round:beforeYes{ agentName, round, messages }
ai:round:afterNo{ agentName, round, response }

AI — Tool Execution

Hook NameWaterfall?Data Shape
ai:tool:beforeYes{ agentName, toolName, args }
ai:tool:afterNo{ agentName, toolName, args, result }
ai:tool:deniedNo{ agentName, toolName, reason }
ai:tool:approvalNo{ agentName, toolName, approvalId, status }

AI — System Events

Hook NameWaterfall?Data Shape
ai:token:recordedNo{ agentName, tokens, model }
ai:context:trimmedNo{ agentName, before, after } — message counts
ai:retryNo{ agentName, attempt, error }
ai:rounds:exceededNo{ agentName, maxRounds }
ai:interruptNo{ agentName, reason }

Governance

Hook NameWaterfall?Data Shape
governance:review:afterNo{ taskId, taskTitle, reviewer, report, timestamp }
governance:intervention:afterNo{ taskId, taskTitle, agentName, reason, minutesElapsed, interventionAgent, report, timestamp }
governance:janitor:afterNo{ cleaned } — summary of janitor cleanup pass
governance:budget:alertNo{ agentName, tokens, threshold }

Memory

Hook NameWaterfall?Data Shape
memory:extract:afterNo{ agentId, memories } — array of extracted memory strings

Conversation

Hook NameWaterfall?Data Shape
conversation:create:afterNo{ conversation }
conversation:message:afterNo{ conversationId, message }
conversation:conclude:afterNo{ conversationId, summary }

Meeting

Hook NameWaterfall?Data Shape
meeting:start:afterNo{ meeting }
meeting:speech:afterNo{ meetingId, agentName, content }
meeting:conclude:afterNo{ meetingId, minutes }

Approval

Hook NameWaterfall?Data Shape
approval:decide:afterNo{ approvalId, toolName, agentName, decision, decidedBy }

Skill & MCP

Hook NameWaterfall?Data Shape
skill:create:afterNo{ skill }
skill:delete:afterNo{ skillId }
skill:toggle:afterNo{ skillId, enabled }
skill:bind:afterNo{ skillId, agentId }
skill:unbind:afterNo{ skillId, agentId }
mcp:connect:afterNo{ skillId, serverName }
mcp:disconnect:afterNo{ skillId, serverName }

Settings

Hook NameWaterfall?Data Shape
settings:update:afterNo{ key, value } — changed setting
settings:permission:updateNo{ toolName, allowed }

Org

Hook NameWaterfall?Data Shape
org:create:afterNo{ org }
org:delete:afterNo{ orgId }
org:member:add:afterNo{ orgId, agentId }
org:member:remove:afterNo{ orgId, agentId }

Department

Hook NameWaterfall?Data Shape
dept:create:afterNo{ dept }
dept:update:afterNo{ dept }
dept:delete:afterNo{ deptId }
dept:member:add:afterNo{ deptId, agentId }
dept:member:remove:afterNo{ deptId, agentId }

Project

Hook NameWaterfall?Data Shape
project:create:afterNo{ project }
project:delete:afterNo{ projectId }

Message

Hook NameWaterfall?Data Shape
message:chat:beforeYes{ channel, sender, content }
message:chat:afterNo{ channel, sender, content, messageId }
message:send:afterNo{ channel, messageId }

Athena

Hook NameWaterfall?Data Shape
athena:chat:beforeYes{ userMessage, context }
athena:chat:afterNo{ userMessage, response }

Idle Behaviour

Hook NameWaterfall?Data Shape
idle:action:beforeYes{ agentId, action }
idle:action:afterNo{ agentId, action, result }

Channel (External Integrations)

Hook NameWaterfall?Data Shape
channel:create:afterNo{ channel }
channel:delete:afterNo{ channelId }
channel:incoming:afterNo{ channelId, platform, sender, content }
channel:reply:afterNo{ channelId, content }

Container

Hook NameWaterfall?Data Shape
container:stop:afterNo{ containerId, name }
container:remove:afterNo{ containerId, name }

Auth

Hook NameWaterfall?Data Shape
auth:login:afterNo{ userId }
auth:setup:afterNo{ userId } — first-time setup completed

Cron

Hook NameWaterfall?Data Shape
cron:execute:beforeYes{ jobId, agentId, command }
cron:execute:afterNo{ jobId, agentId, result }

System

Hook NameWaterfall?Data Shape
system:readyNo{} — emitted once when server startup completes
system:sse:broadcastNo{ type, data } — forward an event to all connected SSE clients

Writing an Extension

Extensions are plain ES Module files that export a single register function. Drop a .js file in the extensions/ directory and it will be loaded automatically on the next server start.

File location

thinkroid-space-server/
  src/
    extensions/          <-- place your extension here
      my-extension.js

The directory is created automatically by hookSetup.js if it does not exist.

Required export

js
export async function register(hooks) {
  // hooks is the HookManager singleton
}

The register function receives the live hooks instance. It may be async — the loader awaits it before moving on to the next file.

Example extension — task audit logger

This extension writes a structured audit entry to a local JSON file every time a task completes or fails.

js
// src/extensions/task-audit.js

import fs from 'fs';
import path from 'path';

const LOG_PATH = path.resolve('/data/task-audit.jsonl');

function append(entry) {
  fs.appendFileSync(LOG_PATH, JSON.stringify(entry) + '\n');
}

export async function register(hooks) {
  hooks.on('task:complete:after', async ({ task }) => {
    append({
      event: 'complete',
      taskId: task.id,
      title: task.title,
      assignedTo: task.assigned_to,
      ts: new Date().toISOString()
    });
  }, { priority: 200, label: 'task-audit:complete' });

  hooks.on('task:fail:after', async ({ task, error }) => {
    append({
      event: 'fail',
      taskId: task.id,
      title: task.title,
      error: error?.message,
      ts: new Date().toISOString()
    });
  }, { priority: 200, label: 'task-audit:fail' });
}

Example extension — block a tool via waterfall

This extension prevents agents from running the shell_exec tool outside business hours.

js
// src/extensions/business-hours-guard.js

export async function register(hooks) {
  hooks.on('ai:tool:before', async ({ agentName, toolName }) => {
    if (toolName !== 'shell_exec') return;

    const hour = new Date().getHours();
    if (hour < 9 || hour >= 18) {
      return {
        cancel: true,
        reason: `shell_exec is restricted outside business hours (agent: ${agentName})`
      };
    }
  }, { priority: 5, label: 'business-hours-guard' });
}

Priority guidelines

RangeIntended use
1–9Critical gates — security, auth, hard policy enforcement
10–49System-level wiring (SSE bridge runs at 10)
50–99Application logic that must run before defaults
100Default (most extensions should use this)
101–999Observability, logging, analytics

Built-in Hook Wiring

hookSetup.js is called once during server startup (setupHooks(hooks)). It establishes two pieces of wiring before any extensions are loaded.

SSE bridge (priority 10)

js
hooks.on('system:sse:broadcast', ({ type, data }) => {
  broadcastAgentEvent(type, data);
}, { priority: 10, label: 'sse-bridge' });

Any code that calls hooks.emit('system:sse:broadcast', { type, data }) pushes a real-time event to every connected browser client. This is the canonical way to push live updates from deep within server logic without importing the SSE route directly.

Extension auto-discovery

After the SSE bridge is registered, setupHooks scans src/extensions/ for .js files and calls each file's register(hooks) function in filesystem order.

  • If the extensions/ directory does not exist it is created automatically and a log message is printed.
  • Files that do not export a register function are silently skipped.
  • A file that throws during register logs the error and does not prevent other extensions from loading.

Governance triggers (hookSetup Phase 2)

The governance functions in governanceTriggers.js are currently called inline by the task executor rather than through hooks, and are documented here for completeness:

Internal functionEffectively equivalent hookTrigger condition
onTaskCompleted(task)governance:review:afterTask status transitions to completed; output review fires at a configurable sampling rate (default 30%) if an output_review governance agent exists
onTaskStuck(task, minutesElapsed)governance:intervention:afterTask remains in_progress beyond the configured timeout; the task is marked blocked, the assigned agent returns to idle, and an intervention agent generates a report

Both functions broadcast their results to connected clients via broadcastAgentEvent, which in turn maps to system:sse:broadcast.

Governance output routing (governanceRouter.js)

services/governanceRouter.js is the centralized gateway for all governance output. Every governance action calls persistGovernanceOutput(), which:

  1. Writes a record to the messages table with channel='governance'
  2. Broadcasts the corresponding SSE event (governance:review, governance:intervention, governance:janitor:after, governance:budget:alert)
  3. Calls routeGovernanceNotification() to decide whether to notify Boss

Notification routing flow:

Governance action → persistGovernanceOutput()
  → Write to messages (channel='governance')
  → Broadcast SSE event
  → routeGovernanceNotification()
    → notification_reader agent assigned?
        → AI call: NOTIFY | SKIP
    → No reader?
        → auto-notify only intervention + budget_alert

Channel reference:

ChannelData SourcePurpose
Boss Chat (Manager)messages channel='boss' + dm:Boss:*Boss ↔ Agent conversations (manager channel + DMs)
Message Centerboss_notificationsFiltered governance notifications (see MessageCenter section below)
Dashboard → Governancemessages channel='governance'Full governance audit log (DB-backed, persistent)
Chat Logmessages (dm:, bulletin, meeting:)Agent-to-agent chat

Note: Agent Settings chat and Boss Chat share the same DM channel (dm:Boss:AgentName), so conversations are unified across both views.

notification_reader capability:

Assigning the notification_reader capability (kind: hook, 23rd capability) to an agent makes that agent the governance notification filter. Before Boss is notified, the system calls the agent's Brain to evaluate the governance event summary and return NOTIFY or SKIP. This prevents low-signal events from cluttering the Boss inbox while keeping the full audit trail in the governance channel.

A built-in NotificationReader agent template (12th template) ships pre-configured for this role — hire it from the Hire panel to enable filtering immediately.

Fallback (no notification_reader assigned): only intervention and budget_alert events automatically notify Boss.

Data migration:

On server startup, any [Auto Review] or [Intervention] messages previously stored with channel='boss' are automatically migrated to channel='governance'.


MessageCenter (boss_notifications)

MessageCenter is the Boss's filtered notification inbox. Notifications arrive here after governanceRouter.js routes them through the notification_reader evaluation step.

UI features:

  • Inline expand/collapse — click a notification to expand its full content in-place; does not navigate to BossChatPanel
  • Markdown rendering — expanded content rendered via ReactMarkdown + remarkGfm + rehypeHighlight (tables, code blocks, lists, inline code)
  • Preview limit — 4000 chars
  • Category badge — each item shows a colored badge for its event type (review, intervention, janitor, budget_alert, etc.)
  • Lazy loading — 20 items per page, appends on scroll
  • Batch operations — Read All, Select & Read, Delete All, Select & Delete; confirm dialogs for destructive actions
  • Server-side search — activates at 3+ characters, 400ms debounce
  • Sort — Newest / Oldest / Agent name
  • Sender filter — multi-select agent name checkboxes
  • Category filter — multi-select event type checkboxes
  • Date range filter — From/To date picker toggle

Schema change: boss_notifications table gained a category column (event type string).

New API endpoints:

MethodPathDescription
GET/api/notifications/boss/filtersAvailable senders and categories for filter dropdowns
PUT/api/notifications/boss/batch-readMark selected IDs as read
DELETE/api/notifications/boss/batch-deleteDelete selected IDs
DELETE/api/notifications/boss/delete-all-readDelete all read notifications

Updated list endpointGET /api/notifications/boss now accepts: ?search=&sort=&sender=&category=&from=&to=&before=&limit=&offset=


Frequently Asked Questions

Can an extension remove a system handler?

Yes. hooks.off(name, handler) accepts any handler reference. However, removing the built-in sse-bridge handler will break real-time updates for all connected clients — do not remove it unless you are replacing it with your own SSE implementation.

Are hook handlers called in parallel?

No. Handlers on the same hook are called sequentially in priority order. This is intentional: it makes waterfall cancellation deterministic and avoids race conditions when multiple handlers write to the same resource.

What happens if my handler is slow?

emit() awaits each handler before moving to the next. A slow handler delays all subsequent handlers and delays the caller. Keep handlers fast. For heavy work (file I/O, external HTTP calls, database writes) consider buffering and processing asynchronously inside the handler.

Can I emit hooks from within an extension?

Yes. You have the full hooks instance. Emitting hooks from within a handler is safe, but watch out for infinite loops — for example, do not emit task:complete:after from inside a task:complete:after handler.

Can extensions be hot-reloaded?

No. Extensions are loaded once at startup via import(). To apply changes, restart the server (docker compose down && docker compose up -d --build in production, or npm run dev in local development).