Skip to content

Hooks System Internals

This document explains the internal architecture of the hooks system, how hooks integrate with runtimes, and key design decisions.

Overview

The hooks system provides composable observability throughout agent execution. It's implemented as:

  1. Types - Hook interfaces and payload definitions (packages/core/src/hooks/types.ts)
  2. Hook Manager - Registration and invocation logic (packages/core/src/hooks/hook-manager.ts)
  3. Runtime Integration - Hook invocation points in executors

Architecture

┌─────────────────────────────────────────────────────────────┐
│                      Agent Execution                         │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────────┐                                            │
│  │ HookManager │◄─── Composed from agent + execution hooks  │
│  └──────┬──────┘                                            │
│         │                                                    │
│         │ invoke('onAgentStart', payload, context)          │
│         ▼                                                    │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐    │
│  │ Parent Hooks │──►│ Agent Hooks  │──►│ Exec Hooks   │    │
│  │ (sub-agents) │   │ (defineAgent)│   │ (execute())  │    │
│  └──────────────┘   └──────────────┘   └──────────────┘    │
│                                                              │
├─────────────────────────────────────────────────────────────┤
│  Hook Points:                                                │
│  • onAgentStart    • beforeLLMCall    • beforeTool          │
│  • onAgentComplete • afterLLMCall     • afterTool           │
│  • onAgentFail     • onStateChange    • beforeSubAgent      │
│  • onMessage       • afterSubAgent                          │
│  (onTextDelta and onThinking are defined but not yet wired) │
└─────────────────────────────────────────────────────────────┘

HookManager Implementation

DefaultHookManager

The primary hook manager implementation:

typescript
class DefaultHookManager<TState, TOutput> implements HookManager<TState, TOutput> {
  private handlers: AgentHooks<TState, TOutput>[] = [];
  private parent?: HookManager<TState, TOutput>;

  constructor(parent?: HookManager<TState, TOutput>) {
    this.parent = parent;
  }

  register(hooks: AgentHooks<TState, TOutput>): void {
    this.handlers.push(hooks);
  }

  async invoke<K extends keyof AgentHooks<TState, TOutput>>(
    hookName: K,
    ...args: Parameters<NonNullable<AgentHooks<TState, TOutput>[K]>>
  ): Promise<void> {
    // Parent hooks run first
    if (this.parent) {
      await this.parent.invoke(hookName, ...args);
    }

    // Then registered handlers in order
    for (const handler of this.handlers) {
      const hookFn = handler[hookName];
      if (hookFn) {
        await (hookFn as Function)(...args);
      }
    }
  }

  createChild(): HookManager<TState, TOutput> {
    return new DefaultHookManager(this);
  }

  hasHooks(): boolean {
    return this.handlers.length > 0 || (this.parent?.hasHooks() ?? false);
  }
}

Key Behaviors

Sequential Execution: Handlers run in registration order, each awaited before the next.

typescript
// Registration order determines execution order
manager.register(loggingHooks);   // Runs first
manager.register(metricsHooks);   // Runs second
manager.register(auditHooks);     // Runs third

Error Propagation: Hook errors propagate up and fail the agent. This is intentional—hooks are part of execution, not advisory.

typescript
// If loggingHooks.onAgentStart throws, the agent fails
await manager.invoke('onAgentStart', payload, context);

Parent-Child Hierarchy: Sub-agents inherit parent hooks via createChild().

typescript
// In sub-agent executor
const childManager = parentManager.createChild();
childManager.register(subAgentHooks); // Sub-agent can add its own
await childManager.invoke('onAgentStart', ...); // Parent hooks run first

NoopHookManager

Performance optimization when no hooks are configured:

typescript
class NoopHookManager<TState, TOutput> implements HookManager<TState, TOutput> {
  register(): void {}
  unregister(): void {}
  async invoke(): Promise<void> {}
  createChild(): HookManager<TState, TOutput> { return this; }
  hasHooks(): boolean { return false; }
}

// Singleton instance
export const noopHookManager: HookManager<any, any> = new NoopHookManager();

When building the hook manager, the runtime returns noopHookManager if no hooks exist:

typescript
function buildHookManager(agentHooks, executionHooks, existingManager) {
  if (!agentHooks && !executionHooks && !existingManager) {
    return noopHookManager;
  }
  return mergeHooks(agentHooks, executionHooks, existingManager);
}

Factory Functions

createHookManager

Creates a manager with optional initial hooks:

typescript
function createHookManager<TState, TOutput>(
  initialHooks?: AgentHooks<TState, TOutput>
): HookManager<TState, TOutput> {
  const manager = new DefaultHookManager<TState, TOutput>();
  if (initialHooks) {
    manager.register(initialHooks);
  }
  return manager;
}

composeHookManagers

Composes multiple hook sets into one manager:

typescript
function composeHookManagers<TState, TOutput>(
  ...hooksList: (AgentHooks<TState, TOutput> | undefined)[]
): HookManager<TState, TOutput> {
  const manager = new DefaultHookManager<TState, TOutput>();
  for (const hooks of hooksList) {
    if (hooks) {
      manager.register(hooks);
    }
  }
  return manager;
}

mergeHooks

Merges agent-level and execution-level hooks:

typescript
function mergeHooks<TState, TOutput>(
  agentHooks?: AgentHooks<TState, TOutput>,
  executionHooks?: AgentHooks<TState, TOutput>,
  existingManager?: HookManager<TState, TOutput>
): HookManager<TState, TOutput> {
  const manager = existingManager ?? new DefaultHookManager<TState, TOutput>();

  if (agentHooks) {
    manager.register(agentHooks);  // Agent hooks first
  }
  if (executionHooks) {
    manager.register(executionHooks);  // Execution hooks second
  }

  return manager;
}

HookContext Creation

The createHookContext function builds context from runtime state:

typescript
function createHookContext<TState, TOutput>(
  options: CreateHookContextOptions<TState, TOutput>
): HookContext<TState, TOutput> {
  return {
    // Read-only execution info
    runId: options.runId,
    agentType: options.agentType,
    streamId: options.streamId,
    stepCount: options.stepCount,
    parentAgentId: options.parentAgentId,
    abortSignal: options.abortSignal,

    // Session context (for tracing platforms)
    sessionId: options.sessionId,
    userId: options.userId,
    tags: options.tags,
    metadata: options.metadata,

    // State access
    getState: options.getState,
    updateState: options.updateState,

    // Stream emission
    emit: options.emit,
    emitCustom: async (eventName: string, data: unknown) => {
      await options.emit({
        type: 'custom',
        agentId: options.runId,
        agentType: options.agentType,
        timestamp: Date.now(),
        eventName,
        data,
      });
    },
  };
}

Runtime Integration

JS Runtime Integration

The JSAgentExecutor invokes hooks at key points in the execution loop.

Hook Manager Building (in JSAgentExecutor.buildHookManager()):

typescript
private buildHookManager<TState, TOutput>(
  agent: AgentConfig<..., ...>,
  options: ExecuteOptions<TState, TOutput>
): HookManager<TState, TOutput> {
  // Return noop if no hooks configured
  if (!agent.hooks && !options.hooks && !options.hookManager) {
    return noopHookManager;
  }

  // Use provided manager or create new one
  const manager = options.hookManager ?? new DefaultHookManager<TState, TOutput>();

  // Register agent hooks first
  if (agent.hooks) {
    manager.register(agent.hooks);
  }

  // Then execution hooks
  if (options.hooks) {
    manager.register(options.hooks);
  }

  return manager;
}

Hook Invocation Points:

HookLocationWhen Invoked
onAgentStartrunLoop() entryBefore first LLM call
beforeLLMCallBefore llmAdapter.generateStep()Each LLM call
afterLLMCallAfter planStepProcessing()Each LLM response
onMessageAfter message appendUser, assistant, tool messages
beforeTooltool-executor.tsBefore tool.execute()
afterTooltool-executor.tsAfter tool completes
onStateChangetool-executor.tsAfter state mutation
beforeSubAgentsubagent-executor.tsBefore sub-agent starts
afterSubAgentsubagent-executor.tsAfter sub-agent completes
onAgentCompleterunLoop() exitOn successful completion
onAgentFailrunLoop() exitOn failure

Example: Tool Executor Hook Integration (simplified from actual implementation):

typescript
// packages/runtime-js/src/execution/tool-executor.ts

async function executeSingleToolCall<TState, TOutput>(
  call: ParsedToolCall,
  tool: Tool,
  baseContext: ToolContext,
  // ... other params: writer, runId, stateStore, state, batchAbortSignal
  hookManager?: HookManager<any, any>,
  getHookContext?: () => HookContext<TState, TOutput>
): Promise<RegularToolResult> {
  // Create per-tool state tracker for atomic state merging
  const currentState = baseContext.getState();
  const toolTracker = new ImmerStateTracker(structuredClone(currentState));

  // Create tool-specific context with its own state tracker
  const toolContext: ToolContext = {
    ...baseContext,
    getState: () => toolTracker.getState(),
    updateState: (updater) => toolTracker.updateState(updater),
  };

  // Hook: beforeTool
  if (hookManager && getHookContext) {
    await hookManager.invoke('beforeTool', { toolCall: call, tool }, getHookContext());
  }

  const toolStartTime = Date.now();

  try {
    const result = await tool.execute(call.arguments, toolContext);

    // After tool completes, check if state was modified
    if (toolTracker.hasChanges()) {
      const previousState = structuredClone(currentState);

      // Merge state changes to store...

      // Hook: onStateChange
      if (hookManager && getHookContext) {
        await hookManager.invoke('onStateChange', {
          previousState,
          newState: toolTracker.getState(),
          source: 'tool',
          toolName: call.name,
        }, getHookContext());
      }
    }

    // Hook: afterTool (success)
    if (hookManager && getHookContext) {
      await hookManager.invoke('afterTool', {
        toolCall: call,
        tool,
        result,
        success: true,
        error: undefined,
        durationMs: Date.now() - toolStartTime,
      }, getHookContext());
    }

    return { kind: 'tool', id: call.id, toolName: call.name, arguments: call.arguments, success: true, result };
  } catch (error) {
    const errorObj = error instanceof Error ? error : new Error(String(error));

    // Hook: afterTool (failure)
    if (hookManager && getHookContext) {
      await hookManager.invoke('afterTool', {
        toolCall: call,
        tool,
        result: undefined,
        success: false,
        error: errorObj,
        durationMs: Date.now() - toolStartTime,
      }, getHookContext());
    }

    return { kind: 'tool', id: call.id, toolName: call.name, arguments: call.arguments, success: false, error: errorObj.message };
  }
}

Temporal Runtime Integration

In Temporal, hooks run inside activities (non-deterministic code), not in the workflow (deterministic code). The hook manager is passed to the GenericAgentActivities class at initialization time when activities are registered.

Activity Setup (packages/runtime-temporal/src/activities.ts):

typescript
// Hook manager is passed at activity registration time, not serialized
export class GenericAgentActivities {
  private readonly hookManager?: HookManager<any, any>;

  constructor(
    registry: AgentRegistry,
    stateStore: StateStore,
    streamManager: StreamManager,
    llmAdapter: LLMAdapter,
    cancellationProvider?: CancellationSignalProvider,
    heartbeatProvider?: HeartbeatProvider,
    hookManager?: HookManager<any, any>  // Passed at setup time
  ) {
    this.hookManager = hookManager;
  }
}

Hook Invocation in Activities:

typescript
// Inside activity methods
if (this.hookManager) {
  await this.hookManager.invoke('beforeLLMCall', {
    messages,
    tools,
    config: agentConfig,
    model,
  }, hookContext);
}

const stepResult = await llmAdapter.generateStep(...);

if (this.hookManager) {
  await this.hookManager.invoke('afterLLMCall', {
    result: stepResult,
    plan,
    durationMs,
    model,
    usage: stepResult.usage,
  }, hookContext);
}

Important: Hook managers are NOT serialized to workflows. They must be provided when activities are registered. This means:

  • Hooks defined in defineAgent({ hooks: ... }) work if the activity runner receives them at setup
  • Execution-time hooks passed to executor.execute() require the executor to be configured with hook support

Cloudflare Runtime Integration

Similar to Temporal, hooks run in step methods. The hook manager is passed to the WorkflowStepExecutor at initialization time.

typescript
// packages/runtime-cloudflare/src/steps.ts

export class WorkflowStepExecutor {
  private readonly hookManager?: HookManager<any, any>;

  constructor(
    stateStore: StateStore,
    streamManager: StreamManager,
    llmAdapter: LLMAdapter,
    hookManager?: HookManager<any, any>  // Passed at setup time
  ) {
    this.hookManager = hookManager;
  }

  async executeToolStep(/* ... */): Promise<ToolStepOutput> {
    if (this.hookManager) {
      await this.hookManager.invoke('beforeTool', { toolCall, tool }, hookContext);
    }

    // Execute tool...

    if (this.hookManager) {
      await this.hookManager.invoke('afterTool', {
        toolCall, tool, result, success, error, durationMs
      }, hookContext);
    }

    return { result, success };
  }
}

Sub-Agent Hook Inheritance

When a sub-agent executes, it inherits the parent's hooks via createChild():

typescript
// packages/runtime-js/src/execution/subagent-executor.ts

async function executeSubAgent(
  call: ParsedSubAgentCall,
  parentHookManager: HookManager,
  parentContext: HookContext
): Promise<SubAgentResult> {
  // Create child manager that inherits parent hooks
  const childHookManager = parentHookManager.createChild();

  // Sub-agent's own hooks are registered on the child
  if (subAgentConfig.hooks) {
    childHookManager.register(subAgentConfig.hooks);
  }

  // Invoke beforeSubAgent on parent
  await parentHookManager.invoke('beforeSubAgent', {
    call,
    agentConfig: subAgentConfig,
    parentRunId: parentContext.runId,
  }, parentContext);

  // Execute sub-agent with child hook manager
  // When child invokes hooks, parent hooks run first
  const result = await runSubAgent(subAgentConfig, call.input, {
    hookManager: childHookManager,
  });

  // Invoke afterSubAgent on parent
  await parentHookManager.invoke('afterSubAgent', {
    call,
    agentConfig: subAgentConfig,
    result: result.output,
    success: result.status === 'completed',
    error: result.error,
    durationMs: result.durationMs,
    subAgentRunId: result.runId,
  }, parentContext);

  return result;
}

Invocation Order for Sub-Agent:

Parent's onAgentStart → Parent's hooks
Child's onAgentStart  → Parent's hooks (via parent) → Child's hooks
Child's beforeTool    → Parent's hooks (via parent) → Child's hooks
Child's onAgentComplete → Parent's hooks (via parent) → Child's hooks
Parent's afterSubAgent → Parent's hooks

Design Decisions

1. Hooks Fail the Agent

Decision: Hook errors propagate and fail the agent execution.

Rationale: Hooks are part of execution, not advisory. If logging or auditing fails, that's a real failure that should be visible.

Alternative Considered: Catching hook errors and logging warnings. Rejected because it could hide critical failures (e.g., audit requirements not met).

2. Sequential Execution

Decision: Hooks execute sequentially in registration order.

Rationale:

  • Predictable execution order
  • Hooks can depend on each other (e.g., auth check before audit)
  • Simplifies debugging

Alternative Considered: Parallel execution for performance. Rejected because it complicates error handling and ordering guarantees.

3. NoopHookManager Optimization

Decision: Return singleton noopHookManager when no hooks are configured.

Rationale: Avoids overhead of empty manager creation and iteration when hooks aren't used.

typescript
// Fast path: no hooks
if (!agent.hooks && !options.hooks) {
  return noopHookManager; // All methods are no-ops
}

4. Parent-Child Hierarchy for Sub-Agents

Decision: Sub-agents inherit parent hooks via createChild().

Rationale:

  • Observability flows through entire execution tree
  • Parent can trace all activity including sub-agents
  • Sub-agents can add specialized hooks without losing parent's

5. Hook Context is Read-Only (Except State)

Decision: Hook context properties are read-only; only state can be modified.

Rationale: Hooks should observe, not control execution flow. State modification is allowed for use cases like tracking metadata.

Performance Considerations

Hook Invocation Overhead

Each hook invocation involves:

  1. Manager method call
  2. Parent chain traversal
  3. Handler array iteration
  4. Async function calls

For frequently-invoked hooks (like beforeTool/afterTool in tool-heavy agents), consider:

  • Using hasHooks() guard before building payload
  • Keeping hook implementations lightweight
typescript
// Guard pattern for performance
if (hookManager.hasHooks()) {
  await hookManager.invoke('beforeTool', payload, context);
}

Note: The onTextDelta and onThinking hooks are defined in the type system but not yet wired into the runtime. Streaming observability is currently handled via stream chunks rather than hooks.

Context Creation

createHookContext() creates a new object per invocation. For frequent hooks, the context is typically created once per step and reused:

typescript
// Create once per step
const hookContext = createHookContext({
  runId,
  agentType,
  streamId,
  stepCount,
  // ... methods bound to current state
});

// Reuse for all hooks in this step
await hookManager.invoke('beforeLLMCall', payload, hookContext);
await hookManager.invoke('afterLLMCall', payload, hookContext);

Testing

Unit Testing Hook Managers

typescript
import { createHookManager, DefaultHookManager } from '@helix-agents/core';

describe('DefaultHookManager', () => {
  it('invokes handlers in registration order', async () => {
    const order: string[] = [];
    const manager = createHookManager<unknown, unknown>();

    manager.register({
      onAgentStart: () => { order.push('first'); },
    });
    manager.register({
      onAgentStart: () => { order.push('second'); },
    });

    await manager.invoke('onAgentStart', {} as any, {} as any);

    expect(order).toEqual(['first', 'second']);
  });

  it('parent hooks run before child hooks', async () => {
    const order: string[] = [];
    const parent = createHookManager<unknown, unknown>();
    parent.register({
      onAgentStart: () => { order.push('parent'); },
    });

    const child = parent.createChild();
    child.register({
      onAgentStart: () => { order.push('child'); },
    });

    await child.invoke('onAgentStart', {} as any, {} as any);

    expect(order).toEqual(['parent', 'child']);
  });

  it('propagates hook errors', async () => {
    const manager = createHookManager({
      onAgentStart: () => { throw new Error('Hook failed'); },
    });

    await expect(
      manager.invoke('onAgentStart', {} as any, {} as any)
    ).rejects.toThrow('Hook failed');
  });
});

Integration Testing

typescript
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { defineAgent, MockLLMAdapter, FINISH_TOOL_NAME } from '@helix-agents/core';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { z } from 'zod';

describe('JSAgentExecutor hooks', () => {
  let stateStore: InMemoryStateStore;
  let streamManager: InMemoryStreamManager;
  let mockLLM: MockLLMAdapter;
  let executor: JSAgentExecutor;

  beforeEach(() => {
    stateStore = new InMemoryStateStore();
    streamManager = new InMemoryStreamManager();
    mockLLM = new MockLLMAdapter();
    executor = new JSAgentExecutor(stateStore, streamManager, mockLLM);
  });

  it('invokes hooks during execution', async () => {
    const calls: string[] = [];

    const agent = defineAgent({
      name: 'test',
      systemPrompt: 'Test',
      outputSchema: z.object({ result: z.string() }),
      llmConfig: { model: {} as never },
      hooks: {
        onAgentStart: () => calls.push('start'),
        beforeLLMCall: () => calls.push('before-llm'),
        afterLLMCall: () => calls.push('after-llm'),
        onAgentComplete: () => calls.push('complete'),
      },
    });

    mockLLM.addResponse({
      type: 'tool_calls',
      toolCalls: [{ id: '1', name: FINISH_TOOL_NAME, arguments: { result: 'done' } }],
    });

    const handle = await executor.execute(agent, 'Hello');
    await handle.result();

    expect(calls).toEqual(['start', 'before-llm', 'after-llm', 'complete']);
  });
});

See Also

Released under the MIT License.