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:
Hooks are how FIM One turns “the agent is supposed to…” into “the agent cannot bypass…”.
Where hooks plug in
Three hook points are defined today. Each marks a boundary the agent crosses during one loop iteration:
Multiple hooks at the same point run in priority order. An earlier
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”:
Rule of thumb: if the wrong behavior is an incident, use a hook. If the wrong behavior is a minor annoyance, an instruction is fine.
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:
A user-defined hook layer is also planned: per-agent YAML configuration (
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:
Hooks consume Channels — a hook that needs to talk to the outside world (send a card, post an alert, escalate to a group) calls into the org’s Channel. A channel without any hook using it is still useful (e.g. agents can proactively send notifications via a tool), but the approval-gate pattern specifically requires both halves to be in place.
Put differently: Channels are the “where do I talk to humans” plumbing, Hooks are the “when do I have to talk to humans” policy. Production human-in-the-loop workflows need both.
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 -
✅ Hooks fire on every execution surface: the main chat path (Portal, API, DAG), delegated sub-agents (
CallAgentTool), and WorkflowAGENTnodes. Eval Center runs intentionally bypass hooks (automated evaluation must not block on human approval). A delegated agent does not inherit its caller’s registry — it builds its own from its ownmodel_config_json. Because the confirmation gate is auto-attached to every agent, rebuilding is a superset of inheriting: the delegate gets the gate plus whatever it declares for itself, and the gate resolves the delegate’srequire_confirmation_for_allrather than its caller’s. Both surfaces fail closed: an agent whose hooks cannot be built is not run. -
❌
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.