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
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:
- payload - Data specific to the hook event
- context -
HookContextwith agent info and capabilities
Agent Lifecycle Hooks
onAgentStart
Called when agent execution begins, before the first LLM call.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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).
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
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.
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.
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.
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.
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:
// 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:
// 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:
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:
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:
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
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
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
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:
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
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:
// 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:
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:
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:
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:
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
- Usage Tracking - Track tokens, tools, and custom metrics
- Hooks Internals - Deep dive into hook architecture
- Hooks API Reference - Complete type definitions
- Streaming - Real-time event handling
- State Management - Working with agent state