Hooks
Five lifecycle seams — inject context, deny or rewrite tool calls, observe outcomes — 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.
There are four seams. Semantics are identical across TypeScript, Python, and Swift — pinned by shared conformance vectors that all three SDKs run in CI, so 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 |
Registering hooks
Hooks ride on the agent config as an ordered list — list order is fold order.
Python — decorators from cosmo_ai.hooks:
from cosmo_ai import CosmoRealtime
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, type(ctx.outcome).__name__)
agent = client.agent(instructions="…", hooks=[add_context, block_deletes, log_outcome])TypeScript — seam factories:
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)),
],
});Swift — factory functions on SessionConfig.hooks:
var config = SessionConfig()
config.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) },
]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 does not match delete_file; delete* does. No matcher means every tool. Malformed patterns (an unterminated [) are rejected at registration, not silently ignored.
Fold rules
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. - 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. The exception is a throwing SessionStart in TypeScript, which aborts the not-yet-opened session with handshake_failed.
The PostToolUse context carries the final outcome as a typed value: ToolOk(result), ToolError(message), or ToolDenied(reason) — so your audit log records denials the same way it records failures.
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 executes 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.
Each firing reaches the client as an ordinary user-speech-timeout session event on the event stream (RealtimeUserSpeechTimeout 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 are not
Hooks guard client tools. Server tools execute on the backend and are governed by which names you opt into, not by PreToolUse. And hooks are not a place for conversation logic — steering what the agent says belongs in instructions and skills; hooks are for guarantees.