Why hooks exist
Instructions in a system prompt are suggestions. A sufficiently stubborn or confused LLM can ignore them. For most agent behavior that is exactly what you want — instructions give the model room to adapt. But some requirements are not suggestions. “Every sensitive tool call must be logged.” “Write operations are blocked when the org is in read-only mode.” “Payments above ¥50k require a human tap before firing.” These are invariants — facts about the system that must hold regardless of what the model decides in any given turn. A hook is code that runs outside the LLM loop at a well-defined point in the agent’s execution lifecycle. The LLM cannot see the hook. The LLM cannot argue with the hook. The LLM cannot talk the hook into skipping a step. If aPreToolUse hook returns allow=False, the tool call does not happen — no matter how insistent the reasoning trace was.
This is the critical architectural distinction:
| Mechanism | Where it runs | Who controls it | Guarantee |
|---|---|---|---|
| System prompt instruction | Inside LLM inference | Model | ”Probably follows it” |
| Tool description / schema | Inside LLM inference | Model | ”Probably follows it” |
| Hook | Around LLM inference | Platform code | Always runs |
Where hooks plug in
Three hook points are defined today. Each marks a boundary the agent crosses during one loop iteration:| Hook point | Fires when | Can it block? | Can it mutate data? |
|---|---|---|---|
SessionStart | Before the first LLM call of a session | No | Yes — injects context into the initial prompt |
PreToolUse | After the LLM picks a tool, before the tool runs | Yes (via allow=False) | Yes — can rewrite tool_args before execution |
PostToolUse | After the tool returns, before the observation goes to the LLM | No | Yes — can rewrite the observation |
PreToolUse hook’s rewritten args are carried forward to later hooks, so middleware composes.
When a hook vs. an instruction
Deciding whether to solve a requirement with a prompt instruction or a hook is the same calculation as “runtime assert vs. code comment”:| Symptom | Solution |
|---|---|
| ”The agent should prefer X when Y” | Instruction — soft guidance, model has latitude |
| ”The agent must log every call to connector Z” | PostToolUse hook — cannot rely on the model to remember |
| ”Payments over ¥50k need human approval” | PreToolUse hook — cannot rely on the model to ask |
| ”The agent should introduce itself in Chinese” | Instruction — stylistic, low cost if missed |
| ”The agent cannot write to the production database in read-only mode” | PreToolUse hook — safety invariant, zero tolerance |
| ”The agent should summarize long DB query results” | Could be either, but a hook is robust — see PostToolUse truncate |
The hook contract
A hook is a subclass ofPreToolUseHook, PostToolUseHook, or SessionStartHook with one required method:
HookContext passed in carries tool_name, tool_args, agent_id, user_id, and a flexible metadata dict the engine populates with per-request facts (org id, conversation id, the connector action’s requires_confirmation flag, …).
The HookResult returned controls the outcome:
allow: bool = True— whether the tool call proceeds (ignored forPostToolUse/SessionStart)error: str | None— human-readable reason, surfaced to the LLM as the observation when blockedmodified_args: dict | None— if set, replaces the tool args before executionmodified_result: Any | None— if set (PostToolUse), replaces the observation before it returns to the LLMside_effects: list[str]— audit trail of what the hook did, merged into the agent’s trace
Case study: FeishuGateHook
The first hook shipped on top of this system is FeishuGateHook — a PreToolUse hook that turns any tool flagged requires_confirmation=True into a human-in-the-loop approval card posted to the org’s Feishu group.
This hook exercises the full lifecycle:
What this design is buying:
- The tool call is genuinely suspended. The agent’s SSE stream pauses between “I will call
oa__purchase_pay” and the observation. The user sees the agent waiting, which matches what is happening under the hood. - Approval survives a process restart. The pending row is in the database, not in memory. If the backend restarts while a card is outstanding, the next poll picks up where it left off.
- The decision is audited.
ConfirmationRequestkeepspayload,responded_at,responded_by_open_id, and the final status — an auditable record of who approved what and when. - No LLM in the decision loop. The model produces the tool call. Humans produce the verdict. The hook is the deterministic bridge.
FeishuGateHook depends on a configured Feishu Channel — the hook sends the card through the channel’s send_interactive_card() method and listens for callback events the channel parsed. The separation is deliberate: the hook owns “approval state machine”, the channel owns “IM platform mechanics”. The same hook could target Slack or WeCom tomorrow without changing its logic — only the channel implementation.
Planned hooks (v0.9)
Four hook patterns are on the v0.9 roadmap, all reusing the same lifecycle:| Hook | Point | Purpose |
|---|---|---|
AuditLogHook | PostToolUse | Auto-write ConnectorCallLog on every connector call. Today this is manual; making it a hook ensures coverage. |
ReadOnlyGuard | PreToolUse | Block writes when the org is in read-only mode. |
ResultTruncateHook | PostToolUse | Truncate oversized tool observations (>8k chars) before they reach the LLM context. |
ConnectorRateLimitHook | PreToolUse | Per-connector per-user call-frequency cap, independent of LLM rate limits. |
hooks: [...]) declaring shell commands or Python callables to run on matching tool events. This follows the same pattern modern agent frameworks (Claude Code, OpenDevin) have converged on — hook-based enforcement keeps the “must always happen” logic out of prompts.
Hooks vs. Channels
The two abstractions solve orthogonal problems:| Concept | What it models | Lifetime | Example |
|---|---|---|---|
| Hook | A point in the agent’s execution where platform code runs | Per tool call | FeishuGateHook, AuditLogHook |
| Channel | A pluggable adapter to an external messaging platform | Long-lived per org | FeishuChannel, planned SlackChannel |
Current state (v0.8.4)
Snapshot of what shipped and what is still ahead:- ✅
HookRegistry,HookContext,HookResultprimitives wired into both ReAct and DAG - ✅
PreToolUseHook/PostToolUseHook/SessionStartHookabstract bases - ✅
FeishuGateHook— complete, includingConfirmationRequesttable, polling loop, timeout/expire, and callback-driven state flips - ✅ Feishu channel callback endpoint that decodes
card.action.triggerand updates the pending row - ✅ Agent-level hook declarations:
agent.model_config_json.hooks.class_hooksresolves to an instantiatedHookRegistryon every ReAct/DAG session - 🟡 Hook inheritance across execution surfaces (v0.8.5): the main chat path (Portal, API, DAG) fires hooks. Eval Center runs intentionally bypass hooks (automated evaluation must not block on human approval). Delegated sub-agents (
CallAgentTool) and WorkflowAGENTnodes currently do not inherit parent hooks — inheritance policy is a v0.8.5 decision point. - ❌
AuditLogHook,ReadOnlyGuard,ResultTruncateHook,ConnectorRateLimitHook(v0.9) - ❌ User-defined YAML hook declarations (v0.9)
FeishuGateHook) is also a production feature in its own right, which is why the skeleton shipped early for the 2026-04-24 roadshow rather than waiting for the full hook catalog.