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:
| Option | Effect |
|---|---|
priority | Determines ordering; a higher value runs first. |
timeoutMs | Sets 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
| Hook | Purpose |
|---|---|
before_model_resolve | Override the provider or model before session messages load |
agent_turn_prepare | Consume queued plugin turn injections and add same-turn context before prompt hooks |
before_prompt_build | Add dynamic context or system-prompt text before the model call |
before_agent_run | Inspect the final prompt and session messages before model submission; can block the run |
before_agent_reply | Short-circuit the model turn with a synthetic reply or silence |
before_agent_finalize | Inspect the natural final answer and request one more model pass |
agent_end | Observe final messages, success state, and run duration |
heartbeat_prompt_contribution | Add heartbeat-only context for background monitor and lifecycle plugins |
Conversation observation
| Hook | Purpose |
|---|---|
model_call_started / model_call_ended | Sanitized provider/model call metadata: timing, outcome, bounded request-id hashes. No prompt or response content. |
llm_input | Provider input: system prompt, prompt, history |
llm_output | Provider output, usage, and the resolved contextTokenBudget when available |
Tools
| Hook | Purpose |
|---|---|
before_tool_call | Rewrite tool params, block execution, or require approval |
after_tool_call | Observe tool results, errors, and duration |
resolve_exec_env | Contribute plugin-owned environment variables to exec |
tool_result_persist | Rewrite the assistant message produced from a tool result |
before_message_write | Inspect or block an in-progress message write (rare) |
Messages and delivery
| Hook | Purpose |
|---|---|
inbound_claim | Claim an inbound message before agent routing (synthetic replies) |
channel_pairing_requested | Observe newly created DM pairing requests |
message_received | Observe inbound content, sender, thread, and metadata |
message_sending | Rewrite outbound content or cancel delivery |
reply_payload_sending | Mutate or cancel normalized reply payloads before delivery |
message_sent | Observe outbound delivery success or failure |
before_dispatch | Inspect or rewrite an outbound dispatch before channel handoff |
reply_dispatch | Participate in the final reply-dispatch pipeline |
Sessions and compaction
| Hook | Purpose |
|---|---|
session_start / session_end | Monitor 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_compaction | Watch or tag compaction cycles |
before_reset | Track 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. Beforesubagent_spawnedtriggers, the core now preparesthread: truesubagent bindings using channel session-binding adapters.subagent_spawnedcontainsresolvedModelandresolvedProvideronce OpenClaw has determined the child session's native model prior to launch.subagent_endedholdstargetSessionKey(identity, matchingsubagent_spawned.childSessionKey),targetKind(either"subagent"or"acp"),reason, an optionaloutcome(which can be"ok","error","timeout","killed","reset", or"deleted"), an optionalerror,runId,endedAt,accountId, andsendFarewell. It does not includeagentIdorchildSessionKey; usetargetSessionKeyto correlate with the correspondingsubagent_spawnedevent.
Lifecycle
| Hook | Purpose |
|---|---|
gateway_start / gateway_stop | Start or stop services owned by the plugin alongside the Gateway |
deactivate | A deprecated compatibility alias for gateway_stop; in new plugins, use gateway_stop |
cron_reconciled | Reconcile against the full Gateway cron state after a startup or reload |
cron_changed | Monitor lifecycle changes for Gateway-owned crons (added, updated, removed, started, finished, scheduled) |
before_install | Inspect 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.toolNameevent.params- optionally,
event.toolKindandevent.toolInputKindact as host-authoritative discriminators for tools that intentionally share names; for instance, outer code-modeexeccalls usetoolKind: "code_mode_exec"and includetoolInputKind: "javascript" | "typescript"when the input language is known - optionally,
event.derivedPathsprovides best-effort host-derived target path hints for well-known tool envelopes likeapply_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 diagnosticctx.trace - optionally,
ctx.requesteris the host-derived requester that started the current message run. It may includechannel,accountId,senderId,senderIsOwner, and provider-nativeroleIds. 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: trueis terminal and skips lower-priority handlers.block: falseis treated as no decision.paramsrewrites the tool parameters for execution.requireApprovalpauses the agent run and asks the user through plugin approvals./approvecan approve both exec and plugin approvals. In Codex app-server report-mode nativePreToolUserelays, this defers to the matching app-server approval request; see Codex harness runtime.- A lower-priority
block: truecan still block after a higher-priority hook requested approval. onResolutionreceives the resolved decision:allow-once,allow-always,deny,timeout, orcancelled.
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.sessionKeyevent.toolName, currently always"exec"event.host, one of"gateway","sandbox", or"node"- context fields such as
ctx.agentId,ctx.sessionKey,ctx.messageProvider, andctx.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.detailsso metadata does not become part of the model context. - Persisted session entries retain only bounded
details. Large details are replaced with a compact summary andpersistedDetailsTruncated: true. tool_result_persistandbefore_message_writeexecute before the final persistence cap. Keep returneddetailssmall and avoid putting prompt-relevant text exclusively indetails; place model-visible tool output incontent.
Prompt and model hooks
For new plugins, use the phase-specific hooks:
before_model_resolve: receives only the current prompt and attachment metadata. ReturnproviderOverrideormodelOverride.agent_turn_prepare: receives the current prompt, prepared session messages, and any exactly-once queued injections drained for this session. ReturnprependContextorappendContext.before_prompt_build: receives the current prompt and session messages. ReturnprependContext,appendContext,systemPrompt,prependSystemContext, orappendSystemContext.heartbeat_prompt_contribution: runs only for heartbeat turns and returnsprependContextorappendContext. 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. Feishuopen_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. Feishuchat_id, Telegramchat_id). Populated when the originating channel provides a native conversation ID.ctx.channelContext.sender.id- the same sender ID asctx.senderId, but under a channel-owned object that plugins can extend with channel-specific fields.ctx.channelContext.chat.id- the same conversation ID asctx.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, orderedmedia, and metadata.message_sending: rewritecontentor return{ cancel: true }.reply_payload_sending: rewrite normalizedReplyPayloadobjects (includingpresentation,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_sendingwithcancel: trueis terminal.message_sendingwithcancel: falseis interpreted as no decision.- A rewritten
contentpasses to lower-priority hooks unless a later hook cancels delivery. reply_payload_sendingexecutes 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_sendingpayloads do not expose runtime trust markers liketrustedLocalMedia; plugins can modify payload structure but cannot grant local media trust.message_sendingcan returncancelReasonand a boundedmetadataalong with a cancellation. New message lifecycle APIs expose this as a suppressed delivery outcome with reasoncancelled_by_message_sending_hook; legacy direct delivery continues returning an empty result array for compatibility.message_sentis 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_claimandmessage_receivedhandlers. ReadBodyForAgentand the structured user-context blocks instead of parsing flat envelope text. See Plaintext channel envelopes → BodyForAgent. subagent_spawningremains for compatibility with older plugins, but new plugins should not return thread routing from it. Core preparesthread: truesubagent bindings through channel session-binding adapters beforesubagent_spawnedfires.deactivateremains as a deprecated cleanup compatibility alias until after 2026-08-16. New plugins should usegateway_stop.onResolutioninbefore_tool_callnow uses the typedPluginApprovalResolutionunion (allow-once/allow-always/deny/timeout/cancelled) instead of a free-formstring.api.registerSessionExtension/api.enqueueNextTurnInjectionremain as top-level compatibility aliases. New plugins should useapi.session.state.registerSessionExtension(...)andapi.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-auth → command-status rename - see Plugin SDK migration → Active deprecations.
Related
- Plugin SDK migration - current deprecations and removal schedule
- Creating plugins
- Plugin SDK summary
- Plugin entry points
- Internal hooks
- Plugin architecture internals