Hooks
Hooks let you intercept agent lifecycle events — block tool calls, modify context, log actions, or inject follow-up inputs.
import { createAgentSession } from '@philschmid/agent';
const session = createAgentSession({ model: 'gemini-3-flash-preview', tools: ['read', 'write', 'bash'],});
// Block destructive commandssession.on('beforeToolExecute', (event) => { if (event.toolName === 'bash' && event.arguments.command?.includes('rm -rf')) { return { allow: false, reason: 'Destructive command blocked' }; } return { allow: true };});What Are Hooks?
Section titled “What Are Hooks?”Hooks are callbacks that fire at specific points in the agent lifecycle. They let you:
- Observe: Log tool calls, track usage, collect metrics
- Modify: Change system instructions, filter context, transform results
- Control: Block dangerous operations, require approval, rate limit
Hooks run synchronously in registration order. Each hook type has specific rules for how multiple handlers interact (see The emit() Method).
Architecture
Section titled “Architecture”The hooks system exists only in @philschmid/agent — the core package (@philschmid/agents-core) provides a raw agent loop with no interception points.
| Package | Provides | Hooks Support |
|---|---|---|
@philschmid/agents-core | agentLoop, tool() factory | ❌ None |
@philschmid/agent | AgentSession, HookRunner, built-in tools | ✅ Full hooks system |
Why this separation? The core package stays minimal and unopinionated. If you need custom lifecycle handling, you can build your own wrapper around agentLoop. The agent package provides one implementation via HookRunner.
Hook Lifecycle
Section titled “Hook Lifecycle”Hook Types
Section titled “Hook Types”Each hook receives an event object and returns a result. Here are the TypeScript types for all hooks:
onAgentStart
Section titled “onAgentStart”Fires once before the first LLM call. Modify tools, system instruction, or initial input.
// Eventtype OnAgentStartEvent = { type: 'onAgentStart'; tools: AgentTool[]; systemInstruction?: string; input: Turn['content'];};
// Resulttype OnAgentStartResult = { tools?: AgentTool[]; systemInstruction?: string; input?: Turn['content'];};session.on('onAgentStart', (event: OnAgentStartEvent): OnAgentStartResult => { return { systemInstruction: event.systemInstruction + `\nCurrent time: ${new Date()}`, };});onInteractionStart
Section titled “onInteractionStart”Fires before each LLM call. Filter or transform the interaction history.
// Eventtype OnInteractionStartEvent = { type: 'onInteractionStart'; interactions: Turn[];};
// Resulttype OnInteractionStartResult = { interactions?: Turn[];};session.on('onInteractionStart', (event: OnInteractionStartEvent): OnInteractionStartResult => { // Keep only last 10 interactions (sliding window) return { interactions: event.interactions.slice(-10) };});beforeToolExecute
Section titled “beforeToolExecute”Fires before each tool call. Block execution or modify arguments.
// Eventtype BeforeToolExecuteEvent = { type: 'beforeToolExecute'; toolName: string; toolCallId: string; arguments: Record<string, unknown>;};
// Result (required)type BeforeToolExecuteResult = { allow: boolean; reason?: string; // Why blocked (if allow: false) arguments?: Record<string, unknown>; // Modified args};session.on('beforeToolExecute', (event: BeforeToolExecuteEvent): BeforeToolExecuteResult => { if (event.toolName === 'bash') { const cmd = event.arguments.command as string; if (cmd.includes('rm -rf')) { return { allow: false, reason: 'Destructive command blocked' }; } } return { allow: true };});afterToolExecute
Section titled “afterToolExecute”Fires after each tool call. Modify the result before sending to the model.
// Eventtype AfterToolExecuteEvent = { type: 'afterToolExecute'; toolName: string; toolCallId: string; result: AgentToolResult;};
// Resulttype AfterToolExecuteResult = { result?: AgentToolResult;};session.on('afterToolExecute', (event: AfterToolExecuteEvent): AfterToolExecuteResult => { if (event.result.result.length > 10000) { return { result: { result: event.result.result.slice(0, 10000) + '\n...(truncated)' }, }; } return {};});onInteractionEnd
Section titled “onInteractionEnd”Fires after each LLM call completes. Observe only — no modifications.
// Eventtype OnInteractionEndEvent = { type: 'onInteractionEnd'; turn: Turn;};
// Resulttype OnInteractionEndResult = undefined; // No return valuesession.on('onInteractionEnd', (event: OnInteractionEndEvent): void => { console.log(`Turn complete: ${event.turn.role}`);});onAgentEnd
Section titled “onAgentEnd”Fires when agent loop completes. Inject follow-up input to continue.
// Eventtype OnAgentEndEvent = { type: 'onAgentEnd'; interactionCount: number; filesModified?: number;};
// Resulttype OnAgentEndResult = { input?: string; // Inject to trigger another loop};session.on('onAgentEnd', (event: OnAgentEndEvent): OnAgentEndResult => { if (event.interactionCount < 3) { return { input: 'Please continue with the next step.' }; } return {}; // Complete normally});How HookRunner Works
Section titled “How HookRunner Works”The HookRunner class manages hook registration and execution. Understanding its internals helps when building custom extensions.
Handler Storage
Section titled “Handler Storage”class HookRunner { private handlers = new Map<HookName, Handler[]>();
on(hookName, handler) { // Handlers stored in registration order this.handlers.get(hookName)?.push(handler); }}The emit() Method
Section titled “The emit() Method”When emit(hookName, event) is called, handlers run in registration order. Result merging varies by hook type:
| Hook | Merging Strategy |
|---|---|
beforeToolExecute | First block wins. Arguments merge until a handler returns { allow: false } |
onAgentEnd | Combine inputs. All input values joined with \n\n |
onInteractionEnd | Observe only. Returns undefined, handlers just observe |
| Others | Last write wins. Results shallow-merged, later handlers override |
// beforeToolExecute: First block winssession.on('beforeToolExecute', () => ({ allow: true }));session.on('beforeToolExecute', () => ({ allow: false, reason: 'Blocked' }));session.on('beforeToolExecute', () => ({ allow: true })); // Never runs// Result: Blocked
// onAgentEnd: Inputs combinedsession.on('onAgentEnd', () => ({ input: 'Verify changes' }));session.on('onAgentEnd', () => ({ input: 'Check for errors' }));// Result: input = 'Verify changes\n\nCheck for errors'
// onAgentStart: Last write winssession.on('onAgentStart', () => ({ systemInstruction: 'First' }));session.on('onAgentStart', () => ({ systemInstruction: 'Second' }));// Result: systemInstruction = 'Second'Building Your Own Hooks
Section titled “Building Your Own Hooks”Using HookRunner Directly
Section titled “Using HookRunner Directly”You can use HookRunner independently of AgentSession:
import { HookRunner } from '@philschmid/agent';
const hooks = new HookRunner();
hooks.on('beforeToolExecute', (event) => { console.log(`Tool: ${event.toolName}`); return { allow: true };});
// Emit manuallyconst result = await hooks.emit('beforeToolExecute', { type: 'beforeToolExecute', toolName: 'bash', toolCallId: 'call-123', arguments: { command: 'ls -la' },});
console.log(result.allow); // trueExtending AgentSession
Section titled “Extending AgentSession”For custom lifecycle behavior, extend AgentSession:
import { AgentSession, type AgentSessionOptions } from '@philschmid/agent';
class MyAgentSession extends AgentSession { constructor(options: AgentSessionOptions) { super(options);
// Register default hooks this.on('beforeToolExecute', this.auditLog.bind(this)); this.on('onAgentEnd', this.summarize.bind(this)); }
private auditLog(event) { console.log(`[AUDIT] ${event.toolName}(${JSON.stringify(event.arguments)})`); return { allow: true }; }
private summarize(event) { if (event.interactionCount > 5) { return { input: 'Provide a summary of what you accomplished.' }; } return {}; }}Building on agents-core Directly
Section titled “Building on agents-core Directly”For full control, wrap agentLoop yourself:
import { agentLoop } from '@philschmid/agents-core';
async function myCustomLoop(input, options) { // Pre-processing hook point const modifiedInput = await myBeforeHook(input);
const stream = agentLoop(modifiedInput, options);
for await (const event of stream) { // Event interception hook point if (event.type === 'tool.start') { const allowed = await myToolApproval(event); if (!allowed) continue; } yield event; }
// Post-processing hook point await myAfterHook();}Hook Reference
Section titled “Hook Reference”| Hook | When | Can Modify | Returns |
|---|---|---|---|
onAgentStart | Once, before first LLM call | tools, systemInstruction, input | Modified values |
onInteractionStart | Before each LLM call | interactions | Filtered interactions |
beforeToolExecute | Before each tool runs | arguments | { allow, reason?, arguments? } |
afterToolExecute | After each tool completes | result | { result? } |
onInteractionEnd | After each LLM turn | — (observe only) | undefined |
onAgentEnd | When agent loop completes | — | { input? } to continue |
Practical Examples
Section titled “Practical Examples”Command Approval
Section titled “Command Approval”const ALLOWED_COMMANDS = ['ls', 'pwd', 'cat', 'head', 'tail'];
session.on('beforeToolExecute', (event) => { if (event.toolName !== 'bash') return { allow: true };
const cmd = event.arguments.command as string; const firstWord = cmd.split(' ')[0];
if (!ALLOWED_COMMANDS.includes(firstWord)) { return { allow: false, reason: `Command '${firstWord}' not in allowlist` }; } return { allow: true };});Rate Limiting
Section titled “Rate Limiting”const callCounts = new Map<string, number>();
session.on('beforeToolExecute', (event) => { const count = callCounts.get(event.toolName) || 0;
if (count >= 10) { return { allow: false, reason: `Tool ${event.toolName} rate limited` }; }
callCounts.set(event.toolName, count + 1); return { allow: true };});Logging
Section titled “Logging”session.on('beforeToolExecute', (event) => { console.log(`[TOOL] ${event.toolName}(${JSON.stringify(event.arguments)})`); return { allow: true };});
session.on('afterToolExecute', (event) => { const preview = event.result.result.slice(0, 100); console.log(`[RESULT] ${preview}${event.result.result.length > 100 ? '...' : ''}`); return {};});Next Steps
Section titled “Next Steps”- Tools: How tools work
- Configuration: Configure default tools and settings
- Streaming: Handle events in your UI