Skip to main content
Agents

Agent Hooks

Hooks intercept specific points in the agent execution pipeline. Use them to observe or modify behavior during agent operations: before a request starts, when preparing messages for the LLM, before and after tool execution, or after completion.

Defining Hooks​

Define hooks using the createHooks helper function. Pass the resulting object to the Agent constructor, or to individual method calls like generateText and streamText.

import {
Agent,
createHooks,
messageHelpers,
type AgentTool,
type AgentOperationOutput,
type VoltAgentError,
type OnStartHookArgs,
type OnEndHookArgs,
type OnPrepareMessagesHookArgs,
type OnPrepareModelMessagesHookArgs,
type OnToolStartHookArgs,
type OnToolEndHookArgs,
type OnToolErrorHookArgs,
type OnHandoffHookArgs,
} from "@voltagent/core";

// Define a collection of hooks using the helper
const myAgentHooks = createHooks({
/**
* Called before the agent starts processing a request.
*/
onStart: async (args: OnStartHookArgs) => {
const { agent, context } = args;
console.log(`[Hook] Agent ${agent.name} starting interaction at ${new Date().toISOString()}`);
console.log(`[Hook] Operation ID: ${context.operationId}`);
},

/**
* Called after VoltAgent sanitizes UI messages but before the LLM receives them.
* `rawMessages` contains the unsanitized list for inspection or metadata recovery.
*/
onPrepareMessages: async (args: OnPrepareMessagesHookArgs) => {
const { messages, rawMessages, context } = args;
console.log(`Preparing ${messages.length} sanitized messages for LLM`);

// Add timestamp to each message
const timestamp = new Date().toLocaleTimeString();
const enhanced = messages.map((msg) => messageHelpers.addTimestampToMessage(msg, timestamp));

if (rawMessages) {
// Access raw message structure for audit logging
console.debug(`First raw message parts:`, rawMessages[0]?.parts);
}

return { messages: enhanced };
},

/**
* Called after UI messages are converted into provider-specific ModelMessage objects.
*/
onPrepareModelMessages: async (args: OnPrepareModelMessagesHookArgs) => {
const { modelMessages, uiMessages } = args;
console.log(`Model payload contains ${modelMessages.length} messages`);

// Inject a system message if none exists
if (!modelMessages.some((msg) => msg.role === "system")) {
return {
modelMessages: [
{
role: "system",
content: [{ type: "text", text: "Operate within safety budget" }],
},
...modelMessages,
],
};
}

return {};
},

/**
* Called after the agent completes a request (success or failure).
*/
onEnd: async (args: OnEndHookArgs) => {
const { agent, output, error, context } = args;
if (error) {
console.error(`[Hook] Agent ${agent.name} finished with error:`, error.message);
console.error(`[Hook] Error Details:`, JSON.stringify(error, null, 2));
} else if (output) {
console.log(`[Hook] Agent ${agent.name} finished successfully.`);
// Log aggregate usage or inspect output type
if ("totalUsage" in output && output.totalUsage) {
console.log(`[Hook] Total Token Usage: ${output.totalUsage.totalTokens}`);
}
if ("text" in output && output.text) {
console.log(`[Hook] Final text length: ${output.text.length}`);
}
if ("object" in output && output.object) {
console.log(`[Hook] Final object keys: ${Object.keys(output.object).join(", ")}`);
}
}
},

/**
* Called before a tool executes.
*/
onToolStart: async (args: OnToolStartHookArgs) => {
const { agent, tool, context, args: toolArgs } = args;
console.log(`[Hook] Agent ${agent.name} starting tool: ${tool.name}`);
console.log(`[Hook] Tool arguments:`, toolArgs);
},

/**
* Called after a tool completes or throws an error.
*/
onToolEnd: async (args: OnToolEndHookArgs) => {
const { agent, tool, output, error, context } = args;
if (error) {
console.error(`[Hook] Tool ${tool.name} failed:`, error.message);
console.error(`[Hook] Tool Error Details:`, JSON.stringify(error, null, 2));
} else {
console.log(`[Hook] Tool ${tool.name} completed with result:`, output);
}
},

/**
* Called only when a tool throws, before VoltAgent serializes the error payload.
*/
onToolError: async (args: OnToolErrorHookArgs) => {
const { tool, originalError } = args;
console.error(`[Hook] Raw tool error from ${tool.name}:`, originalError.message);

// Return { output } to customize what gets sent back to the model.
return {
output: {
error: true,
message: "External API request failed",
},
};
},

/**
* Called when a task is handed off from a source agent to this agent.
*/
onHandoff: async (args: OnHandoffHookArgs) => {
const { agent, sourceAgent } = args;
console.log(`[Hook] Task handed off from ${sourceAgent.name} to ${agent.name}`);
},
});

// Pass hooks to the Agent constructor
const agentWithHooks = new Agent({
name: "My Agent with Hooks",
instructions: "An assistant demonstrating hooks",
model: "openai/gpt-4o",
hooks: myAgentHooks,
});

// Or define hooks inline
const agentWithInlineHooks = new Agent({
name: "Inline Hooks Agent",
instructions: "Another assistant",
model: "openai/gpt-4o",
hooks: {
onStart: async ({ agent, context }) => {
/* ... */
},
onEnd: async ({ agent, output, error, context }) => {
/* ... */
},
},
});

Passing Hooks to Methods​

Pass hooks to generateText, streamText, generateObject, or streamObject to run hooks for that specific invocation only.

warning

Method-level hooks do not override agent-level hooks. Both will execute. For most hooks, the method-level hook runs first, then the agent-level hook. For onPrepareMessages and onPrepareModelMessages, the method-level hook replaces the agent-level hook entirely.

const agent = new Agent({
name: "My Agent with Hooks",
instructions: "An assistant demonstrating hooks",
model: "openai/gpt-4o",
hooks: myAgentHooks,
});

await agent.generateText("Hello, how are you?", {
hooks: {
onEnd: async ({ context }) => {
console.log("End of generation for this invocation!");
},
},
});

For example, store conversation history only for specific endpoints:

const agent = new Agent({
name: "Translation Agent",
instructions: "A translation agent that translates text from English to French",
model: "openai/gpt-4o",
});

// for the translate endpoint, we don't want to store the conversation history
app.post("/api/translate", async (req, res) => {
const result = await agent.generateText(req.body.text);
return result;
});

// for the chat endpoint, we want to store the conversation history
app.post("/api/translate/chat", async (req, res) => {
const result = await agent.streamText(req.body.text, {
hooks: {
onEnd: async ({ context }) => {
await chatStore.save({
conversationId: context.conversationId,
messages: context.steps,
});
},
},
});

return result.textStream;
});

Available Hooks​

All hooks receive a single argument object containing relevant information.

Choosing the right message hook​

HookStageWhen to use
onPrepareMessagesOperates on sanitized UIMessage[] built from memory + user inputTransform messages using helper functions, redact content, or access rawMessages for logging.
onPrepareModelMessagesRuns after convertToModelMessages on ModelMessage[]Apply provider-specific adjustments, inject system directives, or modify the final payload structure.

Use onPrepareMessages for general transformations and onPrepareModelMessages for provider-specific adjustments. Both hooks can be used together; VoltAgent applies them in that order.

onStart​

  • Triggered: Before the agent begins processing a request (generateText, streamText, etc.).
  • Argument Object (OnStartHookArgs): { agent: Agent, context: OperationContext }
  • Use Cases: Initialization logic, request logging, setting up request-scoped resources.
// Example: Log the start of an operation
onStart: async ({ agent, context }) => {
console.log(`Agent ${agent.name} starting operation ${context.operationId}`);
};

onPrepareMessages​

  • Triggered: After VoltAgent assembles conversation history and sanitizes UI messages, before conversion to provider-specific payloads.
  • Argument Object (OnPrepareMessagesHookArgs): { messages: UIMessage[], rawMessages?: UIMessage[], context: OperationContext, agent: Agent }
  • Use Cases: Transform sanitized messages (add timestamps, redact content), or access rawMessages for logging or metadata recovery.
  • Return: { messages: UIMessage[] } to replace the sanitized list, or an empty object to keep the original.
  • Notes:
    • messages contains sanitized UI messages safe for the LLM.
    • rawMessages contains the full structure before sanitization, useful for logging or metadata reconstruction.
onPrepareMessages: async ({ messages, rawMessages }) => {
const tagged = messages.map((msg) =>
messageHelpers.addTimestampToMessage(msg, new Date().toISOString())
);

if (rawMessages) {
auditTrail.write(rawMessages); // your own analytics sink
}

return { messages: tagged };
};

onPrepareModelMessages​

  • Triggered: After UI messages are converted via convertToModelMessages, immediately before the provider receives them.
  • Argument Object (OnPrepareModelMessagesHookArgs): { modelMessages: ModelMessage[], uiMessages: UIMessage[], context: OperationContext, agent: Agent }
  • Use Cases: Apply provider-specific transformations, inject final system messages, compress conversation length, or add structured metadata.
  • Return: { modelMessages: ModelMessage[] } with a replacement list, or an empty object to keep the original.
  • Tip: Use onPrepareMessages for general transformations, and this hook for provider-specific adjustments.
onPrepareModelMessages: async ({ modelMessages }) => {
// Force the last message to include a "speak clearly" reminder for voice models
const last = modelMessages.at(-1);
if (last && last.role === "user" && Array.isArray(last.content)) {
last.content.push({ type: "text", text: "Please answer succinctly." });
}

return { modelMessages };
};

onEnd​

  • Triggered: After the agent completes processing a request (success or failure).
  • Argument Object (OnEndHookArgs): { agent: Agent, output: AgentOperationOutput | undefined, error: VoltAgentError | undefined, conversationId: string, context: OperationContext }
  • Use Cases: Cleanup, logging, analyzing output or errors, recording usage statistics, storing conversation history.
  • Note: The output structure depends on the method called. Check for text or object fields. error contains the structured VoltAgentError on failure.
// Example: Log the outcome of an operation and store conversation history
onEnd: async ({ agent, output, error, conversationId, context }) => {
if (error) {
console.error(`Agent ${agent.name} operation ${context.operationId} failed: ${error.message}`);
console.log(`User input: "${context.historyEntry.input}"`);
// Only user input available on error (no assistant response)
} else {
// Check output type if needed
if (output && "text" in output) {
console.log(
`Agent ${agent.name} operation ${context.operationId} succeeded with text output.`
);
} else if (output && "object" in output) {
console.log(
`Agent ${agent.name} operation ${context.operationId} succeeded with object output.`
);
} else {
console.log(`Agent ${agent.name} operation ${context.operationId} succeeded.`);
}

// Access input and output directly from context
console.log("Request input:", context.input);
console.log("Generated output:", context.output);

// Log the complete conversation flow
console.log(`Conversation flow:`, {
user: context.historyEntry.input,
assistant: context.steps, // the assistant steps
totalMessages: context.steps.length,
toolInteractions: context.steps.flatMap((s) => s.toolInvocations || []).length,
toolsUsed: context.steps.flatMap((s) => s.toolInvocations || []).map((t) => t.toolName),
});

// Log complete interaction using input/output
console.log("Full interaction:", {
input: context.input,
output: context.output,
userId: context.userId,
conversationId: context.conversationId,
operationId: context.operationId,
});

// Log usage if available
if (output?.totalUsage) {
console.log(` Total usage: ${output.totalUsage.totalTokens} tokens`);
}
}
};

onRetry​

  • Triggered: When VoltAgent schedules a retry for an LLM attempt or a middleware abort.
  • Argument Object (OnRetryHookArgs): Includes source, operation, context, and retry details.
  • Use Cases: Retry metrics, alerts, tracking retry sources.
onRetry: async (args) => {
if (args.source === "llm") {
console.log(
`LLM retry ${args.nextAttempt}/${args.maxRetries + 1} for ${args.modelName} (${args.operation})`
);
} else {
console.log(
`Middleware retry ${args.retryCount + 1}/${args.maxRetries + 1} for ${args.middlewareId ?? "unknown"}`
);
}
};

onFallback​

  • Triggered: When VoltAgent selects the next model candidate.
  • Argument Object (OnFallbackHookArgs): Includes stage, fromModel, fromModelIndex, nextModel, and error.
  • Use Cases: Tracking model failover, audit logs, fallback diagnostics.
onFallback: async ({ stage, fromModel, nextModel, operation }) => {
console.warn(
`Fallback (${stage}) from ${fromModel} to ${nextModel ?? "next"} during ${operation}`
);
};

onToolStart​

  • Triggered: Before an agent executes a tool.
  • Argument Object (OnToolStartHookArgs):
    • agent: Agent instance running the tool
    • tool: The tool being executed
    • args: Tool input arguments
    • context: Operation context for the current call
    • options: ToolExecuteOptions (includes toolContext, abortController, etc.)
  • Use Cases: Logging tool usage, inspecting tool arguments, or validating inputs before execution.
// Example: Log tool invocation with arguments
onToolStart: async ({ agent, tool, args, context }) => {
console.log(`Agent ${agent.name} invoking tool '${tool.name}'`);
console.log(`Tool arguments:`, args);
};

onToolEnd​

  • Triggered: After a tool execution completes or throws an error.
  • Argument Object (OnToolEndHookArgs):
    • agent: Agent instance running the tool
    • tool: The tool that completed
    • output: Tool output (undefined on error)
    • error: VoltAgentError when tool throws (undefined on success)
    • context: Operation context for the current call
    • options: ToolExecuteOptions (includes toolContext, abortController, etc.)
  • Return: { output } to replace the tool result. The replacement is validated again if the tool has an outputSchema.
  • Use Cases: Logging tool results or errors, post-processing output, triggering actions based on success or failure.
// Example: Log the result or error of a tool execution
onToolEnd: async ({ agent, tool, output, error, context }) => {
if (error) {
console.error(
`Tool '${tool.name}' failed in operation ${context.operationId}: ${error.message}`
);
} else {
console.log(
`Tool '${tool.name}' succeeded in operation ${context.operationId}. Result:`,
output
);
}
};

onToolError​

  • Triggered: Only when a tool throws, before VoltAgent builds its default serialized error result.
  • Argument Object (OnToolErrorHookArgs):
    • agent: Agent instance running the tool
    • tool: The tool that failed
    • args: Tool input arguments
    • error: Structured VoltAgentError for the tool failure
    • originalError: Original thrown error normalized to an Error instance
    • context: Operation context for the current call
    • options: ToolExecuteOptions (includes toolContext, abortController, etc.)
  • Return: Optional { output } replacement payload. If omitted, VoltAgent returns its default serialized error payload.
  • Use Cases: Centralized error normalization (for example Axios), redacting sensitive fields, shrinking error payload size before it is sent to the model.
onToolError: async ({ tool, error, originalError }) => {
const maybeAxios = (originalError as any).isAxiosError === true;
if (!maybeAxios) {
return;
}

return {
output: {
error: true,
name: error.name,
message: originalError.message,
code: (originalError as any).code,
status: (originalError as any).response?.status,
},
};
};

Tool-level hooks (per tool)​

Tool hooks run for a specific tool instance and are called before/after execution. Tool-level onEnd runs before agent-level onToolEnd. If both return { output }, the agent hook wins. Any override is re-validated when outputSchema is present.

Tool hook parameters:

  • onStart: { tool, args, options }
  • onEnd: { tool, args, output, error, options } (return { output } to override)
import { createTool } from "@voltagent/core";
import { z } from "zod";

const normalizeTool = createTool({
name: "normalize_text",
description: "Normalize and trim text",
parameters: z.object({ text: z.string() }),
execute: async ({ text }) => text,
hooks: {
onStart: ({ tool }) => {
console.log(`[tool] ${tool.name} starting`);
},
onEnd: ({ output }) => {
if (typeof output === "string") {
return { output: output.trim() };
}
},
},
});

onHandoff​

  • Triggered: When one agent delegates a task to another agent via the delegate_task tool.
  • Argument Object (OnHandoffHookArgs): { agent: Agent, sourceAgent: Agent }
  • Use Cases: Tracking workflow in multi-agent systems, logging agent collaboration.
// Example: Log agent handoffs
onHandoff: async ({ agent, sourceAgent }) => {
console.log(`Task handed off from agent '${sourceAgent.name}' to agent '${agent.name}'`);
};

Hook Execution and Error Handling​

  • Async Execution: Hooks can be async functions. VoltAgent awaits completion before proceeding. Long-running operations in hooks add latency to agent response time.
  • Error Handling: Errors thrown inside hooks may interrupt agent execution. Use try...catch within hooks or design them to be reliable.
  • Hook Merging: When hooks are passed to both the Agent constructor and a method call:
    • Most hooks (onStart, onEnd, onError, onHandoff, onToolStart, onToolEnd, onToolError, onStepFinish, onRetry, onFallback) execute both: method-level first, then agent-level.
    • Message hooks (onPrepareMessages, onPrepareModelMessages) do not merge: method-level replaces agent-level entirely.

Additional Hooks​

  • onError: Called when an error occurs during agent execution. Receives { agent: Agent, error: Error, context: OperationContext }.
  • onToolError: Called when a tool throws, before default tool error serialization. Receives { agent, tool, args, error, originalError, context } and can return { output }.
  • onStepFinish: Called after each step in multi-step agent execution. Receives { agent: Agent, step: any, context: OperationContext }.
  • onRetry: Called when VoltAgent schedules a retry. Receives { source, operation, ... } with retry metadata.
  • onFallback: Called when VoltAgent selects the next model candidate. Receives { stage, fromModel, nextModel, error, ... }.

Common Use Cases​

  1. Logging & Observability: Track execution steps, timings, inputs, outputs, and errors.
  2. Analytics: Collect usage data (token counts, tool usage frequency, success/error rates).
  3. Message Transformation: Modify messages before they reach the LLM or provider.
  4. State Management: Initialize or clean up request-specific resources.
  5. Workflow Orchestration: Trigger external actions based on agent events.
  6. UI Integration: Convert OperationContext to messages for the Vercel AI SDK using @voltagent/vercel-ui.

Examples​

Message Transformation with onPrepareMessages​

Transform messages before they reach the LLM using onPrepareMessages and message helper functions:

import { Agent, createHooks, messageHelpers } from "@voltagent/core";

const enhancedHooks = createHooks({
onPrepareMessages: async ({ messages, context }) => {
const enhanced = messages.map((msg) => {
// Add timestamps to user messages
if (msg.role === "user") {
const timestamp = new Date().toLocaleTimeString();
msg = messageHelpers.addTimestampToMessage(msg, timestamp);
}

// Redact sensitive data
msg = messageHelpers.mapMessageContent(msg, (text) => {
text = text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[SSN-REDACTED]");
text = text.replace(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, "[CC-REDACTED]");
return text;
});

return msg;
});

// Add context based on user
if (context.context?.get && context.context.get("userId")) {
const systemContext = {
role: "system" as const,
content: `User ID: ${context.context.get("userId")}. Provide personalized responses.`,
};
enhanced.unshift(systemContext);
}

return { messages: enhanced };
},

onEnd: async ({ output, context }) => {
console.log(`Messages processed for operation ${context.operationId}`);
if (output?.totalUsage) {
console.log(`Tokens used: ${output.totalUsage.totalTokens}`);
}
},
});

const agent = new Agent({
name: "Privacy-Aware Assistant",
instructions: "A helpful assistant that protects user privacy",
model: "openai/gpt-4o-mini",
hooks: enhancedHooks,
});

// User message: "My SSN is 123-45-6789"
// LLM receives: "[10:30:45] My SSN is [SSN-REDACTED]"

Inspecting Output in onEnd​

The output parameter structure depends on the agent method called. Check for text or object fields:

For token accounting, VoltAgent exposes the AI SDK usage contract directly:

  • output.usage is the final step usage, matching AI SDK result.usage.
  • output.totalUsage is the aggregate usage across all steps, matching AI SDK result.totalUsage.
  • output.steps?.[index].usage is the per-step usage, matching AI SDK result.steps[].usage.

In a single-step run, usage and totalUsage are usually the same. In a multi-step tool run, use totalUsage for total cost or billing, usage for the final model step, and steps[].usage for step-level analysis.

const hooks = createHooks({
onEnd: async ({ output }) => {
if (!output) return; // operation failed or was aborted

const finalStepUsage = output.usage;
const aggregateUsage = output.totalUsage;
const perStepUsage = output.steps?.map((step) => step.usage);

console.log({
finalStepTokens: finalStepUsage?.totalTokens,
aggregateTokens: aggregateUsage?.totalTokens,
stepTokens: perStepUsage?.map((usage) => usage.totalTokens),
});

// Handle text results
if ("text" in output && output.text) {
console.log("Final text:", output.text);
return;
}

// Handle object results
if ("object" in output && output.object) {
console.log("Final object keys:", Object.keys(output.object));
}
},
});

Table of Contents