Plugin Hooks: Intercept Agent, Tool, Message, Session, and Gateway Events

Learn how to use plugin hooks as in-process extension points in OpenClaw plugins. This page covers registering typed hooks with api.on() to inspect or modify agent runs, tool calls, message flow, and more.

Read this when

  • You are building a plugin that needs before_tool_call, before_agent_reply, message hooks, or lifecycle hooks
  • You need to block, rewrite, or require approval for tool calls from a plugin
  • You are deciding between internal hooks and plugin hooks
  • You are projecting OpenClaw cron wakes into an external host scheduler

Plugin hooks serve as in-process extension points within OpenClaw plugins. They allow you to inspect or modify agent runs, tool calls, message flow, session lifecycle, subagent routing, installs, or Gateway startup.

For a small operator-installed HOOK.md script that responds to command and Gateway events like /new, /reset, /stop, agent:bootstrap, or gateway:startup, use internal hooks instead.

Quick start

To register typed hooks, call api.on(...) from the plugin entry point:

import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";

export default definePluginEntry({
  id: "tool-preflight",
  name: "Tool Preflight",
  register(api) {
    api.on(
      "before_tool_call",
      async (event) => {
        if (event.toolName !== "web_search") {
          return;
        }

        return {
          requireApproval: {
            title: "Run web search",
            description: `Allow search query: ${String(event.params.query ?? "")}`,
            severity: "info",
            timeoutMs: 60_000,
          },
        };
      },
      { priority: 50 },
    );
  },
});

Handlers that return decisions or modifications execute sequentially in descending priority order. When priorities are equal, handlers run in their registration order. Observation-only handlers execute in parallel, and fire-and-forget observation dispatches can overlap with subsequent events. Do not use priority to sequence observation side effects.

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

OptionEffect
priorityDetermines ordering; a higher value runs first.
timeoutMsSets a per-hook await budget. When this budget expires, OpenClaw stops waiting for that handler and proceeds. The handler and its side effects are not cancelled. If omitted, the runner's default per-hook timeout applies.

Operators can configure hook budgets without modifying plugin code:

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

hooks.timeouts.<hookName> takes precedence over hooks.timeoutMs, which in turn overrides the value api.on(..., { timeoutMs }) set by the plugin. Each value must be a positive integer up to 600000 ms. For hooks known to be slow, prefer per-hook overrides so one plugin does not receive a longer budget everywhere.

A handler promise that times out continues running because hook callbacks receive no cancellation signal. The hook dispatch can release its Gateway admission while that plugin's work is still ongoing. Plugins that own long-running work must implement their own cancellation and shutdown lifecycle.

Outbound modifying hooks message_sending and reply_payload_sending use a 15-second default per handler. If a timeout occurs, OpenClaw logs the plugin error and proceeds with the latest payload so the serialized delivery lane can settle. For plugins that intentionally perform slower work before delivery, set a larger per-hook budget.

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

Each hook receives event.context.pluginConfig, which is the resolved configuration for the plugin that registered that handler. OpenClaw injects this per handler without modifying the shared event object visible to other plugins.

Hook catalog

Hooks are organized by the surface they extend. Bold names accept a decision result (block, cancel, override, or require approval). The rest are observation-only.

Agent turn

HookPurpose
before_model_resolveOverride the provider or model before session messages load
agent_turn_prepareConsume queued plugin turn injections and add same-turn context before prompt hooks
before_prompt_buildAdd dynamic context or system-prompt text before the model call
before_agent_runInspect the final prompt and session messages before model submission; can block the run
before_agent_replyShort-circuit the model turn with a synthetic reply or silence
before_agent_finalizeInspect the natural final answer and request one more model pass
agent_endObserve final messages, success state, and run duration
heartbeat_prompt_contributionAdd heartbeat-only context for background monitor and lifecycle plugins

Conversation observation

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

Tools

HookPurpose
before_tool_callRewrite tool params, block execution, or require approval
after_tool_callObserve tool results, errors, and duration
resolve_exec_envContribute plugin-owned environment variables to exec
tool_result_persistRewrite the assistant message produced from a tool result
before_message_writeInspect or block an in-progress message write (rare)

Messages and delivery

HookPurpose
inbound_claimClaim an inbound message before agent routing (synthetic replies)
channel_pairing_requestedObserve newly created DM pairing requests
message_receivedObserve inbound content, sender, thread, and metadata
message_sendingRewrite outbound content or cancel delivery
reply_payload_sendingMutate or cancel normalized reply payloads before delivery
message_sentObserve outbound delivery success or failure
before_dispatchInspect or rewrite an outbound dispatch before channel handoff
reply_dispatchParticipate in the final reply-dispatch pipeline

Sessions and compaction

HookPurpose
session_start / session_endMonitor the start and end of session lifecycles. reason can be any of new, reset, idle, daily, compaction, deleted, shutdown, restart, or unknown. When the process shuts down or restarts while sessions remain active, shutdown/restart are triggered by the Gateway shutdown finalizer. This allows plugins (such as memory stores and transcript stores) to close ghost rows rather than leaving them dangling across restarts. The finalizer has a time limit, so a slow plugin won't prevent SIGTERM/SIGINT from being handled.
before_compaction / after_compactionWatch or tag compaction cycles
before_resetTrack session reset events (/reset, resets triggered programmatically)

When calling sessions.create with parentSessionKey and emitCommandHooks: true, a dedicated child always gets session_start. The caller decides whether the parent also gets a terminal session_end using succeedsParent: true indicates a successor, false indicates a parallel child. If omitted, the legacy parent rollover behavior is preserved. In both scenarios, the command:new and before_reset hooks still describe the /new action that was requested.

Subagents

  • subagent_spawned / subagent_ended - track when a subagent starts and finishes.
  • subagent_delivery_target - a compatibility hook for delivering completions when no route can be projected by a core session binding.
  • subagent_spawning - a deprecated compatibility hook. Before subagent_spawned triggers, the core now prepares thread: true subagent bindings using channel session-binding adapters.
  • subagent_spawned contains resolvedModel and resolvedProvider once OpenClaw has determined the child session's native model prior to launch.
  • subagent_ended holds targetSessionKey (identity, matching subagent_spawned.childSessionKey), targetKind (either "subagent" or "acp"), reason, an optional outcome (which can be "ok", "error", "timeout", "killed", "reset", or "deleted"), an optional error, runId, endedAt, accountId, and sendFarewell. It does not include agentId or childSessionKey; use targetSessionKey to correlate with the corresponding subagent_spawned event.

Lifecycle

HookPurpose
gateway_start / gateway_stopStart or stop services owned by the plugin alongside the Gateway
deactivateA deprecated compatibility alias for gateway_stop; in new plugins, use gateway_stop
cron_reconciledReconcile against the full Gateway cron state after a startup or reload
cron_changedMonitor lifecycle changes for Gateway-owned crons (added, updated, removed, started, finished, scheduled)
before_installInspect staged skill or plugin installation material from a loaded plugin runtime

Channel pairing requests

When a plugin needs to alert an operator or generate an audit record after an unpaired DM sender creates a pending pairing request, use channel_pairing_requested. This hook fires at the moment the request is created; slow or failing hook handlers do not delay channel delivery of the pairing reply.

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

This hook provides observation only. It cannot approve, reject, suppress, or modify the pairing reply. The payload includes the channel, an optional accountId, a channel-scoped senderId, a pairing code, and channel metadata. Treat the pairing code as a live, single-use approval credential and deliver it solely to a trusted operator sink. Treat metadata as untrusted identity text supplied by the sender. The inbound message body and media are not included in the hook.

Debug runtime hooks

To switch the provider or model for an agent turn, use before_model_resolve; it executes before model resolution. llm_output runs only after a model attempt has produced assistant output.

For evidence of the effective session model, inspect runtime registrations, then use openclaw sessions or the Gateway session or status surfaces. To debug provider payloads, start the Gateway with --raw-stream and --raw-stream-path <path> to write raw model stream events to a jsonl file.

Tool call policy

before_tool_call receives:

  • event.toolName
  • event.params
  • optionally, event.toolKind and event.toolInputKind act as host-authoritative discriminators for tools that intentionally share names; for instance, outer code-mode exec calls use toolKind: "code_mode_exec" and include toolInputKind: "javascript" | "typescript" when the input language is known
  • optionally, event.derivedPaths provides best-effort host-derived target path hints for well-known tool envelopes like apply_patch; these paths may be incomplete or over-approximate what the tool will actually touch (for example, with malformed or partial inputs)
  • optionally, event.runId
  • optionally, event.toolCallId
  • context fields including ctx.agentId, ctx.sessionKey, ctx.sessionId, ctx.runId, ctx.toolKind, ctx.toolInputKind, and diagnostic ctx.trace
  • optionally, ctx.requester is the host-derived requester that started the current message run. It may include channel, accountId, senderId, senderIsOwner, and provider-native roleIds. Missing fields are unproven, not false assurances; fail closed when policy requires them.

It can return:

type BeforeToolCallResult = {
  params?: Record<string, unknown>;
  block?: boolean;
  blockReason?: string;
  requireApproval?: {
    title: string;
    description: string;
    severity?: "info" | "warning" | "critical";
    timeoutMs?: number;
    /** @deprecated Unresolved approvals always deny. */
    timeoutBehavior?: "allow" | "deny";
    allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
    pluginId?: string;
    onResolution?: (
      decision: "allow-once" | "allow-always" | "deny" | "timeout" | "cancelled",
    ) => Promise<void> | void;
  };
};

Guard behavior for typed lifecycle hooks:

  • block: true is terminal and skips lower-priority handlers.
  • block: false is treated as no decision.
  • params rewrites the tool parameters for execution.
  • requireApproval pauses the agent run and asks the user through plugin approvals. /approve can approve both exec and plugin approvals. In Codex app-server report-mode native PreToolUse relays, this defers to the matching app-server approval request; see Codex harness runtime.
  • A lower-priority block: true can still block after a higher-priority hook requested approval.
  • onResolution receives the resolved decision: allow-once, allow-always, deny, timeout, or cancelled.

Sender-aware policy in one file

A standalone plugin file can hold deployment-specific policy in code instead of adding another configuration schema. This example gives owners every tool, lets configured maintainers use a conservative tool and message-action set, and exposes /fix to senders already authorized by the channel configuration:

import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";

const AGENT_ID = "maintenance-agent";
const MAINTAINER_SCOPES = [
  {
    channel: "discord",
    accountId: "operations",
    senderIds: new Set(["maintainer-user-id"]),
    roleIds: new Set(["maintainer-role-id"]),
  },
];
const MAINTAINER_TOOLS = new Set(["read", "web_fetch", "web_search", "session_status", "message"]);
const MAINTAINER_MESSAGE_ACTIONS = new Set(["react", "reply", "thread-create", "thread-reply"]);

export default definePluginEntry({
  id: "maintenance-access",
  name: "Maintenance access",
  description: "Apply sender-aware tool policy to the maintenance agent.",
  register(api) {
    api.on("before_tool_call", (event, ctx) => {
      if (ctx.agentId !== AGENT_ID) {
        return;
      }

      const requester = ctx.requester;
      if (requester?.senderIsOwner === true) {
        return;
      }

      const maintainerScope = requester
        ? MAINTAINER_SCOPES.find(
            (scope) =>
              scope.channel === requester.channel && scope.accountId === requester.accountId,
          )
        : undefined;
      const isMaintainer =
        maintainerScope !== undefined &&
        ((requester?.senderId !== undefined && maintainerScope.senderIds.has(requester.senderId)) ||
          requester?.roleIds?.some((roleId) => maintainerScope.roleIds.has(roleId)) === true);
      if (!isMaintainer) {
        return { block: true, blockReason: "Maintainer access required." };
      }

      if (event.toolName === "message") {
        const action = typeof event.params.action === "string" ? event.params.action : "";
        if (MAINTAINER_MESSAGE_ACTIONS.has(action)) {
          return;
        }
        return { block: true, blockReason: `Owner required for message.${action || "unknown"}.` };
      }

      if (MAINTAINER_TOOLS.has(event.toolName)) {
        return;
      }
      return { block: true, blockReason: `Owner required for ${event.toolName}.` };
    });

    api.registerCommand({
      name: "fix",
      description: "Ask the maintenance agent to investigate and fix an issue.",
      acceptsArgs: true,
      requireAuth: true,
      handler: async (ctx) =>
        ctx.agentId === AGENT_ID
          ? { continueAgent: true }
          : { text: "This command is only available in the maintenance conversation." },
    });
  },
});

Load the file directly and restart the Gateway:

{
  agents: {
    list: [
      {
        id: "maintenance-agent",
        workspace: "~/.openclaw/workspace-maintenance",
      },
    ],
  },
  bindings: [
    {
      agentId: "maintenance-agent",
      match: {
        channel: "discord",
        accountId: "operations",
        peer: { kind: "channel", id: "maintenance-channel-id" },
      },
    },
  ],
  plugins: {
    load: { paths: ["~/.openclaw/policies/maintenance-access.ts"] },
  },
}

AGENT_ID must name the agent bound to the maintenance conversation. The binding selects that agent for normal messages and /fix; the standalone file remains the single owner of owner-versus-maintainer tool policy.

requireAuth: true reuses each channel's existing sender admission. For Discord, a guild or channel users/roles allowlist can authorize the maintenance audience. Other channels can use stable sender ids. The hook then applies the finer per-tool decision on every tool call in the run, including Codex native PreToolUse calls. It can veto a tool the model sees, but cannot add a tool omitted by the host. Existing sandbox, exec approval, owner-only core-tool, and channel policies still apply; the hook cannot grant past them.

Scope sender and role ids to an exact channel/account pair as shown; both are provider-local namespaces. Keep the allowlists conservative. Add write or execution tools only when the deployment's sandbox and approval policy make that safe. For automated or system runs, decide explicitly whether an absent ctx.requester should pass; the example denies it for the scoped agent.

See Plugin permission requests for approval routing, decision behavior, and when to use requireApproval instead of optional tools or exec approvals.

Plugins that need host-level policy can register trusted tool policies with api.registerTrustedToolPolicy(...). These run before ordinary before_tool_call hooks and before normal hook decisions. Bundled trusted policies run first; installed-plugin trusted policies run next in plugin-load order; ordinary before_tool_call hooks run after them. Bundled plugins keep the existing trusted-policy path. Installed plugins must be explicitly enabled and declare every policy id in contracts.trustedToolPolicies; undeclared ids are rejected before registration. Policy ids are scoped to the registering plugin, so different plugins may reuse the same local id. Use this tier only for host-trusted gates such as workspace policy, budget enforcement, or reserved workflow safety.

Exec environment hook

resolve_exec_env lets plugins contribute environment variables to exec tool invocations before the command runs. It receives:

  • event.sessionKey
  • event.toolName, currently always "exec"
  • event.host, one of "gateway", "sandbox", or "node"
  • context fields such as ctx.agentId, ctx.sessionKey, ctx.messageProvider, and ctx.channelId

Produce a Record<string, string> that gets folded into the exec environment. Handlers execute in priority order; for identical keys, later results overwrite earlier ones.

Before merging, hook output is filtered by the host exec environment key policy. PATH is always removed (command resolution and safe-bin checks rely on it). Invalid keys and dangerous host override keys such as LD_*, DYLD_*, NODE_OPTIONS, proxy variables (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY), and TLS override variables (NODE_TLS_REJECT_UNAUTHORIZED, SSL_CERT_FILE, and similar) are also removed. The filtered plugin environment is included in Gateway approval and audit metadata and is passed on to node-host execution requests.

Tool result persistence

Tool results may contain structured details for UI rendering, diagnostics, media routing, or plugin-specific metadata. Treat details as runtime metadata, not as prompt content:

  • Before provider replay and compaction input, OpenClaw strips toolResult.details so metadata does not become part of the model context.
  • Persisted session entries retain only bounded details. Large details are replaced with a compact summary and persistedDetailsTruncated: true.
  • tool_result_persist and before_message_write execute before the final persistence cap. Keep returned details small and avoid putting prompt-relevant text exclusively in details; place model-visible tool output in content.

Prompt and model hooks

For new plugins, use the phase-specific hooks:

  • before_model_resolve: receives only the current prompt and attachment metadata. Return providerOverride or modelOverride.
  • agent_turn_prepare: receives the current prompt, prepared session messages, and any exactly-once queued injections drained for this session. Return prependContext or appendContext.
  • before_prompt_build: receives the current prompt and session messages. Return prependContext, appendContext, systemPrompt, prependSystemContext, or appendSystemContext.
  • heartbeat_prompt_contribution: runs only for heartbeat turns and returns prependContext or appendContext. Designed for background monitors that need to summarize the current state without altering user-initiated turns.

before_agent_run executes after prompt construction and before any model input, including prompt-local image loading and llm_input observation. It receives the current user input as prompt, plus loaded session history in messages and the active system prompt. Return { outcome: "block", reason, message? } to stop the run before the model reads the prompt. reason is internal; message is the user-facing replacement. Only pass and block outcomes are supported; unsupported decision shapes cause a closed failure.

When a run is blocked, OpenClaw stores only the replacement text in message.content plus non-sensitive block metadata such as the blocking plugin id and timestamp. The original user text is not kept in the transcript or future context. Internal block reasons are treated as sensitive and excluded from transcript, history, broadcast, log, and diagnostics payloads. Observability should use sanitized fields such as blocker id, outcome, timestamp, or a safe category.

Agent-turn hooks that include agent_end also contain event.runId when OpenClaw can determine the active run; the same value appears on ctx.runId as well. For cron-driven runs, ctx.jobId (the originating cron job identifier) is also exposed on the agent-turn context, allowing hooks to scope metrics, side effects, or state to a particular scheduled job. ctx.jobId does not appear in the before_tool_call tool context.

In runs that originate from a channel, ctx.channel and ctx.messageProvider identify the provider surface, for example discord or telegram, while ctx.channelId serves as the conversation target identifier when OpenClaw can infer one from the session key or delivery metadata.

When sender identity is available, agent hook contexts additionally include:

  • ctx.senderId - a sender ID scoped to the channel (e.g. Feishu open_id, Discord user ID). Populated when the run originates from a user message with known sender metadata.
  • ctx.chatId - a conversation identifier native to the transport (e.g. Feishu chat_id, Telegram chat_id). Populated when the originating channel provides a native conversation ID.
  • ctx.channelContext.sender.id - the same sender ID as ctx.senderId, but under a channel-owned object that plugins can extend with channel-specific fields.
  • ctx.channelContext.chat.id - the same conversation ID as ctx.chatId, but under a channel-owned object that plugins can extend with channel-specific fields.

Core defines only the nested id fields. Channel plugins that pass richer sender or chat metadata through the inbound helper can augment PluginHookChannelSenderContext or PluginHookChannelChatContext from openclaw/plugin-sdk/channel-inbound:

declare module "openclaw/plugin-sdk/channel-inbound" {
  interface PluginHookChannelSenderContext {
    unionId?: string;
    userId?: string;
  }
}

Channel plugins pass these fields through the inbound SDK helper:

buildChannelInboundEventContext({
  // ...
  channelContext: {
    sender: { id: senderOpenId, unionId, userId },
    chat: { id: chatId },
  },
});

These fields are optional and are absent for system-originated runs (heartbeat, cron, exec-event).

ctx.senderExternalId remains as a deprecated source-compatibility field for older plugins. Core does not populate it; new channel-specific sender identities should reside under ctx.channelContext.sender through module augmentation.

agent_end is an observation hook. Gateway and persistent harness paths run it fire-and-forget after the turn, while short-lived one-shot CLI paths wait for the hook promise before process cleanup so trusted plugins can flush terminal observability or capture state. The hook runner applies a 30-second timeout so a wedged plugin or embedding endpoint cannot leave the hook promise pending indefinitely. A timeout is logged and OpenClaw continues; it does not cancel plugin-owned network work unless the plugin also uses its own abort signal.

Use model_call_started and model_call_ended for provider-call telemetry that should not receive raw prompts, history, responses, headers, request bodies, or provider request IDs. These hooks include stable metadata such as runId, callId, provider, model, optional api/transport, terminal durationMs/outcome, and upstreamRequestIdHash when OpenClaw can derive a bounded provider request-id hash. When the runtime has resolved context-window metadata, the hook event and context also include contextTokenBudget, the effective token budget after model/config/agent caps, plus contextWindowSource and contextWindowReferenceTokens when a lower cap was applied.

before_agent_finalize runs only when a harness is about to accept a natural final assistant answer. It is not the /stop cancellation path and does not run when the user aborts a turn. Return { action: "revise", reason } to ask the harness for one more model pass before finalization, { action: "finalize", reason? } to force finalization, or omit a result to continue. Handlers have a 15s default budget; on timeout, OpenClaw logs the failure and continues with the original final answer. Codex native Stop hooks are relayed into this hook as OpenClaw before_agent_finalize decisions.

When returning action: "revise", plugins can include retry metadata to make the extra model pass bounded and replay-safe:

type BeforeAgentFinalizeRetry = {
  instruction: string;
  idempotencyKey?: string;
  maxAttempts?: number;
};

instruction is appended to the revision reason sent to the harness. idempotencyKey lets the host count retries for the same plugin request across equivalent finalize decisions, and maxAttempts caps how many extra passes the host will allow before continuing with the natural final answer.

For non-bundled plugins that require raw conversation hooks (before_model_resolve, before_agent_reply, llm_input, llm_output, before_agent_finalize, agent_end, or before_agent_run), the following must be set:

{
  "plugins": {
    "entries": {
      "my-plugin": {
        "hooks": {
          "allowConversationAccess": true
        }
      }
    }
  }
}

Prompt-mutating hooks and durable next-turn injections can be turned off per plugin using plugins.entries.<id>.hooks.allowPromptInjection=false.

Session extensions and next-turn injections

Workflow plugins can persist small JSON-compatible session state with api.session.state.registerSessionExtension(...) and modify it via the Gateway sessions.pluginPatch method. Session rows expose registered extension state through pluginExtensions, which allows Control UI and other clients to display plugin-owned status without needing to understand plugin internals. api.registerSessionExtension(...) remains functional but is deprecated in favor of the api.session.state namespace.

When a plugin needs durable context to reach the next model turn exactly once, use api.session.workflow.enqueueNextTurnInjection(...) (the top-level api.enqueueNextTurnInjection(...) is a deprecated alias with identical behavior). OpenClaw drains queued injections before prompt hooks, discards expired injections, and deduplicates by idempotencyKey per plugin. This is the appropriate mechanism for approval resumes, policy summaries, background monitor deltas, and command continuations that should be visible to the model on the next turn but should not become permanent system prompt text.

Cleanup semantics form part of the contract. Session extension cleanup and runtime lifecycle cleanup callbacks receive reset, delete, disable, or restart. For reset, delete, or disable operations, the host removes the owning plugin's persistent session extension state and pending next-turn injections; restart preserves durable session state while cleanup callbacks allow plugins to release scheduler jobs, run context, and other out-of-band resources for the old runtime generation.

Message hooks

Use message hooks for channel-level routing and delivery policy:

  • message_received: observe inbound content, sender, threadId, messageId, senderId, optional run/session correlation, ordered media, and metadata.
  • message_sending: rewrite content or return { cancel: true }.
  • reply_payload_sending: rewrite normalized ReplyPayload objects (including presentation, delivery, media refs, and text) or return { cancel: true }.
  • message_sent: observe final success or failure.

For audio-only TTS replies, content may contain the hidden spoken transcript even when the channel payload has no visible text or caption. Rewriting that content updates only the hook-visible transcript; it is not rendered as a media caption.

reply_payload_sending events may include usageState, a best-effort live per-turn model, usage, and context snapshot. Durable delivery, recovered replay, and replies without exact run correlation omit it.

Message hook contexts expose stable correlation fields when available: ctx.sessionKey, ctx.runId, ctx.messageId, ctx.senderId, ctx.trace, ctx.traceId, ctx.spanId, ctx.parentSpanId, and ctx.callDepth. Inbound and before_dispatch contexts also expose reply metadata when the channel has visibility-filtered quoted message data: replyToId, replyToIdFull, replyToBody, replyToSender, and replyToIsQuote. Use these first-class fields before reading legacy metadata.

Use typed threadId and replyToId fields before relying on channel-specific metadata.

Inbound claim and message-received events expose media?: PluginHookMediaFact[] as the standard attachment interface. Each fact may carry path, url, contentType, kind, transcribed, messageId, and workspaceDir; the position within the array identifies the attachment. When a remote attachment has not yet been staged locally, media is absent, along with mediaStagingPending: true, and originalMedia holds the provider-side facts. Do not consider originalMedia.path locally readable until a later staged event provides media.

The singular and plural mediaPath, mediaUrl, mediaType, mediaPaths, mediaUrls, mediaTypes, and corresponding originalMedia* metadata properties are deprecated compatibility aliases. New hooks should instead use the typed top-level arrays.

Decision rules:

  • message_sending with cancel: true is terminal.
  • message_sending with cancel: false is interpreted as no decision.
  • A rewritten content passes to lower-priority hooks unless a later hook cancels delivery.
  • reply_payload_sending executes after payload normalization and before channel delivery, including replies routed back to the originating channel. Handlers run sequentially, and each handler sees the most recent payload produced by higher-priority handlers.
  • reply_payload_sending payloads do not expose runtime trust markers like trustedLocalMedia; plugins can modify payload structure but cannot grant local media trust.
  • message_sending can return cancelReason and a bounded metadata along with a cancellation. New message lifecycle APIs expose this as a suppressed delivery outcome with reason cancelled_by_message_sending_hook; legacy direct delivery continues returning an empty result array for compatibility.
  • message_sent is observation only. Handler failures are logged and do not alter the delivery result.

Install hooks

Use security.installPolicy for operator-owned allow or block decisions. That policy runs from OpenClaw configuration, covers CLI install and update paths, and fails closed when enabled but unavailable.

before_install is a plugin-runtime lifecycle hook. It runs after security.installPolicy only in the OpenClaw process where plugin hooks are already loaded, such as Gateway-backed install flows. It is useful for plugin-owned observations, warnings, and compatibility checks, but it is not the primary enterprise or host security boundary for installs. The builtinScan field remains in the event payload for compatibility, but OpenClaw no longer runs built-in install-time dangerous-code blocking, so it returns an empty ok result. Return additional findings or { block: true, blockReason } to stop the install in that process.

block: true is terminal. block: false is treated as no decision. Handler failures block the install and fail closed.

Gateway lifecycle

Use gateway_start to start general plugin services and gateway_stop to clean up long-running resources. The cron scheduler may still be loading when gateway_start runs, so do not use it as the baseline signal for an external cron projection.

Do not rely on the internal gateway:startup hook for plugin-owned runtime services.

cron_reconciled fires after the Gateway cron scheduler and its on-exit watchers have reconciled their durable state. It fires for both initial startup and scheduler replacement during config reload. The event reports reason (startup or reload) and the effective enabled state. Disabled cron still emits with enabled: false, allowing an external projection to clear stale wakes. Use ctx.getCron?.() for the exact scheduler instance that completed reconciliation; a later reload does not retarget that callback. ctx.abortSignal owns that same scheduler snapshot. The Gateway aborts it as soon as a newer scheduler is armed or shutdown starts. Pass it through every durable side effect and do not accept the snapshot after it aborts. This is a scheduler lifecycle signal, not a plugin-activation signal: a plugin-only hot reload does not replay it. A newly enabled consumer receives its first baseline on the next scheduler replacement or Gateway start.

Like other observation hooks, gateway_start and cron_reconciled callbacks can overlap. If both handlers share plugin initialization, coordinate them with a plugin-local readiness promise rather than depending on callback order.

cron_changed is triggered for cron lifecycle events managed by Gateway, carrying a typed event payload that includes added, updated, removed, started, finished, and scheduled reasons. The event contains a PluginHookGatewayCronJob snapshot (which includes state.nextRunAtMs, state.lastRunStatus, and state.lastError when available) along with a PluginHookGatewayCronDeliveryStatus of not-requested | delivered | not-delivered | unknown. Removal events are post-commit: they fire only after a durable deletion succeeds and still include the deleted job snapshot so external schedulers can reconcile their state.

A scheduled event is post-commit: it fires only after a successful durable write modifies an existing job's effective nextRunAtMs, excluding that job's explicit added, updated, or removed lifecycle event. The top-level event.nextRunAtMs is the committed next wake; when absent, the job has no next wake. Treat these events as reconciliation hints rather than an ordered delta log. Use them as coalescible hints to reread the scheduler last captured by cron_reconciled; do not adopt the scheduler from a cron_changed context. Keep OpenClaw as the authoritative source for due checks and execution.

Safe external cron projection

Project a complete wake snapshot instead of forwarding cron event deltas. The external adapter's replaceAll operation must be atomic and idempotent, and must resolve only after the host has durably accepted the snapshot. It must also respect the supplied abort signal: if the signal aborts before durable acceptance, the adapter must not accept that snapshot.

This pattern keeps one latest-state worker in flight. Only cron_reconciled adopts a scheduler instance; cron_changed merely asks that worker to reread the authoritative instance, so a late hint cannot restore an older scheduler. A newer revision aborts the active host attempt before it can accept a stale snapshot.

import { setTimeout as sleep } from "node:timers/promises";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";

type ExternalWake = { jobId: string; runAtMs: number };

type ExternalWakeHost = {
  replaceAll(wakes: readonly ExternalWake[], options: { signal: AbortSignal }): Promise<void>;
  close(): Promise<void>;
};

type CronReader = {
  list(options: { includeDisabled: true }): Promise<
    Array<{
      id: string;
      enabled?: boolean;
      state?: { nextRunAtMs?: number };
    }>
  >;
};

export function registerCronProjection(api: OpenClawPluginApi, host: ExternalWakeHost) {
  const lifecycle = new AbortController();
  let cron: CronReader | undefined;
  let enabled = false;
  let hasBaseline = false;
  let reconciliationSignal: AbortSignal | undefined;
  let requestedRevision = 0;
  let appliedRevision = 0;
  let worker = Promise.resolve();
  let activeAttempt: AbortController | undefined;

  const projectLatest = async () => {
    let retryMs = 1_000;

    while (!lifecycle.signal.aborted && appliedRevision < requestedRevision) {
      const ownerSignal = reconciliationSignal;
      if (!ownerSignal || ownerSignal.aborted) {
        return;
      }
      const targetRevision = requestedRevision;
      const attempt = new AbortController();
      const signal = AbortSignal.any([lifecycle.signal, ownerSignal, attempt.signal]);
      activeAttempt = attempt;

      try {
        const jobs = enabled && cron ? await cron.list({ includeDisabled: true }) : [];
        if (signal.aborted || targetRevision !== requestedRevision) {
          continue;
        }
        const wakes = jobs
          .flatMap((job): ExternalWake[] => {
            const runAtMs = job.enabled === false ? undefined : job.state?.nextRunAtMs;
            return runAtMs === undefined ? [] : [{ jobId: job.id, runAtMs }];
          })
          .sort((a, b) => a.runAtMs - b.runAtMs || a.jobId.localeCompare(b.jobId));

        await host.replaceAll(wakes, { signal });
        if (signal.aborted || targetRevision !== requestedRevision) {
          continue;
        }
        appliedRevision = targetRevision;
        retryMs = 1_000;
      } catch {
        if (lifecycle.signal.aborted || ownerSignal.aborted) {
          return;
        }
        if (attempt.signal.aborted) {
          continue;
        }
        api.logger.warn(`external cron projection failed; retrying in ${retryMs}ms`);
        try {
          await sleep(retryMs, undefined, { signal });
        } catch {
          if (lifecycle.signal.aborted) {
            return;
          }
          if (attempt.signal.aborted) {
            continue;
          }
        }
        retryMs = Math.min(retryMs * 2, 30_000);
      } finally {
        if (activeAttempt === attempt) {
          activeAttempt = undefined;
        }
      }
    }
  };

  const requestProjection = () => {
    const targetRevision = ++requestedRevision;
    activeAttempt?.abort();
    worker = worker.then(async () => {
      if (!lifecycle.signal.aborted && appliedRevision < targetRevision) {
        await projectLatest();
      }
    });
    return worker;
  };

  api.on("cron_reconciled", (event, ctx) => {
    const reconciledCron = ctx.getCron?.();
    if (event.enabled && !reconciledCron) {
      api.logger.warn("cron reconciliation did not expose a scheduler");
      return;
    }
    cron = reconciledCron;
    enabled = event.enabled;
    hasBaseline = true;
    reconciliationSignal = ctx.abortSignal;
    return requestProjection();
  });

  api.on("cron_changed", () => {
    if (hasBaseline) {
      return requestProjection();
    }
  });

  api.on("gateway_stop", async () => {
    lifecycle.abort();
    await worker;
    await host.close();
  });
}

When cron_reconciled reports enabled: false, the same path calls replaceAll([]) and clears stale external wakes. Retry/backoff in this example is process-local and treats runtime adapter failures as transient; validate non-retryable configuration before registration. OpenClaw does not provide an outbox for plugin hook effects. If the process exits before durable acceptance, the next Gateway start emits a new authoritative cron_reconciled snapshot. gateway_stop aborts in-flight host work, waits for the worker to settle, then closes the adapter.

Upcoming deprecations

A few hook-adjacent surfaces are deprecated but still supported. Migrate before the next major release:

  • Plaintext channel envelopes in inbound_claim and message_received handlers. Read BodyForAgent and the structured user-context blocks instead of parsing flat envelope text. See Plaintext channel envelopes → BodyForAgent.
  • subagent_spawning remains for compatibility with older plugins, but new plugins should not return thread routing from it. Core prepares thread: true subagent bindings through channel session-binding adapters before subagent_spawned fires.
  • deactivate remains as a deprecated cleanup compatibility alias until after 2026-08-16. New plugins should use gateway_stop.
  • onResolution in before_tool_call now uses the typed PluginApprovalResolution union (allow-once / allow-always / deny / timeout / cancelled) instead of a free-form string.
  • api.registerSessionExtension / api.enqueueNextTurnInjection remain as top-level compatibility aliases. New plugins should use api.session.state.registerSessionExtension(...) and api.session.workflow.enqueueNextTurnInjection(...).

For the full list - memory capability registration, provider thinking profile, external auth providers, provider discovery types, task runtime accessors, and the command-authcommand-status rename - see Plugin SDK migration → Active deprecations.