16 KiB
Hook System Reference
Overview
The hook system provides extensibility points at both the Agent and AgentHarness layers. Hooks are asynchronous, can be cancelled via abort signal, and run in subscription order.
Hook Categories
1. Message Transformation Hooks
convertToLlm
Location: AgentLoopConfig.convertToLlm
Purpose: Convert AgentMessage[] to Message[] before LLM call.
When called: Just before each LLM request.
Key contract:
- Must not throw or reject
- Must handle all
AgentMessagevariants - Filter out UI-only messages (notifications, artifacts, etc.)
Example:
convertToLlm: (messages) => messages.filter(m =>
m.role === "user" ||
m.role === "assistant" ||
m.role === "toolResult"
)
transformContext
Location: AgentLoopConfig.transformContext (optional)
Purpose: Manipulate context before LLM conversion.
When called: Before convertToLlm.
Use cases:
- Context window management (pruning old messages)
- Injecting external context
- Message deduplication
Example:
transformContext: async (messages, signal) => {
if (estimateTokens(messages) > MAX_TOKENS) {
return pruneOldMessages(messages);
}
return messages;
}
2. Lifecycle Hooks
beforeToolCall
Location: AgentLoopConfig.beforeToolCall (optional)
Context:
interface BeforeToolCallContext {
assistantMessage: AssistantMessage;
toolCall: AgentToolCall;
args: unknown; // Validated against tool schema
context: AgentContext;
}
Return:
interface BeforeToolCallResult {
block?: boolean; // If true, tool won't execute
reason?: string; // Error message shown in tool result
}
When called: After args validation, before tool execution.
Use cases:
- Permission checks (user approval)
- Rate limiting
- Context-aware tool blocking
Example:
beforeToolCall: async ({ toolCall, args, context }, signal) => {
if (toolCall.name === "bash" && signal?.aborted) {
return { block: true, reason: "Operation aborted" };
}
return undefined; // Allow execution
}
afterToolCall
Location: AgentLoopConfig.afterToolCall (optional)
Context:
interface AfterToolCallContext {
assistantMessage: AssistantMessage;
toolCall: AgentToolCall;
args: unknown;
result: AgentToolResult<any>;
isError: boolean;
context: AgentContext;
}
Return:
interface AfterToolCallResult {
content?: (TextContent | ImageContent)[];
details?: unknown;
isError?: boolean;
usage?: Usage;
terminate?: boolean; // Early termination hint
}
When called: After tool execution, before emitting tool_execution_end.
Use cases:
- Modify tool results (redact sensitive data)
- Update usage tracking
- Trigger early termination
Example:
afterToolCall: async ({ result }, signal) => {
// Redact sensitive content
const content = result.content.map(c => {
if (c.type === "text") {
return { ...c, text: redactSecrets(c.text) };
}
return c;
});
return { content };
}
shouldStopAfterTurn
Location: AgentLoopConfig.shouldStopAfterTurn (optional)
Context:
interface ShouldStopAfterTurnContext {
message: AssistantMessage;
toolResults: ToolResultMessage[];
context: AgentContext;
newMessages: AgentMessage[];
}
Return: boolean
When called: After turn_end, before draining steering/follow-up queues.
Use cases:
- Stop when goal achieved
- Stop before context gets too large
- Error recovery
Example:
shouldStopAfterTurn: async ({ message, toolResults, context }) => {
// Stop if model indicates task complete
if (message.content.some(c =>
c.type === "text" && c.text.includes("TASK_COMPLETE"))) {
return true;
}
// Stop if context too large
if (estimateTokens(context.messages) > MAX_TOKENS * 0.8) {
return true;
}
return false;
}
prepareNextTurn
Location: AgentLoopConfig.prepareNextTurn (optional)
Context: Same as ShouldStopAfterTurnContext
Return:
interface AgentLoopTurnUpdate {
context?: AgentContext;
model?: Model<any>;
thinkingLevel?: ThinkingLevel;
}
When called: After shouldStopAfterTurn, if not stopping.
Use cases:
- Update model based on conversation context
- Switch thinking level
- Inject new context
Example:
prepareNextTurn: async ({ message, toolResults, context }) => {
// Switch to higher reasoning for complex tasks
if (toolResults.length > 3) {
return {
thinkingLevel: "high"
};
}
return undefined; // Keep current config
}
3. Queue Draining Hooks
getSteeringMessages
Location: AgentLoopConfig.getSteeringMessages (optional)
Return: Promise<AgentMessage[]>
When called: After turn ends, before next LLM call.
Purpose: Inject messages to interrupt agent mid-workflow.
Mode: "all" or "one-at-a-time" (controls how many messages injected)
Example:
getSteeringMessages: async () => {
// Check for user input while agent is working
if (userQueue.length > 0) {
return userQueue.splice(0, 1); // one-at-a-time mode
}
return [];
}
getFollowUpMessages
Location: AgentLoopConfig.getFollowUpMessages (optional)
Return: Promise<AgentMessage[]>
When called: When agent would stop (no more tool calls, no steering messages).
Purpose: Queue messages for after agent finishes.
Mode: "all" or "one-at-a-time"
Example:
getFollowUpMessages: async () => {
// Check if user typed while agent was working
if (followUpQueue.length > 0) {
return followUpQueue.splice(0, 1);
}
return [];
}
AgentHarness Hooks
1. System Prompt Hooks
before_agent_start
Location: AgentHarness.on("before_agent_start")
Event:
{
type: "before_agent_start";
prompt: string;
images?: ImageContent[];
systemPrompt: string;
resources: AgentHarnessResources;
}
Return:
{
messages?: AgentMessage[];
systemPrompt?: string;
}
When called: Before agent starts, after system prompt generated.
Use cases:
- Add conversation hints
- Inject images
- Modify system prompt
2. Context Hooks
context
Location: AgentHarness.on("context")
Event:
{
type: "context";
messages: AgentMessage[];
}
Return:
{
messages: AgentMessage[];
}
When called: Before convertToLlm.
Use cases:
- Message filtering
- Context window management
- Message augmentation
3. Tool Hooks
tool_call
Location: AgentHarness.on("tool_call")
Event:
{
type: "tool_call";
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
}
Return:
{
block?: boolean;
reason?: string;
}
When called: Before tool execution.
Use cases:
- Audit logging
- Approval workflows
- Input validation
tool_result
Location: AgentHarness.on("tool_result")
Event:
{
type: "tool_result";
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
content: (TextContent | ImageContent)[];
details: unknown;
isError: boolean;
usage?: Usage;
}
Return:
{
content?: (TextContent | ImageContent)[];
details?: unknown;
isError?: boolean;
usage?: Usage;
terminate?: boolean;
}
When called: After tool execution.
Use cases:
- Result transformation
- Usage tracking
- Early termination
4. Session Hooks
session_before_compact
Location: AgentHarness.on("session_before_compact")
Event:
{
type: "session_before_compact";
preparation: BranchPreparation;
branchEntries: SessionTreeEntry[];
customInstructions?: string;
signal: AbortSignal;
}
Return:
{
cancel?: boolean;
compaction?: CompactionResult;
}
When called: Before compaction.
Use cases:
- Skip compaction in certain conditions
- Provide custom summary
- Abort compaction
session_before_tree
Location: AgentHarness.on("session_before_tree")
Event:
{
type: "session_before_tree";
preparation: {
targetId: string;
oldLeafId: string;
commonAncestorId: string;
entriesToSummarize: SessionTreeEntry[];
userWantsSummary: boolean;
customInstructions?: string;
replaceInstructions?: boolean;
label?: string;
};
signal: AbortSignal;
}
Return:
{
cancel?: boolean;
summary?: {
summary: string;
details?: unknown;
usage?: Usage;
};
customInstructions?: string;
replaceInstructions?: boolean;
}
When called: Before tree navigation (branching).
Use cases:
- Skip branch summary
- Provide custom summary
- Cancel navigation
5. Provider Hooks
before_provider_request
Location: AgentHarness.on("before_provider_request")
Event:
{
type: "before_provider_request";
model: Model<any>;
sessionId: string;
streamOptions: AgentHarnessStreamOptions;
}
Return:
{
streamOptions: AgentHarnessStreamOptionsPatch;
}
When called: Just before each LLM request.
Use cases:
- Add authentication headers
- Set request metadata
- Configure caching
before_provider_payload
Location: AgentHarness.on("before_provider_payload")
Event:
{
type: "before_provider_payload";
model: Model<any>;
payload: unknown;
}
Return:
{
payload: unknown;
}
When called: Just before sending payload to LLM.
Use cases:
- Payload transformation
- Debug logging
- Schema validation
Hook Execution Order
Full Turn Flow
1. AgentHarness.prompt()
│
├─► emit "before_agent_start"
│ └─► Hook can return new messages/systemPrompt
│
▼
2. AgentLoopConfig creation
│
├─► transformContext hook → AgentLoop.transformContext
├─► convertToLlm hook → AgentLoop.convertToLlm
├─► beforeToolCall hook → AgentLoop.beforeToolCall
├─► afterToolCall hook → AgentLoop.afterToolCall
├─► prepareNextTurn hook → AgentLoop.prepareNextTurn
├─► shouldStopAfterTurn hook → AgentLoop.shouldStopAfterTurn
├─► getSteeringMessages hook → AgentLoop.getSteeringMessages
└─► getFollowUpMessages hook → AgentLoop.getFollowUpMessages
│
▼
3. streamAssistantResponse()
│
├─► emit "before_provider_request" (harness)
│ └─► Hook can modify stream options
├─► transformContext() (agent)
├─► convertToLlm() (agent)
├─► streamFn() → LLM call
└─► Emit message_start/update/end events
│
▼
4. executeToolCalls()
│
├─► For each tool call:
│ ├─► emit "tool_call" (harness)
│ │ └─► Hook can block execution
│ ├─► tool.execute()
│ └─► emit "tool_result" (harness)
│ └─► Hook can override result
│
▼
5. turn_end
│
├─► emit "turn_end" (agent)
├─► shouldStopAfterTurn() (agent)
│ └─► Return true to exit
├─► prepareNextTurn() (agent)
│ └─► Hook can update context/model/thinkingLevel
├─► Drain steering queue
└─► Drain follow-up queue
│
├─► If steering/follow-up: repeat from #3
└─► If no more: agent_end
│
└─► emit "agent_end" (agent)
Queue Mode Behavior
"all" Mode
All queued messages are injected at once:
Agent would continue...
→ getFollowUpMessages returns [msg1, msg2, msg3]
→ All three injected together
→ Agent processes all before next turn
"one-at-a-time" Mode
One message injected at a time:
Agent would continue...
→ getFollowUpMessages returns [msg1]
→ msg1 injected
→ Agent processes msg1
→ After turn, getFollowUpMessages returns [msg2]
→ msg2 injected
→ Agent processes msg2
→ ...and so on
Abort Signal Propagation
All hooks receive an optional AbortSignal:
interface BeforeToolCallContext {
// ... other fields
// signal is NOT included - use agent.signal instead
}
Agent hooks:
transformContext: receivessignalbeforeToolCall: receivessignalafterToolCall: receivessignal
Harness hooks:
before_agent_start: receivessignalcontext: NO signaltool_call: NO signaltool_result: NO signalsession_before_compact: receivessignalsession_before_tree: receivessignalbefore_provider_request: receivessignalbefore_provider_payload: NO signal
Error Handling
Hook Errors
Agent layer: Hook errors are caught and encoded in tool results:
try {
const beforeResult = await config.beforeToolCall(...);
if (beforeResult?.block) {
return immediateError(beforeResult.reason);
}
} catch (error) {
return immediateError(error.message);
}
Harness layer: Hook errors are wrapped and re-thrown:
try {
const result = await handler(event);
} catch (error) {
throw normalizeHookError(error);
}
Best Practices
- Always handle errors: Wrap async operations in try/catch
- Respect abort signals: Check
signal.abortedin long operations - Return safe defaults: Return empty arrays/objects on errors
- Don't block: Hooks should be fast (no network calls)
- Idempotent: Hooks should be safe to run multiple times
Common Patterns
1. Context Window Management
transformContext: async (messages, signal) => {
if (signal?.aborted) return messages;
const tokenCount = estimateTokens(messages);
if (tokenCount > MAX_TOKENS * 0.9) {
return pruneOldestMessages(messages, Math.floor(MAX_TOKENS * 0.3));
}
return messages;
}
2. Permission-Gated Tools
beforeToolCall: async ({ toolCall, args }, signal) => {
if (toolCall.name === "bash" && signal?.aborted) {
return { block: true, reason: "Operation aborted" };
}
if (toolCall.name === "bash" && !await canExecuteBash(args)) {
return { block: true, reason: "Permission denied" };
}
return undefined;
}
3. Result Redaction
afterToolCall: async ({ result }) => {
const content = result.content.map(c => {
if (c.type === "text") {
return { ...c, text: redactSecrets(c.text) };
}
return c;
});
return { content };
}
4. Early Termination
shouldStopAfterTurn: async ({ message }) => {
// Check if model indicates completion
if (message.content.some(c =>
c.type === "text" && c.text.includes("TASK_COMPLETE"))) {
return true;
}
// Check if all tool calls set terminate
return false;
}
5. Audit Logging
tool_call: async ({ toolCallId, toolName, input }) => {
console.log(`[TOOL_CALL] ${toolName} (${toolCallId}):`, input);
return undefined;
}
tool_result: async ({ toolCallId, toolName, content, isError }) => {
console.log(`[TOOL_RESULT] ${toolName} (${toolCallId}):`, {
hasError: isError,
contentLength: content.length
});
return undefined;
}
Summary
| Hook | Layer | When | Can Block? |
|---|---|---|---|
convertToLlm |
Agent | Before LLM call | No (sync) |
transformContext |
Agent | Before convertToLlm |
Yes (async) |
beforeToolCall |
Agent | After validation | Yes (async) |
afterToolCall |
Agent | After execution | Yes (async) |
shouldStopAfterTurn |
Agent | After turn_end | Yes (async) |
prepareNextTurn |
Agent | Before next turn | Yes (async) |
getSteeringMessages |
Agent | After turn_end | Yes (async) |
getFollowUpMessages |
Agent | When agent would stop | Yes (async) |
All hooks are optional and have sensible defaults.