stratus

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

HookWhen it fires
beforeRunBefore the first model call
afterRunAfter the final result is produced
beforeToolCallBefore a tool's execute function runs. Supports matcher arrays
afterToolCallAfter a tool's execute function returns. Supports matcher arrays
beforeHandoffBefore switching to a handoff agent
onStopBefore MaxTurnsExceededError or MaxBudgetExceededError is thrown
onSubagentStartBefore a subagent begins execution
onSubagentStopAfter a subagent finishes execution
onSessionStartOn the session's first stream() call
onSessionEndAfter each session stream() completes
onLlmStartBefore each LLM API call
onLlmEndAfter each LLM API call

Usage

hooks.ts
import { Agent } from "@usestratus/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.

deny-tool.ts
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.

modify-tool.ts
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.

deny-handoff.ts
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.

matchers.ts
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

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

on-stop.ts
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.

subagent-hooks.ts
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.

session-lifecycle.ts
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.

onLlmStart / onLlmEnd

Fire before and after every LLM API call. Useful for logging, latency tracking, or request auditing.

llm-hooks.ts
hooks: {
  onLlmStart: async ({ agent, messages, context }) => { 
    console.log(`LLM call for ${agent.name} with ${messages.length} messages`);
  },
  onLlmEnd: async ({ agent, response, context }) => { 
    console.log(`LLM responded: ${response.toolCallCount} tool calls`);
  },
}

Run Hooks

Run hooks fire across all agents in a run, including after handoffs. Unlike agent hooks (which are scoped to a single agent), run hooks observe the entire execution.

Set them via runHooks in run() / stream() options or in SessionConfig:

run-hooks.ts
import { run } from "@usestratus/sdk/core";
import type { RunHooks } from "@usestratus/sdk/core";

const hooks: RunHooks = {
  onAgentStart: async ({ agent }) => {
    console.log(`Agent started: ${agent.name}`);
  },
  onAgentEnd: async ({ agent, output }) => {
    console.log(`Agent ended: ${agent.name}`);
  },
  onHandoff: async ({ fromAgent, toAgent }) => {
    console.log(`Handoff: ${fromAgent.name} → ${toAgent.name}`);
  },
  onToolStart: async ({ agent, toolName }) => {
    console.log(`Tool started: ${toolName}`);
  },
  onToolEnd: async ({ agent, toolName, result }) => {
    console.log(`Tool ended: ${toolName}`);
  },
  onLlmStart: async ({ agent, request }) => {
    console.log(`LLM call with ${request.messages.length} messages`);
  },
  onLlmEnd: async ({ agent, response }) => {
    console.log(`LLM responded: ${response.toolCallCount} tool calls`);
  },
};

await run(agent, "Hello", { runHooks: hooks }); 

RunHooks reference

HookWhen it fires
onAgentStartWhen an agent starts processing (including after handoffs)
onAgentEndWhen an agent finishes (before handoff or at end)
onHandoffOn every handoff between agents
onToolStartBefore every tool execution
onToolEndAfter every tool execution
onLlmStartBefore every LLM API call
onLlmEndAfter every LLM API call

Run hooks are complementary to agent hooks. Agent hooks fire on their specific agent and can control execution (deny/modify). Run hooks are observational and fire across all agents.

Hook Signatures

types.ts
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>;

  onLlmStart?: (params: {
    agent: Agent<TContext, any>;
    messages: ChatMessage[];
    context: TContext;
  }) => void | Promise<void>;

  onLlmEnd?: (params: {
    agent: Agent<TContext, any>;
    response: { content: string | null; toolCallCount: number };
    context: TContext;
  }) => void | Promise<void>;
}

Hooks in Sessions

session-hooks.ts
const session = createSession({
  model,
  hooks: {
    beforeRun: async ({ input }) => {
      await logToAnalytics("user_message", input);
    },
    afterRun: async ({ result }) => {
      await logToAnalytics("agent_response", result.output);
    },
  },
});

Execution Details

Edit on GitHub

Last updated on

On this page