Hooks
Lifecycle callbacks for observability and permission control
Hooks let you run custom code at key points in the agent lifecycle. Use them for logging, metrics, auditing, or permission control.
Available Hooks
| Hook | When it fires |
|---|---|
beforeRun | Before the first model call |
afterRun | After the final result is produced |
beforeToolCall | Before a tool's execute function runs. Supports matcher arrays |
afterToolCall | After a tool's execute function returns. Supports matcher arrays |
beforeHandoff | Before switching to a handoff agent |
onStop | Before MaxTurnsExceededError or MaxBudgetExceededError is thrown |
onSubagentStart | Before a subagent begins execution |
onSubagentStop | After a subagent finishes execution |
onSessionStart | On the session's first stream() call |
onSessionEnd | After each session stream() completes |
Usage
import { Agent } from "stratus-sdk/core";
const agent = new Agent({
name: "assistant",
model,
hooks: {
beforeRun: async ({ agent, input, context }) => {
console.log(`Starting ${agent.name} with: ${input}`);
},
afterRun: async ({ agent, result, context }) => {
console.log(`${agent.name} finished: ${result.output}`);
},
beforeToolCall: async ({ agent, toolCall, context }) => {
console.log(`Calling tool: ${toolCall.function.name}`);
},
afterToolCall: async ({ agent, toolCall, result, context }) => {
console.log(`Tool ${toolCall.function.name} returned: ${result}`);
},
beforeHandoff: async ({ fromAgent, toAgent, context }) => {
console.log(`Handoff: ${fromAgent.name} → ${toAgent.name}`);
},
},
});Permission Control
beforeToolCall and beforeHandoff can return a decision object to allow, deny, or modify the action.
Returning void (or not returning anything) is treated as "allow", so existing hooks are fully backward compatible.
Tool Call Decisions
beforeToolCall can return a ToolCallDecision:
type ToolCallDecision =
| { decision: "allow" }
| { decision: "deny"; reason?: string }
| { decision: "modify"; modifiedParams: Record<string, unknown> };When denied, the tool's execute function is skipped. The reason is returned to the model as the tool message, and afterToolCall does not fire.
hooks: {
beforeToolCall: ({ toolCall, context }) => {
if (toolCall.function.name === "delete_user" && !context.isAdmin) {
return { decision: "deny", reason: "Admin access required" };
}
},
}If no reason is provided, a default message like Tool call "delete_user" was denied is used.
When modified, the modifiedParams are passed to the tool instead of the original parsed arguments. afterToolCall still fires.
hooks: {
beforeToolCall: ({ toolCall }) => {
if (toolCall.function.name === "search") {
return {
decision: "modify",
modifiedParams: { query: "safe version of the query" },
};
}
},
}Explicitly allow (same as returning void):
hooks: {
beforeToolCall: () => {
return { decision: "allow" };
},
}Handoff Decisions
beforeHandoff can return a HandoffDecision:
type HandoffDecision =
| { decision: "allow" }
| { decision: "deny"; reason?: string };When denied, the agent switch is blocked - result.lastAgent remains the current agent. The denial reason is returned as the tool message.
hooks: {
beforeHandoff: ({ toAgent, context }) => {
if (toAgent.name === "admin_agent" && !context.isAdmin) {
return { decision: "deny", reason: "Admin agent access denied" };
}
},
}Hook Matchers
Instead of filtering by tool name inside your hook function, you can use matcher arrays on beforeToolCall and afterToolCall. Each entry specifies which tools it applies to using strings or regex patterns.
const agent = new Agent({
name: "assistant",
model,
tools: [readFile, writeFile, deleteFile, getWeather],
hooks: {
beforeToolCall: [
{
match: /.*_file$/, // Regex: matches read_file, write_file, delete_file
hook: ({ toolCall, context }) => {
console.log(`File operation: ${toolCall.function.name}`);
},
},
{
match: "delete_file", // String: exact match
hook: ({ context }) => {
if (!context.isAdmin) {
return { decision: "deny", reason: "Admin access required" };
}
},
},
],
afterToolCall: [
{
match: ["read_file", "write_file"], // Array: matches any
hook: ({ toolCall, result }) => {
console.log(`${toolCall.function.name} returned ${result.length} chars`);
},
},
],
},
});Matcher types
| Form | Example | Matches |
|---|---|---|
string | "delete_file" | Exact tool name match |
RegExp | /^dangerous_/ | Tools whose name matches the pattern |
Array | ["read_file", /^write_/] | Tools matching any entry in the array |
Execution semantics
- Matchers are checked in array order
- For
beforeToolCall, the first"deny"or"modify"decision short-circuits — later matchers are skipped - For
afterToolCall, all matching entries run (no short-circuit) - The function form (single callback) still works for backward compatibility
Lifecycle Hooks
Beyond the core hooks, Stratus provides lifecycle hooks for stops, subagent execution, and session boundaries.
onStop
Fires before MaxTurnsExceededError or MaxBudgetExceededError is thrown. Use it for cleanup or logging.
hooks: {
onStop: async ({ agent, context, reason }) => {
// reason: "max_turns" | "max_budget"
await logToAnalytics("agent_stopped", {
agent: agent.name,
reason,
});
},
}onSubagentStart / onSubagentStop
Fire before and after a subagent executes as a tool call.
hooks: {
onSubagentStart: async ({ agent, subagent, context }) => {
console.log(`${agent.name} is delegating to ${subagent.agent.name}`);
},
onSubagentStop: async ({ agent, subagent, result, context }) => {
console.log(`${subagent.agent.name} returned: ${result.slice(0, 100)}`);
},
}onSessionStart / onSessionEnd
Fire on the session's first stream() call and after each stream() completes (in the finally block). Set these on the session's hooks config.
const session = createSession({
model,
hooks: {
onSessionStart: async ({ context }) => {
console.log("Session started");
},
onSessionEnd: async ({ context }) => {
console.log("Stream ended");
},
},
});onSessionStart fires once — on the first stream() call. onSessionEnd fires after every stream() call, including when errors occur.
Hook Signatures
interface AgentHooks<TContext> {
beforeRun?: (params: {
agent: Agent<TContext, any>;
input: string;
context: TContext;
}) => void | Promise<void>;
afterRun?: (params: {
agent: Agent<TContext, any>;
result: RunResult<any>;
context: TContext;
}) => void | Promise<void>;
beforeToolCall?: BeforeToolCallHook<TContext>;
// Function form: (params) => void | ToolCallDecision
// Array form: MatchedToolCallHook<TContext>[]
afterToolCall?: AfterToolCallHook<TContext>;
// Function form: (params) => void
// Array form: MatchedAfterToolCallHook<TContext>[]
beforeHandoff?: (params: {
fromAgent: Agent<TContext, any>;
toAgent: Agent<TContext, any>;
context: TContext;
}) => void | HandoffDecision | Promise<void | HandoffDecision>;
onStop?: (params: {
agent: Agent<TContext, any>;
context: TContext;
reason: "max_turns" | "max_budget";
}) => void | Promise<void>;
onSubagentStart?: (params: {
agent: Agent<TContext, any>;
subagent: SubAgent;
context: TContext;
}) => void | Promise<void>;
onSubagentStop?: (params: {
agent: Agent<TContext, any>;
subagent: SubAgent;
result: string;
context: TContext;
}) => void | Promise<void>;
onSessionStart?: (params: {
context: TContext;
}) => void | Promise<void>;
onSessionEnd?: (params: {
context: TContext;
}) => void | Promise<void>;
}Hooks in Sessions
const session = createSession({
model,
hooks: {
beforeRun: async ({ input }) => {
await logToAnalytics("user_message", input);
},
afterRun: async ({ result }) => {
await logToAnalytics("agent_response", result.output);
},
},
});Execution Details
Last updated on