Skip to content

Hooks (Observability & Callbacks)

Hooks provide a composable way to observe and react to agent execution lifecycle events. Use them for logging, metrics, auditing, tracing, and custom integrations.

Overview

Hooks are callback functions invoked at specific points during agent execution. They can:

  • Log execution details
  • Collect metrics and timing data
  • Send traces to observability platforms (LangFuse, LangSmith)
  • Modify state during execution
  • Emit custom events to streams
  • Audit tool usage and LLM calls
typescript
import { defineAgent } from '@helix-agents/sdk';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

const agent = defineAgent({
  name: 'research-agent',
  systemPrompt: 'You are a research assistant.',
  outputSchema: z.object({ summary: z.string() }),
  llmConfig: { model: openai('gpt-4o') },
  hooks: {
    onAgentStart: (payload, ctx) => {
      console.log(`[${ctx.runId}] Starting with input: ${payload.input}`);
    },
    onAgentComplete: (payload, ctx) => {
      console.log(`[${ctx.runId}] Completed in ${payload.durationMs}ms`);
    },
    beforeTool: (payload, ctx) => {
      console.log(`[${ctx.runId}] Calling tool: ${payload.tool.name}`);
    },
  },
});

Hook Types

All hooks are optional and can be synchronous or asynchronous. They receive two arguments:

  1. payload - Data specific to the hook event
  2. context - HookContext with agent info and capabilities

Agent Lifecycle Hooks

onAgentStart

Called when agent execution begins, before the first LLM call.

typescript
hooks: {
  onAgentStart: (payload, ctx) => {
    // payload.agent - Agent configuration
    // payload.input - User input string
    // payload.initialState - Initial custom state
    console.log(`Starting agent ${payload.agent.name}`);
    console.log(`Input: ${payload.input}`);
  },
}

onAgentComplete

Called when the agent completes successfully with structured output.

typescript
hooks: {
  onAgentComplete: (payload, ctx) => {
    // payload.output - Final structured output
    // payload.finalState - Final custom state
    // payload.stepCount - Total LLM steps executed
    // payload.durationMs - Total duration in milliseconds
    console.log(`Completed in ${payload.stepCount} steps, ${payload.durationMs}ms`);
    console.log(`Output:`, payload.output);
  },
}

onAgentFail

Called when the agent fails with an error.

typescript
hooks: {
  onAgentFail: (payload, ctx) => {
    // payload.error - Error that caused failure
    // payload.finalState - State at time of failure
    // payload.stepCount - Steps executed before failure
    // payload.durationMs - Duration until failure
    // payload.recoverable - Whether error is recoverable
    console.error(`Failed: ${payload.error.message}`);
    console.error(`Recoverable: ${payload.recoverable}`);
  },
}

LLM Call Hooks

beforeLLMCall

Called before each LLM call with the messages and tools being sent.

typescript
hooks: {
  beforeLLMCall: (payload, ctx) => {
    // payload.messages - Messages to send to LLM
    // payload.tools - Tools available for this call
    // payload.config - Agent configuration
    // payload.model - Model identifier (e.g., 'gpt-4o')
    // payload.modelParameters - Temperature, maxOutputTokens, etc.
    console.log(`LLM call #${ctx.stepCount + 1}`);
    console.log(`Messages: ${payload.messages.length}, Tools: ${payload.tools.length}`);
  },
}

afterLLMCall

Called after each LLM call with the result.

typescript
hooks: {
  afterLLMCall: (payload, ctx) => {
    // payload.result - StepResult from LLM
    // payload.plan - Processing plan (tool calls, stop, etc.)
    // payload.durationMs - LLM call duration
    // payload.model - Model that was used
    // payload.usage - Token usage (if available)
    if (payload.usage?.totalTokens) {
      console.log(`Tokens: ${payload.usage.promptTokens ?? 0} in, ${payload.usage.completionTokens ?? 0} out`);
    }
    console.log(`LLM call took ${payload.durationMs}ms`);
  },
}

Tool Execution Hooks

beforeTool

Called before each tool is executed.

typescript
hooks: {
  beforeTool: (payload, ctx) => {
    // payload.toolCall - Tool call details (id, name, arguments)
    // payload.tool - Tool definition
    console.log(`Executing: ${payload.tool.name}`);
    console.log(`Arguments:`, payload.toolCall.arguments);
  },
}

afterTool

Called after each tool completes (success or failure).

typescript
hooks: {
  afterTool: (payload, ctx) => {
    // payload.toolCall - Tool call details
    // payload.tool - Tool definition
    // payload.result - Tool return value
    // payload.success - Whether tool succeeded
    // payload.error - Error if tool failed
    // payload.durationMs - Execution duration
    if (payload.success) {
      console.log(`${payload.tool.name} succeeded in ${payload.durationMs}ms`);
    } else {
      console.error(`${payload.tool.name} failed: ${payload.error?.message}`);
    }
  },
}

Sub-Agent Hooks

beforeSubAgent

Called before a sub-agent is spawned.

typescript
hooks: {
  beforeSubAgent: (payload, ctx) => {
    // payload.call - Sub-agent call details
    // payload.agentConfig - Sub-agent configuration
    // payload.parentRunId - Parent agent's run ID
    console.log(`Delegating to sub-agent: ${payload.agentConfig.name}`);
  },
}

afterSubAgent

Called after a sub-agent completes.

typescript
hooks: {
  afterSubAgent: (payload, ctx) => {
    // payload.call - Sub-agent call details
    // payload.agentConfig - Sub-agent configuration
    // payload.result - Sub-agent's output
    // payload.success - Whether sub-agent succeeded
    // payload.error - Error if failed
    // payload.durationMs - Execution duration
    // payload.subAgentRunId - Sub-agent's run ID
    console.log(`Sub-agent ${payload.agentConfig.name} completed`);
    console.log(`Duration: ${payload.durationMs}ms`);
  },
}

State & Message Hooks

onStateChange

Called when custom state is modified by tools, hooks, or the system.

typescript
hooks: {
  onStateChange: (payload, ctx) => {
    // payload.previousState - State before change
    // payload.newState - State after change
    // payload.source - What triggered change: 'tool' | 'hook' | 'system'
    // payload.toolName - Tool name if source is 'tool'
    console.log(`State changed by ${payload.source}`);
    console.log(`Before:`, payload.previousState);
    console.log(`After:`, payload.newState);
  },
}

onMessage

Called when a message is appended to the conversation.

typescript
hooks: {
  onMessage: (payload, ctx) => {
    // payload.message - The message that was appended
    // payload.messageIndex - Index in conversation
    console.log(`Message #${payload.messageIndex}: ${payload.message.role}`);
  },
}

Streaming Hooks

Not Yet Implemented

The onTextDelta and onThinking hooks are defined in the type system but not yet wired up in the runtime. They are reserved for future use. Streaming is currently handled via stream chunks - use the Streaming Guide to observe text deltas and thinking content.

onTextDelta (Future)

Will be called for each text delta during LLM streaming.

typescript
hooks: {
  onTextDelta: (delta, ctx) => {
    // delta - Incremental text string
    process.stdout.write(delta);
  },
}

onThinking (Future)

Will be called for thinking/reasoning content (Claude extended thinking, OpenAI o-series).

typescript
hooks: {
  onThinking: (content, isComplete, ctx) => {
    // content - Thinking text
    // isComplete - true when thinking block is complete
    if (isComplete) {
      console.log(`Thinking complete: ${content}`);
    }
  },
}

HookContext API

The context object passed to all hooks provides access to agent state and capabilities.

Read-Only Properties

typescript
interface HookContext<TState, TOutput> {
  // Execution info
  readonly runId: string;           // Unique run ID
  readonly agentType: string;       // Agent name
  readonly streamId: string;        // Stream ID
  readonly stepCount: number;       // Current step (0-indexed)
  readonly parentAgentId?: string;  // Parent agent ID (for sub-agents)
  readonly abortSignal: AbortSignal; // Cancellation signal

  // Session context (for tracing platforms)
  readonly sessionId?: string;      // Session grouping
  readonly userId?: string;         // User attribution
  readonly tags?: string[];         // Categorization tags
  readonly metadata?: Record<string, string>; // Custom metadata
}

Methods

getState()

Get a read-only snapshot of the current custom state. The return type is inferred from the AgentHooks<TState, TOutput> type parameters.

typescript
import type { AgentHooks } from '@helix-agents/sdk';

interface MyState { counter: number; }

const hooks: AgentHooks<MyState> = {
  onAgentStart: (payload, ctx) => {
    const state = ctx.getState(); // Returns MyState
    console.log(`Counter: ${state.counter}`);
  },
};

updateState(updater)

Modify custom state using Immer's draft pattern. The draft type is inferred from the AgentHooks<TState, TOutput> type parameters.

typescript
import type { AgentHooks } from '@helix-agents/sdk';

interface MyState { initialized: boolean; }

const hooks: AgentHooks<MyState> = {
  onAgentStart: (payload, ctx) => {
    ctx.updateState((draft) => { // draft is Draft<MyState>
      draft.initialized = true;
    });
  },
};

emit(chunk)

Emit a stream chunk directly.

typescript
hooks: {
  beforeTool: async (payload, ctx) => {
    await ctx.emit({
      type: 'custom',
      agentId: ctx.runId,
      agentType: ctx.agentType,
      timestamp: Date.now(),
      eventName: 'tool_starting',
      data: { tool: payload.tool.name },
    });
  },
}

emitCustom(eventName, data)

Convenience method to emit custom events.

typescript
hooks: {
  afterTool: async (payload, ctx) => {
    await ctx.emitCustom('tool_metrics', {
      tool: payload.tool.name,
      durationMs: payload.durationMs,
      success: payload.success,
    });
  },
}

Execution-Time Hooks

Hooks can be passed at execution time in addition to agent-level hooks:

typescript
// executor is a JSAgentExecutor instance (see Complete Example below)
const handle = await executor.execute(agent, 'Hello', {
  hooks: {
    onAgentComplete: async (payload, ctx) => {
      await auditLog({
        runId: ctx.runId,
        output: payload.output,
        duration: payload.durationMs,
      });
    },
  },
});

Invocation order: Agent hooks run first, then execution-time hooks.

Session Context

Pass session context at execution time for tracing platform integration:

typescript
// executor is a JSAgentExecutor instance (see Complete Example below)
const handle = await executor.execute(agent, 'Hello', {
  sessionId: 'conversation-123',  // Groups related traces
  userId: 'user-456',             // User attribution
  tags: ['production', 'premium'], // Categorization
  metadata: {                      // Custom key-value pairs
    environment: 'production',
    version: '1.0.0',
  },
});

These values are available in HookContext and can be used to propagate context to tracing platforms (see Common Patterns for examples).

Hook Composition

The framework provides utilities to compose hooks from multiple sources. Factory functions like createLoggingHooks() shown below are user-defined patterns (see Common Patterns).

Using composeHookManagers

Compose multiple hook sets into a single manager:

typescript
import { composeHookManagers } from '@helix-agents/sdk';

const hookManager = composeHookManagers(
  createLoggingHooks(logger),
  createMetricsHooks(metricsClient),
  createAuditHooks(auditService),
);

// executor is a JSAgentExecutor instance (see Complete Example below)
const handle = await executor.execute(agent, 'Hello', { hookManager });

Using createHookManager

Create a hook manager with initial hooks:

typescript
import { createHookManager } from '@helix-agents/sdk';

const hookManager = createHookManager({
  onAgentStart: (payload, ctx) => console.log('Starting'),
  onAgentComplete: (payload, ctx) => console.log('Done'),
});

// Register additional hooks
hookManager.register({
  afterTool: (payload, ctx) => console.log(`Tool: ${payload.tool.name}`),
});

Using mergeHooks

Merge agent-level and execution-level hooks:

typescript
import { mergeHooks } from '@helix-agents/sdk';

const manager = mergeHooks(
  agent.hooks,      // Agent-level hooks (run first)
  executionHooks,   // Execution-level hooks (run second)
);

Common Patterns

Logging Hook

typescript
import type { AgentHooks } from '@helix-agents/sdk';

function createLoggingHooks(logger: Logger): AgentHooks {
  return {
    onAgentStart: (payload, ctx) => {
      logger.info('Agent started', {
        runId: ctx.runId,
        agent: payload.agent.name,
        input: payload.input,
      });
    },
    onAgentComplete: (payload, ctx) => {
      logger.info('Agent completed', {
        runId: ctx.runId,
        stepCount: payload.stepCount,
        durationMs: payload.durationMs,
      });
    },
    onAgentFail: (payload, ctx) => {
      logger.error('Agent failed', {
        runId: ctx.runId,
        error: payload.error.message,
        recoverable: payload.recoverable,
      });
    },
    beforeTool: (payload, ctx) => {
      logger.debug('Tool starting', {
        runId: ctx.runId,
        tool: payload.tool.name,
      });
    },
    afterTool: (payload, ctx) => {
      logger.debug('Tool completed', {
        runId: ctx.runId,
        tool: payload.tool.name,
        success: payload.success,
        durationMs: payload.durationMs,
      });
    },
  };
}

Metrics Hook

typescript
import type { AgentHooks } from '@helix-agents/sdk';

function createMetricsHooks(metrics: MetricsClient): AgentHooks {
  return {
    onAgentComplete: (payload, ctx) => {
      metrics.histogram('agent.duration', payload.durationMs, {
        agent: ctx.agentType,
      });
      metrics.counter('agent.steps', payload.stepCount, {
        agent: ctx.agentType,
      });
    },
    afterLLMCall: (payload, ctx) => {
      metrics.histogram('llm.duration', payload.durationMs);
      if (payload.usage) {
        metrics.counter('llm.tokens.input', payload.usage.promptTokens ?? 0);
        metrics.counter('llm.tokens.output', payload.usage.completionTokens ?? 0);
      }
    },
    afterTool: (payload, ctx) => {
      metrics.histogram('tool.duration', payload.durationMs, {
        tool: payload.tool.name,
        success: String(payload.success),
      });
    },
  };
}

Rate Limiting Hook

typescript
import type { AgentHooks } from '@helix-agents/sdk';

function createRateLimitHooks(rateLimiter: RateLimiter): AgentHooks {
  return {
    beforeLLMCall: async (payload, ctx) => {
      const allowed = await rateLimiter.checkLimit(ctx.userId ?? 'anonymous');
      if (!allowed) {
        throw new Error('Rate limit exceeded');
      }
    },
  };
}

Custom Events Hook

Use emitCustom() to emit custom events that can be consumed by stream subscribers:

typescript
import type { AgentHooks } from '@helix-agents/sdk';

function createCustomEventsHooks(): AgentHooks {
  return {
    onAgentStart: async (payload, ctx) => {
      await ctx.emitCustom('agent.initialized', {
        agent: ctx.agentType,
        input: payload.input,
        timestamp: new Date().toISOString(),
      });
    },
    afterTool: async (payload, ctx) => {
      // Emit custom event for analytics
      await ctx.emitCustom('tool.executed', {
        tool: payload.tool.name,
        success: payload.success,
        durationMs: payload.durationMs,
      });
    },
    onStateChange: async (payload, ctx) => {
      // Track state changes for debugging
      await ctx.emitCustom('state.changed', {
        source: payload.source,
        toolName: payload.toolName,
      });
    },
  };
}

Custom events appear in the stream as { type: 'custom', eventName: '...', data: {...} } and can be consumed by any stream subscriber.

Complete Example

typescript
import {
  defineAgent,
  defineTool,
  composeHookManagers,
  JSAgentExecutor,
  InMemoryStateStore,
  InMemoryStreamManager,
} from '@helix-agents/sdk';
import type { AgentHooks } from '@helix-agents/sdk';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

// Define state schema
const StateSchema = z.object({
  searchCount: z.number().default(0),
  findings: z.array(z.string()).default([]),
});
type State = z.infer<typeof StateSchema>;

// Define output schema
const OutputSchema = z.object({
  summary: z.string(),
  sources: z.array(z.string()),
});
type Output = z.infer<typeof OutputSchema>;

// Create logging hooks
const loggingHooks: AgentHooks<State, Output> = {
  onAgentStart: (payload, ctx) => {
    console.log(`[${ctx.runId}] Starting research agent`);
  },
  onAgentComplete: (payload, ctx) => {
    console.log(`[${ctx.runId}] Completed in ${payload.stepCount} steps`);
  },
  beforeTool: (payload, ctx) => {
    console.log(`[${ctx.runId}] Calling: ${payload.tool.name}`);
  },
  afterTool: (payload, ctx) => {
    const status = payload.success ? 'OK' : 'FAILED';
    console.log(`[${ctx.runId}] ${payload.tool.name}: ${status} (${payload.durationMs}ms)`);
  },
};

// Create metrics hooks
const metricsHooks: AgentHooks<State, Output> = {
  afterLLMCall: (payload, ctx) => {
    if (payload.usage?.totalTokens) {
      console.log(`Tokens: ${payload.usage.totalTokens}`);
    }
  },
  onStateChange: (payload, ctx) => {
    console.log(`State updated by ${payload.source}`);
  },
};

// Define search tool
const searchTool = defineTool({
  name: 'search',
  description: 'Search the web',
  inputSchema: z.object({ query: z.string() }),
  execute: async (input, ctx) => {
    ctx.updateState<State>((draft) => {
      draft.searchCount++;
      draft.findings.push(`Found results for: ${input.query}`);
    });
    return { results: [`Result for ${input.query}`] };
  },
});

// Define agent (without hooks - we'll compose them at execution time)
const researchAgent = defineAgent({
  name: 'research-agent',
  systemPrompt: 'You are a research assistant. Use search to find information.',
  stateSchema: StateSchema,
  outputSchema: OutputSchema,
  llmConfig: { model: openai('gpt-4o') },
  tools: [searchTool],
});

// Create executor
const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const llmAdapter = new VercelAIAdapter();
const executor = new JSAgentExecutor(stateStore, streamManager, llmAdapter);

// Execute with composed hooks
async function runAgent() {
  // Compose multiple hook sets into one manager
  const hookManager = composeHookManagers(loggingHooks, metricsHooks);

  const handle = await executor.execute(researchAgent, 'Research quantum computing', {
    hookManager,
    sessionId: 'session-123',
    userId: 'user-456',
    tags: ['research', 'quantum'],
  });

  const result = await handle.result();
  console.log('Final output:', result.output);
}

runAgent();

Best Practices

1. Keep Hooks Lightweight

Hooks are awaited during agent execution, so long-running operations block progress. Avoid expensive blocking operations:

typescript
// Good: Non-blocking
onAgentComplete: (payload, ctx) => {
  metricsQueue.push({ runId: ctx.runId, duration: payload.durationMs });
},

// Avoid: Blocking network call
onAgentComplete: async (payload, ctx) => {
  await fetch('/api/metrics', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
},

2. Handle Errors Gracefully

Hook errors fail the agent by design. Wrap risky operations:

typescript
hooks: {
  onAgentComplete: async (payload, ctx) => {
    try {
      await sendToExternalService(payload);
    } catch (error) {
      logger.warn('Failed to send metrics', error);
      // Don't re-throw - agent already succeeded
    }
  },
}

3. Use Type Parameters

Leverage TypeScript for type-safe hooks:

typescript
import type { AgentHooks } from '@helix-agents/sdk';

interface MyState { counter: number; }
interface MyOutput { result: string; }

const hooks: AgentHooks<MyState, MyOutput> = {
  onAgentComplete: (payload, ctx) => {
    // payload.output is typed as MyOutput
    // ctx.getState() returns MyState
    const state = ctx.getState();
    console.log(`Counter: ${state.counter}, Result: ${payload.output.result}`);
  },
};

4. Compose for Reusability

Create reusable hook factories:

typescript
import type { AgentHooks } from '@helix-agents/sdk';

function createTimingHooks(): AgentHooks {
  const timings = new Map<string, number>();

  return {
    beforeTool: (payload) => {
      timings.set(payload.toolCall.id, Date.now());
    },
    afterTool: (payload) => {
      const start = timings.get(payload.toolCall.id);
      if (start) {
        console.log(`${payload.tool.name}: ${Date.now() - start}ms`);
        timings.delete(payload.toolCall.id);
      }
    },
  };
}

Usage Tracking with Hooks

For automated usage tracking (tokens, tool calls, sub-agents), use the built-in usage tracking system instead of manually collecting metrics in hooks:

typescript
import { InMemoryUsageStore } from '@helix-agents/store-memory';

const usageStore = new InMemoryUsageStore();
const handle = await executor.execute(agent, 'Hello', { usageStore });
await handle.result();

// Get aggregated usage data
const rollup = await handle.getUsageRollup();
console.log(`Total tokens: ${rollup?.tokens.total}`);
console.log(`Tool calls: ${rollup?.toolStats.totalCalls}`);

See Usage Tracking for full details on tracking tokens, tool usage, and custom metrics.

Next Steps

Released under the MIT License.