Skip to content

Hook Handler

@HookHandler is a component decorator in MaiBot's plugin system for subscribing to named Hook points. The main program triggers named Hooks at key execution points, and all plugin handlers subscribed to that Hook are scheduled to execute according to fixed rules, thereby achieving message interception, rewriting, and observation.

WorkflowStep Removed

WorkflowStep has been replaced by @HookHandler in SDK 2.0. Old code still using WorkflowStep will raise RuntimeError at runtime. This is a non-backward-compatible change — you must migrate to @HookHandler.

Decorator Signature

python
from maibot_sdk import HookHandler
from maibot_sdk.types import HookMode, HookOrder, ErrorPolicy

@HookHandler(
    hook: str,                              # Named Hook name to subscribe to (required)
    *,
    name: str = "",                         # Component name, uses method name if empty
    description: str = "",                  # Component description
    mode: HookMode = HookMode.BLOCKING,     # Handler mode
    order: HookOrder = HookOrder.NORMAL,    # Order slot within the same mode
    timeout_ms: int = 0,                    # Handler timeout (milliseconds), 0 = use Hook default
    error_policy: ErrorPolicy = ErrorPolicy.SKIP,  # Exception handling policy
    **metadata,                             # Additional metadata
)

Handler Modes

BLOCKING Mode

  • Serial execution, can modify incoming kwargs
  • Returning modified_kwargs can update parameters received by subsequent handlers
  • Returning action: "abort" can terminate the entire Hook call chain
  • Suitable for scenarios requiring message interception or rewriting

OBSERVE Mode

  • Background concurrent execution, read-only bypass observation
  • Does not participate in main flow control — returned modified_kwargs and abort requests are ignored
  • Suitable for scenarios like logging, data analysis that don't affect the main flow
python
class HookMode(str, Enum):
    BLOCKING = "blocking"  # Sync wait, can modify data
    OBSERVE = "observe"    # Async observation, cannot modify

Order Slots

Handlers within the same mode are sorted and executed by order:

  • HookOrder.EARLY — Execute first, suitable for pre-interception
  • HookOrder.NORMAL — Default order
  • HookOrder.LATE — Execute later, suitable for supplementary processing

Error Policy

When a handler raises an exception, subsequent behavior is determined by error_policy:

  • ErrorPolicy.ABORT — On exception, abort the current Hook call
  • ErrorPolicy.SKIP — Log the error, skip this handler and continue (default)
  • ErrorPolicy.LOG — Log the error, and continue executing subsequent hooks

Scheduling Order

Hook handlers are globally sorted according to the following rules:

  1. Mode priority: blocking before observe
  2. Order slot: earlynormallate
  3. Source priority: Built-in plugins before third-party plugins
  4. Plugin ID: Sorted alphabetically
  5. Handler name: Sorted alphabetically

Basic Usage

Blocking Mode Example: Intercept and Modify Messages

python
from maibot_sdk import MaiBotPlugin, HookHandler
from maibot_sdk.types import HookMode, HookOrder, ErrorPolicy


class MyPlugin(MaiBotPlugin):
    async def on_load(self) -> None:
        self.ctx.logger.info("Plugin loaded")

    async def on_unload(self) -> None:
        self.ctx.logger.info("Plugin unloaded")

    async def on_config_update(self, scope: str, config_data: dict, version: str) -> None:
        pass

    @HookHandler(
        "chat.receive.before_process",
        name="message_filter",
        description="Filter inbound messages",
        mode=HookMode.BLOCKING,
        order=HookOrder.EARLY,
        error_policy=ErrorPolicy.ABORT,
    )
    async def handle_message_filter(self, **kwargs):
        message = kwargs.get("message", {})
        # Filter logic: if message contains banned words, terminate processing chain
        raw_message = message.get("raw_message", "")
        if "banned_word" in raw_message:
            self.ctx.logger.info("Message filtered: %s", raw_message)
            return {"action": "abort"}

        # Modify message content and continue
        kwargs["message"]["filtered"] = True
        return {"action": "continue", "modified_kwargs": kwargs}

Observe Mode Example: Log Recording

python
from maibot_sdk import MaiBotPlugin, HookHandler
from maibot_sdk.types import HookMode, HookOrder


class LogPlugin(MaiBotPlugin):
    async def on_load(self) -> None:
        self.ctx.logger.info("Log plugin loaded")

    async def on_unload(self) -> None:
        self.ctx.logger.info("Log plugin unloaded")

    async def on_config_update(self, scope: str, config_data: dict, version: str) -> None:
        pass

    @HookHandler(
        "chat.receive.after_process",
        name="message_logger",
        description="Record all inbound messages",
        mode=HookMode.OBSERVE,
        order=HookOrder.LATE,
    )
    async def observe_message(self, **kwargs):
        message = kwargs.get("message", {})
        self.ctx.logger.info(
            "Observed message: user=%s, text=%s",
            message.get("user_id", "unknown"),
            message.get("raw_message", ""),
        )
        # Observe mode return values are ignored

Blocking Mode Example: Modify Send Parameters

python
from maibot_sdk import MaiBotPlugin, HookHandler
from maibot_sdk.types import HookMode, HookOrder


class SendInterceptorPlugin(MaiBotPlugin):
    async def on_load(self) -> None:
        self.ctx.logger.info("Send interceptor plugin loaded")

    async def on_unload(self) -> None:
        self.ctx.logger.info("Send interceptor plugin unloaded")

    async def on_config_update(self, scope: str, config_data: dict, version: str) -> None:
        pass

    @HookHandler(
        "send_service.before_send",
        name="send_modifier",
        description="Modify send parameters",
        mode=HookMode.BLOCKING,
        order=HookOrder.NORMAL,
        timeout_ms=5000,
    )
    async def modify_send_params(self, **kwargs):
        # Disable typing effect, force enable send log
        kwargs["typing"] = False
        kwargs["show_log"] = True
        return {"action": "continue", "modified_kwargs": kwargs}

Common Hook Names

Chat Message Chain

  • chat.receive.before_process — Before inbound message executes process()
  • chat.receive.after_process — After inbound message completes lightweight preprocessing

Command Execution Chain

  • chat.command.before_execute — After command matches successfully, before actual execution
  • chat.command.after_execute — After command execution ends

Send Service Chain

  • send_service.after_build_message — After outbound message is built
  • send_service.before_send — Before calling Platform IO to send
  • send_service.after_send — After send process ends

Heart Flow Cycle Chain

  • heart_fc.heart_flow_cycle_start — When heart flow cycle starts
  • heart_fc.heart_flow_cycle_end — When heart flow cycle ends

Maisaka Planner Chain

  • maisaka.planner.before_request — Before sending planning request to model
  • maisaka.planner.after_response — After receiving model response

Maisaka Replyer Chain

  • maisaka.replyer.before_request — Before the Maisaka replyer sends the model request; can read or rewrite this call's reply_tool_args
  • maisaka.replyer.before_model_request — After the Maisaka replyer builds the final messages and before the model request; can rewrite the actual message list sent to the model
  • maisaka.replyer.after_response — After the Maisaka replyer receives the model response; can rewrite the reply or request regeneration

reply_tool_args remains visible in the expression selection chain, maisaka.replyer.before_request, and maisaka.replyer.after_response. It contains extra reply tool arguments other than msg_id, set_quote, and reference_info; modifications returned from before_request continue to later replyer hooks.

Switching Models or Appending Prompts Before Replyer Requests

maisaka.replyer.before_request is the last mutable point before the replyer sends the model request. A blocking handler can rewrite these fields:

  • task_name str — Task name used by this replyer request. Changing it uses that task's default model pool and generation options.
  • model_name str — Concrete model name for this replyer request. It must exist in [[models]] in model_config.toml. When set, only this model is attempted once instead of rotating through the task model pool.
  • extra_prompt str — Extra reply requirements appended to this replyer prompt.
  • reference_info str — Reference information passed by the reply tool. It can be rewritten.
  • reply_tool_args dict — Extra reply tool arguments. Changes continue to later replyer hooks.

model_name is a concrete model name, not a task name. To route through another task's model pool, change task_name. If both task_name and model_name are set, the task supplies generation options such as temperature, token limit, and timeout, while model_name selects the actual model.

If you need to rewrite the exact message list sent by the replyer, use maisaka.replyer.before_model_request. This Hook fires after the replyer has built messages for the currently selected model capability. Blocking handlers can return a new messages list; this is useful for inserting a synthetic first user message after system, experimenting with temporary prompts, or logging the final request body. The Hook only changes this temporary LLM request and does not write back to chat history or affect mid-term memory insertion.

A common pattern is to first use maisaka.planner.before_request to add a parameter schema to the built-in reply tool so the planner can fill that parameter, then read reply_tool_args in maisaka.replyer.before_request to route the model:

python
from maibot_sdk import MaiBotPlugin, HookHandler
from maibot_sdk.types import HookMode


class ThinkingLevelPlugin(MaiBotPlugin):
    @HookHandler("maisaka.planner.before_request", mode=HookMode.BLOCKING)
    async def add_reply_tool_param(self, **kwargs):
        for tool in kwargs.get("tool_definitions", []):
            function = tool.get("function", {})
            if function.get("name") != "reply":
                continue

            parameters = function.setdefault("parameters", {})
            properties = parameters.setdefault("properties", {})
            properties["thinking_level"] = {
                "type": "string",
                "enum": ["normal", "deep"],
                "description": "Reply thinking intensity. normal means a regular reply; deep uses a stronger model and analyzes context more carefully.",
            }
        return {"action": "continue", "modified_kwargs": kwargs}

    @HookHandler("maisaka.replyer.before_request", mode=HookMode.BLOCKING)
    async def route_replyer_model(self, **kwargs):
        reply_tool_args = kwargs.get("reply_tool_args", {})
        if reply_tool_args.get("thinking_level") == "deep":
            kwargs["model_name"] = "your-deep-model-name"
            kwargs["extra_prompt"] = "Please understand the context more carefully before replying."

        return {"action": "continue", "modified_kwargs": kwargs}

Adding or changing a hook name usually does not require plugin SDK runtime changes: @HookHandler accepts a string hook name, and availability is validated by the Host-registered HookSpec. SDK-side updates are only needed for constants, type hints, docs, or examples.

Expression Selection Chain

  • expression.select.before_select — After the expression candidate pool is loaded and before the default selection is built; can rewrite candidates, max_num, or abort this selection
  • expression.select.after_selection — After the default selection is built; can rewrite selected_expression_ids or selected_expressions

before_select receives chat_id, session_id, chat_info, chat_history, reply_message, reply_tool_args, target_message, reply_reason, max_num, think_level, and candidates. reply_tool_args contains extra reply tool arguments other than msg_id, set_quote, and reference_info. after_selection also receives selected_expression_ids and selected_expressions.

python
@HookHandler("expression.select.after_selection", mode=HookMode.BLOCKING)
async def replace_expression_selection(self, **kwargs):
    strategy = kwargs.get("reply_tool_args", {}).get("expression_strategy")
    candidates = kwargs.get("candidates", [])
    selected_ids = [item["id"] for item in candidates[:1]]
    kwargs["selected_expression_ids"] = selected_ids
    return {"action": "continue", "modified_kwargs": kwargs}

Handler Return Values

Blocking mode handlers can return a dictionary to control the subsequent flow:

  • action str"continue" to continue the call chain, "abort" to terminate it
  • modified_kwargs dict — Modified parameters, will be passed to subsequent handlers

Observe mode handler return values are ignored — no need to return a control dictionary.

Hook Dispatch Flow

Migration Guide: WorkflowStep → HookHandler

  • @WorkflowStep(stage="pre_process")@HookHandler("chat.receive.before_process") — Use named Hook points instead of fixed stages
  • blocking=Truemode=HookMode.BLOCKING — Parameter name change
  • observe=Truemode=HookMode.OBSERVE — Parameter name change
  • priority=10order=HookOrder.EARLY — Changed to three-tier enum

DANGER

Calling WorkflowStep(...) directly now immediately raises RuntimeError — there is no compatibility mapping. You must manually replace all @WorkflowStep with @HookHandler.

python
# Old code (SDK 1.x) — no longer works
@WorkflowStep(stage="pre_process", blocking=True)
async def on_pre_process(self, **kwargs):
    ...

# New code (SDK 2.0)
@HookHandler("chat.receive.before_process", mode=HookMode.BLOCKING)
async def on_pre_process(self, **kwargs):
    ...