Skip to content

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 commands
session.on('beforeToolExecute', (event) => {
if (event.toolName === 'bash' && event.arguments.command?.includes('rm -rf')) {
return { allow: false, reason: 'Destructive command blocked' };
}
return { allow: true };
});

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).


The hooks system exists only in @philschmid/agent — the core package (@philschmid/agents-core) provides a raw agent loop with no interception points.

@philschmid/agent

@philschmid/agents-core

agentLoop

HookRunner

AgentSession

Your Code

PackageProvidesHooks Support
@philschmid/agents-coreagentLoop, tool() factory❌ None
@philschmid/agentAgentSession, 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.


Yes

Allowed

Blocked

No

Yes

No

Inject input

Done

🟢 onAgentStart

🔵 onInteractionStart

Tool Call?

🟡 beforeToolExecute

Execute Tool

Return Blocked

🟠 afterToolExecute

⚪ onInteractionEnd

More turns?

🔴 onAgentEnd

Complete


Each hook receives an event object and returns a result. Here are the TypeScript types for all hooks:

Fires once before the first LLM call. Modify tools, system instruction, or initial input.

// Event
type OnAgentStartEvent = {
type: 'onAgentStart';
tools: AgentTool[];
systemInstruction?: string;
input: Turn['content'];
};
// Result
type OnAgentStartResult = {
tools?: AgentTool[];
systemInstruction?: string;
input?: Turn['content'];
};
session.on('onAgentStart', (event: OnAgentStartEvent): OnAgentStartResult => {
return {
systemInstruction: event.systemInstruction + `\nCurrent time: ${new Date()}`,
};
});

Fires before each LLM call. Filter or transform the interaction history.

// Event
type OnInteractionStartEvent = {
type: 'onInteractionStart';
interactions: Turn[];
};
// Result
type OnInteractionStartResult = {
interactions?: Turn[];
};
session.on('onInteractionStart', (event: OnInteractionStartEvent): OnInteractionStartResult => {
// Keep only last 10 interactions (sliding window)
return { interactions: event.interactions.slice(-10) };
});

Fires before each tool call. Block execution or modify arguments.

// Event
type 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 };
});

Fires after each tool call. Modify the result before sending to the model.

// Event
type AfterToolExecuteEvent = {
type: 'afterToolExecute';
toolName: string;
toolCallId: string;
result: AgentToolResult;
};
// Result
type 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 {};
});

Fires after each LLM call completes. Observe only — no modifications.

// Event
type OnInteractionEndEvent = {
type: 'onInteractionEnd';
turn: Turn;
};
// Result
type OnInteractionEndResult = undefined; // No return value
session.on('onInteractionEnd', (event: OnInteractionEndEvent): void => {
console.log(`Turn complete: ${event.turn.role}`);
});

Fires when agent loop completes. Inject follow-up input to continue.

// Event
type OnAgentEndEvent = {
type: 'onAgentEnd';
interactionCount: number;
filesModified?: number;
};
// Result
type 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
});

The HookRunner class manages hook registration and execution. Understanding its internals helps when building custom extensions.

class HookRunner {
private handlers = new Map<HookName, Handler[]>();
on(hookName, handler) {
// Handlers stored in registration order
this.handlers.get(hookName)?.push(handler);
}
}

When emit(hookName, event) is called, handlers run in registration order. Result merging varies by hook type:

HookMerging Strategy
beforeToolExecuteFirst block wins. Arguments merge until a handler returns { allow: false }
onAgentEndCombine inputs. All input values joined with \n\n
onInteractionEndObserve only. Returns undefined, handlers just observe
OthersLast write wins. Results shallow-merged, later handlers override
// beforeToolExecute: First block wins
session.on('beforeToolExecute', () => ({ allow: true }));
session.on('beforeToolExecute', () => ({ allow: false, reason: 'Blocked' }));
session.on('beforeToolExecute', () => ({ allow: true })); // Never runs
// Result: Blocked
// onAgentEnd: Inputs combined
session.on('onAgentEnd', () => ({ input: 'Verify changes' }));
session.on('onAgentEnd', () => ({ input: 'Check for errors' }));
// Result: input = 'Verify changes\n\nCheck for errors'
// onAgentStart: Last write wins
session.on('onAgentStart', () => ({ systemInstruction: 'First' }));
session.on('onAgentStart', () => ({ systemInstruction: 'Second' }));
// Result: systemInstruction = 'Second'

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 manually
const result = await hooks.emit('beforeToolExecute', {
type: 'beforeToolExecute',
toolName: 'bash',
toolCallId: 'call-123',
arguments: { command: 'ls -la' },
});
console.log(result.allow); // true

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 {};
}
}

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();
}

HookWhenCan ModifyReturns
onAgentStartOnce, before first LLM calltools, systemInstruction, inputModified values
onInteractionStartBefore each LLM callinteractionsFiltered interactions
beforeToolExecuteBefore each tool runsarguments{ allow, reason?, arguments? }
afterToolExecuteAfter each tool completesresult{ result? }
onInteractionEndAfter each LLM turn— (observe only)undefined
onAgentEndWhen agent loop completes{ input? } to continue

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 };
});
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 };
});
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 {};
});