Hooks
Four lifecycle seams — inject context, deny or rewrite tool calls, observe outcomes — plus server-side silence hooks, with identical semantics in every SDK.
Hooks are deterministic callbacks at fixed seams of the session lifecycle. Where instructions ask the model to behave, hooks guarantee behavior in code: a delete_* tool stays blocked no matter what the model decides.
Hooks expose four seams. Semantics are identical across TypeScript, Python, and Swift — a hook you design in one language behaves the same in the others.
| Seam | Fires | Can |
|---|---|---|
SessionStart | once, before the session opens | inject extra context into the instructions |
PreToolUse | before each client-tool handler | deny the call or rewrite its arguments |
PostToolUse | after each client-tool outcome | observe (log, meter, alert) |
SessionEnd | exactly once at teardown, any exit path | observe |
Register hooks
Hooks ride on the agent config as an ordered list — list order is fold order.
import { sessionStart, preToolUse, postToolUse, sessionEnd } from 'cosmo-ai';
const agent = client.agent({
instructions: '…',
hooks: [
sessionStart(() => ({ additionalContext: 'The user is on the premium plan.' })),
preToolUse(() => ({ permission: 'deny', reason: 'destructive tools are disabled' }),
{ matcher: 'delete_*' }),
postToolUse((ctx) => console.log(ctx.toolName, ctx.outcome)),
],
});from cosmo_ai import RealtimeClient
from cosmo_ai.hooks import (
PreToolUseResult,
SessionStartResult,
post_tool_use,
pre_tool_use,
session_start,
)
@session_start
def add_context(ctx) -> SessionStartResult:
return SessionStartResult(additional_context="The user is on the premium plan.")
@pre_tool_use(matcher="delete_*")
def block_deletes(ctx) -> PreToolUseResult:
return PreToolUseResult(permission="deny", reason="destructive tools are disabled")
@post_tool_use
def log_outcome(ctx) -> None:
print(ctx.tool_name, ctx.outcome)
agent = client.agent(instructions="…", hooks=[add_context, block_deletes, log_outcome])let agent = try client.agent(
instructions: "…",
hooks: [
sessionStart { _ in
SessionStartResult(additionalContext: "The user is on the premium plan.")
},
try preToolUse(matcher: "delete_*") { _ in
PreToolUseResult(permission: .deny, reason: "destructive tools are disabled")
},
try postToolUse { ctx in print(ctx.toolName, ctx.outcome) },
]
)Scope hooks with matchers
PreToolUse and PostToolUse accept an optional glob matcher on the tool name: * (any run), ? (one char), [seq], [!seq]. Matching is case-sensitive, against the full tool name, with no implicit substring — delete doesn't match delete_file; delete* does. No matcher means every tool. Malformed patterns (an unterminated [) are rejected at registration, not silently ignored: they throw HookError with code malformed_matcher. The underlying matcher never errors on one — it silently matches nothing, which for a deny matcher is a guard that never fires — so the SDK refuses it up front. HookError also covers a hooks element that is not a hook (invalid_hook) and a server hook passed to a catalog agent (server_hook_not_allowed), the latter raised where the agent's config is assembled — at catalog_agent(...) in Python, at start() in TypeScript and Swift.
Combine hooks on a seam
When several hooks share a seam, they fold in list order:
- SessionStart — every hook runs; the
additional_contextstrings are concatenated (blank-line separated) and appended to the instructions. All-none injects nothing. This applies to an inline agent: a catalog agent runs its stored config verbatim, so returned context is dropped with a warning. - PreToolUse — hooks run in order. The first deny wins and stops the fold (an empty reason becomes
"denied by hook"). Argument rewrites chain — each hook sees the previous hook's rewritten arguments. - PostToolUse / SessionEnd — all observers run in order.
A hook that throws is logged and skipped — later hooks still run and the session survives. For PreToolUse that means a guard that crashes fails open: the call proceeds unless another hook denies it. Keep deny logic simple enough not to throw.
The PostToolUse context carries the final outcome as a typed ToolOutcome — ok with the result, error with the message, or denied with the reason — so your audit log records denials the same way it records failures. Each SDK spells ToolOutcome natively: TypeScript discriminates on kind: 'ok' | 'error' | 'denied', Swift is an enum with those cases, and Python is a union of the ToolOk / ToolError / ToolDenied classes that you narrow with isinstance or a match.
These seams are where production guard patterns live: PII scrubbed from arguments before a tool forwards them, a prompt-injection classifier that denies instead of hoping, an audit trail with denials in it, a completeness check in front of hanging up. Add production guardrails works through each one.
Server hooks
Client hooks live in your process; if the process dies mid-call, they die with it. Server hooks are declarative config the backend runs on your behalf — today, the silence timeout:
from cosmo_ai.hooks import SilenceTimeout, Say, EndCall
agent = client.agent(
instructions="…",
hooks=[
SilenceTimeout(timeout_seconds=30,
action=Say(prompt="Gently check whether the caller is still there."),
max_count=2, reset_mode="on_user_speech"),
SilenceTimeout(timeout_seconds=90,
action=EndCall(farewell="I'll let you go — call back anytime.")),
],
)Say speaks exact text or generates from a prompt; EndCall says an optional farewell and hangs up. max_count (1–10) caps how many times the hook fires; reset_mode="on_user_speech" restarts the count whenever the user talks. Up to 16 server hooks per session.
timeout_seconds is what a caller who has not spoken yet waits. Once anyone has spoken, the window widens by present_multiplier (1–10), so a caller who is present but thinking is given longer than a line that was silent from the start. Leave it unset for the server's default, or set 1 for a hook that waits the same either way.
Each firing reaches the client as an ordinary user-speech-timeout session event on the event stream (UserSpeechTimeoutEvent in Python, user_speech_timeout in TypeScript, .userSpeechTimeout in Swift) — a fired server hook surfaces as an event, never as a client hook. In TypeScript and Swift, server hooks share the same hooks list as client hooks (SilenceTimeout objects in TS, Hook.server(...) in Swift); the SDK serializes only the server hooks onto the wire — client callbacks never leave your process.
What hooks aren't
Hooks guard client tools. Server tools run on the backend and are governed by which names you opt into, not by PreToolUse. And hooks aren't a place for conversation logic — steering what the agent says belongs in instructions and skills; hooks are for guarantees.