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-6-luna') },
hooks: {
onAgentStart: (payload, ctx) => {
console.log(`[${ctx.sessionId}] Starting with input: ${payload.input}`);
},
onAgentComplete: (payload, ctx) => {
console.log(`[${ctx.sessionId}] Completed in ${payload.durationMs}ms`);
},
beforeTool: (payload, ctx) => {
console.log(`[${ctx.sessionId}] 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
Which lifecycle hooks fire depends on how the run ends:
| How the run ends | Hooks that fire |
|---|---|
| Completes | onAgentComplete |
| Fails after it started | onAgentFail |
| Completes, but its terminal commit fails | onAgentComplete, then onAgentFail (the run ends failed) |
| Superseded by another entry | onAgentSuperseded only |
| Completes, but is superseded before its terminal commit | onAgentComplete, then onAgentSuperseded (the run ends superseded) |
| Fails at its entry (before its start hook fired) | none |
A run's entry is its run-start commit, its stream claim and its run_started chunk. A run that fails there never fired onAgentStart / onAgentResumed, so it fires no lifecycle hook at all. onAgentFail fires only for a run whose start hook fired. Temporal, DBOS and Cloudflare Workflows key this on a durable record of the start hook (Temporal and DBOS stage a marker before the hook runs), so a retried or recovered run still pairs its terminal hook with its start.
An interrupt() is not consistent across runtimes: runtime-js (and the Cloudflare Durable Object) and Temporal fire onAgentFail for an interrupted run, Cloudflare Workflows does not.
Start signals: one per run
Every run fires exactly one start signal, chosen by how the run began. On runtime-js, the Cloudflare Durable Object and Cloudflare Workflows:
| How the run began | Start signal |
|---|---|
A new turn: execute(), a chat send, a with_message resume, a child's start | onAgentStart (startSignal: true) |
A continue / with_confirmation resume, or a from_checkpoint rewind | onAgentResumed |
retry() | onAgentRetry |
A turn's start work (onAgentStart and the initial onMessage) runs once per turn, and its writes are committed in the turn's start commit before the first model call. A run that owes it (a retry or rewind whose target is before the turn's start commit, a recovery that never got that far) runs it after its own signal, with startSignal: false. onMessage re-fires for the turn's input when a rewind or recovery discards the start commit: the turn's input messages (its first run's input rows) reach onMessage again, even when the run that owes the start brings no new message. A run whose turn already committed its start never runs it again: a recovered run, a retry of a later step and a stopped run all keep the original writes.
Temporal and DBOS keep their previous behaviour until #177 MR 2: onAgentStart fires for every run that is not a resume (a retry included), with startSignal: true, and onAgentRetry does not fire.
onAgentStart
Called once per turn, before the turn's first LLM call (see the start-signal table above). Its updateState() writes, with the initial onMessage's, land in the turn's start commit, so they survive a stop or a crash in the first model call. An onAgentStart or initial onMessage hook that throws fails the run, and onAgentFail then fires once.
payload.startSignal is true when onAgentStart is the run's start signal (a new turn), and false when the run began with another signal and owes the turn's start work.
Temporal fires the start hooks at least once
A Temporal activity retry never re-fires onAgentStart: the activity replays the hooks' recorded customState writes instead. But if the worker crashes after the hooks started and before their outcome was recorded, the retried attempt fires them again. Keep onAgentStart idempotent on Temporal.
hooks: {
onAgentStart: (payload, ctx) => {
// payload.agent - Agent configuration
// payload.input - User input string
// payload.initialState - Initial custom state
// payload.startSignal - true for a new turn; false for owed start work
console.log(`Starting agent ${payload.agent.name}`);
console.log(`Input: ${payload.input}`);
},
}onAgentComplete
Called when the agent completes successfully with structured output. This hook is guaranteed to fire regardless of how the agent completes:
__finish__tool — fires during normal stream finalizationfinishWithtools — fires after the tool output is promoted to agent output- Sub-agents — fires for each sub-agent independently, without closing the parent's stream
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);
},
}Cross-Runtime Guarantee
onAgentComplete fires consistently across all runtimes (JS, Temporal, Cloudflare, DBOS) for both completion paths and sub-agents. This makes it safe to use for tracing, metrics, and cleanup logic.
Terminal hooks run before the terminal commit. The run is still live while onAgentComplete / onAgentFail run. Then the runtime writes the run's terminal state as one commit (a checkpoint of kind state), then the terminal run status, then ends or fails the stream. The terminal commit is written on every terminal exit: completion, failure and stop. What happens to a terminal hook's ctx.updateState() writes:
| Runtime | onAgentComplete writes | onAgentFail writes |
|---|---|---|
| Temporal, Cloudflare Workflows | in the terminal commit | in the terminal commit |
| runtime-js, Cloudflare Durable Object | in the terminal commit | dropped |
| DBOS | n/a (no updateState, below) | n/a |
On runtime-js, an onAgentComplete hook that throws has its partial writes dropped, and the stream's end frame then carries no state. If a completing run's terminal commit fails, the run ends failed and onAgentFail fires after onAgentComplete. onAgentComplete's writes are never persisted in that case.
onAgentFail
Called when the agent fails with an error. It does not fire for a run that failed at its entry, or for a superseded run (see the table above).
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}`);
},
}onAgentSuperseded
Called when the run is superseded: another entry (execute(), resume(), retry()) took over the session with a newer run-start commit, one of this run's own writes was refused by the superseded-run fence, or (runtime-js) the run lost its run lease. The run writes nothing more and does not touch the stream; the superseding run drives the session. The run's AgentResult.status is 'superseded', and neither onAgentComplete nor onAgentFail fires, with one exception: when the supersede lands after onAgentComplete fired and before the run's terminal (K5) commit, onAgentComplete has already fired and onAgentSuperseded follows it. The last terminal hook is authoritative (as with onAgentComplete then onAgentFail when the terminal commit fails): treat a superseded after a complete as the run's real outcome.
hooks: {
onAgentSuperseded: (payload, ctx) => {
// payload.error - The typed stop (code: 'state_run_superseded')
// payload.finalState - Custom state when the run stopped
// payload.stepCount - Steps executed before the stop
// payload.durationMs - Duration until the stop
console.warn(`Run ${ctx.runId} superseded: ${payload.error.message}`);
},
}The hook is optional and best-effort: an error it throws is logged. The payload's detail differs by runtime. runtime-js passes the run's in-memory state and real duration. Temporal and Cloudflare Workflows read finalState and stepCount from the session as stored (which the superseding run may already have changed), and Temporal reports durationMs: 0. DBOS passes finalState: {}, stepCount: 0 and durationMs: 0. Use the hook to close spans and count supersessions, not to read the run's final state.
A supersede raised inside a tool body (runtime-js and the Cloudflare Durable Object: a tool that throws its own run's RunSupersededError, for example a fenced store write) ends the run at once. The step's other tools run in parallel, and those already started keep running after the run ends: their afterTool and onMessage hooks can fire AFTER onAgentSuperseded, and their tool_end frames reach the stream until the newer run claims it. Nothing they produce is persisted (the step is never committed), and no later tool of the run starts. Treat onAgentSuperseded as the run's outcome even when sibling tool hooks follow it (FU-SUPERSEDE-IN-TOOL-SIBLINGS-AFTER-TERMINAL-HOOK).
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-6-luna')
// 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`);
if (payload.usage.cachedTokens) {
console.log(` Cached: ${payload.usage.cachedTokens} (cache hit)`);
}
if (payload.usage.cacheWriteTokens) {
console.log(` Cache write: ${payload.usage.cacheWriteTokens} (new cache entry)`);
}
}
console.log(`LLM call took ${payload.durationMs}ms`);
},
}On Temporal
beforeLLMCall fires before the attempt's step_start chunk, so a chunk the hook emits with ctx.emit precedes it. A call the prompt-prefix check refuses (framework_prompt_prefix_changed) fires beforeLLMCall but not afterLLMCall. afterLLMCall fires once per attempt: a forced-completion call whose provider error is retryable is retried by the activity retry policy, so one forced attempt can fire it up to three times. The hook's result does not carry onErrorFailure on Temporal: the held error report is written after the hook, so a failed error-chunk write is only known afterwards (it is logged and set on the returned step result), whereas runtime-js and CF Workflows settle it before the hook.
A model call that is retried (runtime-js, the CF DO and CF Workflows for a retryable: true error) fires beforeLLMCall and afterLLMCall for every attempt, so a tracing or usage hook sees each billed call; the retried attempt's afterLLMCall gets its error result. A call whose generateStep throws fires no afterLLMCall.
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 (undefined for remote sub-agents)
// payload.parentSessionId - Parent agent's session ID
console.log(`Delegating to sub-agent: ${payload.agentConfig?.name ?? payload.call.agentType}`);
},
}afterSubAgent
Called after a sub-agent completes. A common use is to fold the sub-agent's result into the parent's customState via ctx.updateState():
hooks: {
afterSubAgent: (payload, ctx) => {
// payload.call - Sub-agent call details (payload.call.input is the tool input)
// payload.agentConfig - Sub-agent configuration (undefined for remote sub-agents)
// payload.result - Sub-agent's output
// payload.success - Whether sub-agent succeeded
// payload.error - Error if failed
// payload.durationMs - Execution duration
// payload.subSessionId - Sub-agent's session ID (unique identifier for the sub-agent's session)
const threadId = (payload.call.input as { threadId: number }).threadId;
ctx.updateState((draft) => {
draft.notes[threadId].status = payload.success ? 'covered' : 'failed';
});
},
}Parallel folds compose
When a parent fans out many sub-agents at once, their afterSubAgent folds compose at element granularity on every runtime — disjoint edits (notes[3] and notes[5], or two draft.results.push(...) appends) all survive; additions, in-place mutations, and removals are preserved. No locks are used. Give each child a disjoint slice; same-exact-path concurrent edits resolve last-writer-within-the-wave. See Sub-Agents → Folding parallel results into customState for the full guarantee and the per-runtime hook caveats (Cloudflare Workflows fires afterSubAgent at wave settlement; Temporal does not fire beforeSubAgent).
State & Message Hooks
Canonical hook firing order
For the regular tool execution path, the canonical hook firing order is beforeTool → onStateChange → onMessage → afterTool. This order holds across all runtimes, including DBOS, so portable hook code can rely on it.
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}`);
},
}Fires for user-input messages too
onMessage fires for the user-input message(s) appended on initial entry (not just assistant and tool-result messages). This now holds across all five runtimes (JS, Cloudflare, Temporal, DBOS) — the per-user-message onMessage parity gap on Temporal/DBOS was closed under FU-USER-INPUT-ONMESSAGE-CROSS-RUNTIME.
Suspension Lifecycle Hooks (v7)
In v7's stateless suspension model the runLoop EXITS at every HITL boundary and resume creates a fresh execution. Two lifecycle hooks observe these transitions and fire across every runtime (JS, Temporal, CFW Workflows, CF DO, DBOS):
onAgentSuspended
Called when an agent reaches a suspension boundary — pending client tool, pending approval, or awaiting sub-agent children. The suspension is durable; the runtime returns control to the caller (workflow exits, request response closes, etc.) until the boundary is satisfied.
hooks: {
onAgentSuspended: async (ctx) => {
// ctx.sessionId / ctx.runId
// ctx.reason — 'client_tool' | 'awaiting_children' | 'step_partial'
// ctx.pendingToolCallIds — string[] of tool calls awaiting client submission
// (empty unless reason === 'client_tool')
// ctx.awaitingChildren — SuspendedChildWait[] of child sessions being awaited
// (empty unless reason === 'awaiting_children')
// ctx.stepCount — number of steps completed before suspension
console.log(`Agent suspended: ${ctx.reason} (${ctx.pendingToolCallIds.length} pending tools)`);
metrics.incr('agent.suspended', { reason: ctx.reason });
},
}On DBOS, a client-tool or approval suspension runs in this order: the run's suspended_* status, then each waiting call's beforeTool hook, pending registration and frame (tool_start / tool_approval_request), then the suspension commit, then onAgentSuspended, then the stream pause. So beforeTool fires before onAgentSuspended, and onAgentSuspended fires only once the suspension is committed. An error thrown by onAgentSuspended is logged and does not stop the suspension.
Keep onAgentSuspended fast on the Cloudflare DO. The run is still executing while the hook runs, and a client-tool submit is answered only once its continuation's entry has started (CP-53). So a submit that lands while the hook is still running waits for the hook. Past concurrentEntryTimeoutMs (30 s) the submit answers a typed 500 transport_error; the result is already stored and the run still continues (the DO submit answer).
onAgentResumed
Called when a fresh execution starts to resume a previously-suspended run. On Temporal/CFW Workflows this fires inside the new resume workflow instance (Temporal …__resume-<runId>, Cloudflare Workflows …__resume__<key>); on JS/CF DO it fires when the next chat-handler request lands and the runtime reads the suspension context. On runtime-js, the CF DO, Temporal and Cloudflare Workflows, errors from this hook are logged but not fatal — resume proceeds even if the hook throws. On DBOS it is the run's start hook, and a throwing onAgentResumed fails the run (with one onAgentFail), like a throwing onAgentStart.
hooks: {
onAgentResumed: async (ctx) => {
// ctx.sessionId / ctx.runId
// ctx.previousRunId — runId of the run that suspended (for span linkage / audit)
// ctx.resumedFromCheckpointId — checkpoint restored at resume time (if applicable)
console.log(`Agent resuming ${ctx.runId} from ${ctx.previousRunId}`);
},
}previousRunId linkage contract
previousRunId is the runId of the run that suspended — always populated on resume across all runtimes (JS, Temporal, Cloudflare, DBOS). Use it for span linkage / audit / tracing-parent identification. Do not use runId !== previousRunId to detect a resume: on DBOS Branch 1 (recv-driven in-place continuation) runId === previousRunId (a self-loop), so the inequality check fails there. See docs/internals/concepts.md > "Resume runId semantics".
resumeCount is not on this context
resumeCount is a real field on SessionState, not on AgentResumedHookContext. There is no incrementing per-resume counter on the hook ctx — read it from session state if you need it.
See
@helix-agents/tracing-langfusefor a tracing integration that wires both hooks to span events.
onAgentRetry
The start signal of a run that retry() began (runtime-js, the Cloudflare Durable Object and Cloudflare Workflows; not yet Temporal or DBOS). It fires after the retried run's run-start commit and before its first step. It is observe-only, like onAgentResumed: errors are logged, never fatal. When the state the retry restores owes the turn's start work, onAgentStart follows it with startSignal: false.
hooks: {
onAgentRetry: (ctx) => {
// ctx.sessionId / ctx.runId - the retried run (a new run id)
// ctx.retriedRunId - the failed run it re-runs
// ctx.checkpointId - the checkpoint it restored (absent: genesis)
// ctx.hasMessage - whether retry() carried a new message
console.log(`Retrying ${ctx.retriedRunId} as ${ctx.runId}`);
},
}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 sessionId: string; // Unique session identifier
readonly runId: string; // The executing run
readonly agentType: string; // Agent name
readonly streamId: string; // Stream ID
readonly stepCount: number; // Current step (0-indexed)
readonly parentSessionId?: string; // Parent session ID (for sub-agents)
readonly abortSignal: AbortSignal; // Cancellation signal
// Session context (for tracing platforms)
readonly userId?: string; // User attribution
readonly tags?: string[]; // Categorization tags
readonly metadata?: Record<string, string>; // Custom metadata
}Methods
DBOS hook contexts have no methods
DBOS passes the hook context across a durable step boundary, so the lifecycle and suspension hooks (onAgentStart, onAgentComplete, onAgentFail, onAgentSuperseded, onAgentSuspended, ...) receive getState, updateState, emit and emitCustom as undefined. Guard calls to them in hooks that must run on DBOS.
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;
});
},
};State written during a step that is stopped
A state write persists if and only if the row that records its work persists. This is the same rule as "a stop refuses new work, never the record of completed work", on every runtime and for both stop kinds:
- Discarded: the writes of a step that is stopped before it commits. That covers its
afterLLMCallwrites, and the writes of every tool whose result is not recorded (the call is pairedrun_stopped, or its step commit was refused). - Kept: the writes of a call whose recorded result survives the stop (a
finishWithor approved tool that ran, a child outcome that arrived). They are kept with that result. - Your concern: side effects outside helix state (an HTTP call, a write to your own database). A hook or tool that needs them undone on a stop must do it itself.
On runtime-js, the Cloudflare Durable Object and Cloudflare Workflows, a step that fails without a stop keeps its afterLLMCall writes.
emit(chunk)
Emit a stream chunk directly.
hooks: {
beforeTool: async (payload, ctx) => {
await ctx.emit({
type: 'custom',
agentId: ctx.sessionId,
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({
sessionId: ctx.sessionId,
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', {
sessionId: ctx.sessionId,
agent: payload.agent.name,
input: payload.input,
});
},
onAgentComplete: (payload, ctx) => {
logger.info('Agent completed', {
sessionId: ctx.sessionId,
stepCount: payload.stepCount,
durationMs: payload.durationMs,
});
},
onAgentFail: (payload, ctx) => {
logger.error('Agent failed', {
sessionId: ctx.sessionId,
error: payload.error.message,
recoverable: payload.recoverable,
});
},
beforeTool: (payload, ctx) => {
logger.debug('Tool starting', {
sessionId: ctx.sessionId,
tool: payload.tool.name,
});
},
afterTool: (payload, ctx) => {
logger.debug('Tool completed', {
sessionId: ctx.sessionId,
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);
if (payload.usage.cachedTokens) {
metrics.counter('llm.tokens.cached', payload.usage.cachedTokens);
}
}
},
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.sessionId}] Starting research agent`);
},
onAgentComplete: (payload, ctx) => {
console.log(`[${ctx.sessionId}] Completed in ${payload.stepCount} steps`);
},
beforeTool: (payload, ctx) => {
console.log(`[${ctx.sessionId}] Calling: ${payload.tool.name}`);
},
afterTool: (payload, ctx) => {
const status = payload.success ? 'OK' : 'FAILED';
console.log(`[${ctx.sessionId}] ${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-6-luna') },
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({ sessionId: ctx.sessionId, 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.
Tracing & Observability
For production observability, use the Langfuse tracing integration instead of manual logging hooks:
import { createLangfuseHooks } from '@helix-agents/tracing-langfuse';
const { hooks, flush } = createLangfuseHooks({
release: '1.0.0',
defaultTags: ['production'],
});
const agent = defineAgent({
name: 'my-agent',
hooks, // Automatically traces all execution
// ...
});
// Execute with metadata for attribution
await executor.execute(agent, input, {
userId: 'user-123',
sessionId: 'conversation-456',
tags: ['premium'],
metadata: { feature: 'chat' },
});
// Flush in serverless environments
await flush();The Langfuse integration automatically captures:
- Agent lifecycle (start, complete, fail)
- LLM calls with model, tokens, latency
- Tool executions with arguments and timing
- Sub-agent calls with hierarchy
- Custom metadata and tags
See Tracing Guide for full setup and configuration.
Next Steps
- Tracing & Observability - Langfuse integration for production monitoring
- 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