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

Usage

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

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.

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

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