Skip to main content

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 a PreToolUse 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 of PreToolUseHook, PostToolUseHook, or SessionStartHook with one required method:
The 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 for PostToolUse / SessionStart)
  • error: str | None — human-readable reason, surfaced to the LLM as the observation when blocked
  • modified_args: dict | None — if set, replaces the tool args before execution
  • modified_result: Any | None — if set (PostToolUse), replaces the observation before it returns to the LLM
  • side_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. ConfirmationRequest keeps payload, 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, HookResult primitives wired into both ReAct and DAG
  • PreToolUseHook / PostToolUseHook / SessionStartHook abstract bases
  • FeishuGateHook — complete, including ConfirmationRequest table, polling loop, timeout/expire, and callback-driven state flips
  • ✅ Feishu channel callback endpoint that decodes card.action.trigger and updates the pending row
  • ✅ Agent-level hook declarations: agent.model_config_json.hooks.class_hooks resolves to an instantiated HookRegistry on every ReAct/DAG session
  • Hooks fire on every execution surface: the main chat path (Portal, API, DAG), delegated sub-agents (CallAgentTool), and Workflow AGENT nodes. 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 own model_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’s require_confirmation_for_all rather 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)
The Hook System is a load-bearing foundation for v0.9 production hardening. Its first user (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.