跳到正文
FunCoding

搜索

搜索文档、文章、Skill 和 MCP

Hook reference

How plugin hooks register and execute, plus the complete typed hook catalog

Registration rules, execution contracts, per-handler budgets, and the complete typed hook catalog. Part of the Plugin hooks guide.

Registration and execution

Keep register(api) synchronous and register handlers there. The handlers themselves may be asynchronous except for the two synchronous persistence hooks.

Handlers default to priority 0; higher priorities run first, with registration order breaking ties. Execution depends on the hook kind:

KindExecution contract
ModifySequential; results merge according to the hook's contract below. Returning a rewrite does not generally change the event passed to later handlers.
ClaimSequential; the first { handled: true } wins and skips remaining handlers.
GateSequential; a block stops remaining handlers.
ObserveHandlers run concurrently; return values are ignored. The emitter may await completion or dispatch fire-and-forget.
Sync modify/gateSynchronous, in priority order; each handler sees the latest message. Promises are ignored with a warning.
EvaluateSkill evaluators run concurrently and produce separate attributed outcomes.

Priority does not serialize observation side effects. Fire-and-forget events can overlap later events, and callbacks are not a durable event queue. Return modifications explicitly instead of relying on in-place mutation.

api.on(name, handler, opts?) accepts:

OptionEffect
matcherNon-empty list of canonical OpenClaw tool ids handled by before_tool_call or after_tool_call, such as exec, apply_patch, or spawn_agent. Omit to match all tools. Empty lists, wildcards, blanks, and provider-specific aliases are invalid.
priorityOrdering; higher runs first.
registrationIdStable identity for one registration inside a plugin. Skill evaluators use it as evaluatorId; otherwise the plugin id is used.
timeoutMsPer-handler asynchronous await budget. Expiry applies the hook's failure policy below; it does not cancel the handler or its side effects. Omit to use the runner's default, if any.
eligibleTriggersFor before_agent_reply only, limits host dispatch to one or more of cron, heartbeat, or user.
eligibleDispatchKindsFor reply_dispatch only, limits host dispatch to agent, acp, or both. Omit to handle all dispatch kinds.
requiresToolAuthorityFor before_prompt_build only, runs the handler after the host finalizes the current turn's tool surface and supplies ephemeral ctx.toolAuthority. Use this for context retrieval that must follow tool policy.

Trigger eligibility is enforced by the host before it invokes the handler. A hook registered with eligibleTriggers: ["heartbeat", "cron"] is therefore inactive for user turns, including a recovered user turn. Omitted, empty, malformed, or partly unknown lists remain unrestricted, so the hook runs for those turns. Other hook kinds do not accept this option.

Operators can set hook budgets without patching plugin code:

{
  "plugins": {
    "entries": {
      "my-plugin": {
        "hooks": {
          "timeoutMs": 30000,
          "timeouts": {
            "before_prompt_build": 90000,
            "agent_end": 60000
          }
        }
      }
    }
  }
}

hooks.timeouts.<hookName> overrides hooks.timeoutMs, which overrides the plugin-authored api.on(..., { timeoutMs }) value. The two operator config fields accept positive integers up to 600000 ms. Prefer per-hook overrides for known-slow hooks so one plugin does not get a longer budget everywhere.

A timed-out handler promise continues running because hook callbacks do not receive a timeout-owned cancellation signal. before_tool_call may receive the owning tool call's ctx.abortSignal, but hook timeout expiry does not abort it. The hook dispatch can release its Gateway admission while that plugin work is still in progress. Plugins that own long-running work must provide their own cancellation and shutdown lifecycle.

Prompt-building handlers can use ctx.hookInvocation.assertActive() to reject side effects after their result becomes ineligible. See Handler lifetime.

The standard runner applies these defaults per handler:

HooksDefault timeoutOn thrown error or timeout
before_agent_run, before_tool_call, before_install15 secondsFail closed: block the run, tool call, or install
before_agent_finalize, before_prompt_build, message_sending, reply_payload_sending, resolve_exec_env15 secondsLog and skip the failed handler; retain other successful results
agent_end, before_compaction, after_compaction, skill_changed30 secondsLog and continue
channel_pairing_requested2 secondsLog and continue
gateway_stop5 secondsLog and continue shutdown
Other asynchronous hooks, including claim hooksNo runner timeout unless configuredLog and continue
tool_result_persist, before_message_writeNo asynchronous timeoutSynchronous errors are logged; failed results are ignored

An emitter can impose a tighter overall lifecycle budget, such as the shutdown session_end drain below. A timeout only bounds an asynchronous await; it cannot interrupt synchronous JavaScript. For a policy requirement, use a fail-closed gate rather than assuming an observation or delivery hook will reject the operation on failure.

For claim hooks, continuing means trying the next handler. The caller decides what happens if nobody claims; a failed inbound_claim for a bound conversation can produce a binding notice instead of an ordinary agent reply.

Channel plugins that use createReplyDispatcher can likewise declare a larger positive per-stage budget with beforeDeliverOptions: { timeoutMs }, or when appending work with dispatcher.appendBeforeDeliver(handler, { timeoutMs }). Without an owner-declared budget, those callbacks use the same 15-second default so a hung callback cannot retain the serialized delivery lane.

Hook catalog

Hooks are grouped by the surface they extend. Kinds refer to the execution contracts above; a modifying hook is not an observation hook.

Agent turn

HookKindPurpose
before_model_resolveModifyOverride provider or model before session messages load
agent_turn_prepareModifyInspect drained plugin turn injections and add context before prompt hooks
before_prompt_buildModifyAdd prompt context, narrow the current turn's submitted tools, or perform authorized post-policy enrichment
before_agent_runGateInspect prompt and session messages before model submission; node turns gate Gateway input before worker-local context assembly
before_agent_replyClaimShort-circuit the model turn with a synthetic reply or silence
before_agent_finalizeModifyInspect the natural final answer and request one more model pass
agent_endObserveObserve final messages, success state, and run duration
heartbeat_prompt_contributionModifyAdd heartbeat-only context for background monitor and lifecycle plugins

Conversation observation

HookKindPurpose
model_call_started / model_call_endedObserveSanitized provider/model call metadata: timing, outcome, bounded request-id hashes. No prompt or response content.
llm_inputObserveProvider input: system prompt, prompt, history
llm_outputObserveProvider output, usage, and the resolved contextTokenBudget when available

Tools

HookKindPurpose
before_tool_callModify / gateRewrite tool params, block execution, or require approval
after_tool_callObserveObserve tool results, errors, and duration
resolve_exec_envModifyContribute plugin-owned environment variables to exec
tool_result_persistSync modifyRewrite a toolResult message before transcript persistence
before_message_writeSync modify / gateRewrite or block a message before transcript persistence

Messages and delivery

HookKindPurpose
inbound_claimClaimClaim an inbound message for the plugin that owns its conversation binding
channel_pairing_requestedObserveObserve newly created DM pairing requests
message_receivedObserveObserve inbound content, sender, thread, and metadata
message_sendingModify / gateRewrite outbound content or cancel delivery
reply_payload_sendingModify / gateMutate or cancel normalized reply payloads before delivery
message_sentObserveObserve outbound delivery success or failure
before_dispatchClaimHandle an inbound message before the normal model dispatch
reply_dispatchClaimOwn reply generation and dispatch instead of the default model path

inbound_claim is not a global pre-routing broadcast. OpenClaw invokes it only for the plugin that owns the message's core-managed conversation binding. To suppress an ordinary agent turn before model input without retaining the original prompt in transcript, use before_agent_run on a supported runner. To short-circuit an agent turn with a synthetic reply or silence, use before_agent_reply.

For heartbeat turns, before_agent_reply receives ctx.heartbeatEventQueueSessionKey when the host knows the underlying system-event queue. This can differ from ctx.sessionKey for an isolated heartbeat run. Use the supplied key for event lookup; do not derive a base session by removing a :heartbeat suffix because a configured session can be named heartbeat. The field is optional for older hosts. Without it, plugins can inspect only ctx.sessionKey; they cannot infer an isolated run's base queue. The field identifies an existing queue and grants no additional access.

Sessions and compaction

HookKindPurpose
session_start / session_endObserveTrack session lifecycle boundaries
before_compaction / after_compactionObserveObserve compaction boundaries; no rewrite or veto result
before_resetObserveObserve session-reset events (/reset, programmatic resets)

Successful engine-owned compaction attempts emit after_compaction even when no history changes, with compactedCount: 0. Failed or aborted attempts do not emit that completion hook.

session_end.reason is one of new, reset, idle, daily, compaction, deleted, shutdown, restart, or unknown. session_start has no reason field; it can include resumedFrom. Shutdown/restart events come from the Gateway finalizer for active sessions, so plugins can close session state before the process exits.

On current hosts, session_end context includes endedTranscript. Plugins with conversation access receive { available: true, readTail } when OpenClaw has an immutable ended-session source. Calls must supply positive maxMessages and maxBytes; OpenClaw enforces host caps and returns { messages, totalMessages, truncated }. The reader remains valid while the admitted handler is active. Retained or still-pending reads reject after the handler settles, its configured hook timeout, forced retirement of the owning plugin instance, or the shutdown/restart drain deadline.

When no safe source exists, the value is { available: false, reason }. Current reasons distinguish missing conversation permission, a lifecycle with no stable cutoff, deleted incognito state, an unavailable archive, and an unsupported source. This is an explicit non-result, not an empty transcript. Plugins compiled against this contract should still treat an absent field as an older host.

session_end itself remains a metadata hook and can register without conversation access. For non-bundled plugins, plugins.entries.<id>.hooks.allowConversationAccess=true grants only the bounded ended-transcript reader. Bundled plugins follow the same effective policy and can be denied with allowConversationAccess: false. Permission changes apply to handlers admitted by a successfully published plugin runtime replacement. A handler already running retains its generation's grant while its bounded invocation remains admitted; handler settlement, a hook timeout, forced instance retirement, or shutdown drain expiry ends that lease. Predecessor drain and successor publication may overlap.

Shutdown and restart share one 2-second total session_end drain budget across all active sessions and plugin handlers; the budget is not per handler. Return quickly or keep finalization bounded and persistence crash-consistent. If the budget expires, OpenClaw logs shutdown session-end drain timed out and continues shutdown, so unfinished plugin work can be interrupted.

For sessions.create calls with parentSessionKey and emitCommandHooks: true, a distinct child always receives session_start. Callers declare whether the parent also receives terminal session_end with succeedsParent: true means successor, false means parallel child. Omission preserves the legacy parent-rollover behavior. The command:new and before_reset hooks still describe the requested /new action in both cases.

Subagents

  • subagent_spawned / subagent_ended - observe subagent launch and completion.
  • subagent_progress - observe portable started / ended progress for a background child run; includes runId, childSessionKey, optional requester route, and an outcome on ended.
  • subagent_delivery_target - modifying compatibility hook for completion delivery when no core session binding can project a route. The first returned origin wins.
  • subagent_spawned includes resolvedModel and resolvedProvider when OpenClaw has resolved the child session's native model before launch.
  • subagent_ended carries targetSessionKey (identity - matches subagent_spawned.childSessionKey), targetKind ("subagent" or "acp"), reason, optional outcome ("ok", "error", "timeout", "killed", "reset", or "deleted"), optional error, runId, endedAt, accountId, and sendFarewell. It does not include agentId or childSessionKey; use targetSessionKey to correlate with the matching subagent_spawned event.

Lifecycle

HookKindPurpose
gateway_start / gateway_stopObserveStart or stop plugin-owned services with the Gateway
cron_reconciledObserveReconcile against the complete Gateway cron state after startup or reload
cron_changedObserveObserve Gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled)
before_installModify / gateInspect staged skill or plugin install material from a loaded plugin runtime
skill_changedObserveObserve committed live-skill create, update, and removal events

Skill lifecycle

skill_changed fires after a live skill create, update, or removal commits and includes optional before/after artifacts with content and tree hashes, plus declared and source versions when available.

Channel pairing requests

Use channel_pairing_requested when a plugin needs to notify an operator or write an audit record after an unpaired DM sender creates a pending pairing request. The hook is dispatched when the request is created; channel delivery of the pairing reply is not delayed by slow or failing hook handlers.

api.on("channel_pairing_requested", async (event) => {
  await notifyOperator({
    text: `New ${event.channel} pairing request from ${event.senderId}: ${event.code}`,
  });
});

The hook is observation-only. It does not approve, reject, suppress, or rewrite the pairing reply. The payload includes the channel, optional accountId, channel-scoped senderId, pairing code, and channel metadata. Treat the pairing code as a live single-use approval credential and deliver it only to a trusted operator sink. Treat metadata as untrusted sender-supplied identity text. The hook does not include the inbound message body or media.