Plugin Hooks: Intercept Agent and Gateway Lifecycle Events
Learn how to use plugin hooks in OpenClaw to intercept and modify agent runs, tool calls, messages, sessions, and Gateway startup. This guide is for plugin developers seeking in-process extension points.
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, allowing you to inspect or alter agent runs, tool calls, message flow, session lifecycle, subagent routing, installs, or Gateway startup.
For a small operator-installed HOOK.md script that reacts to command and Gateway events like /new, /reset, /stop, agent:bootstrap, or gateway:startup, use internal hooks instead.
Quick start
From the plugin entry, register typed hooks with api.on(...):
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; handlers with equal priority run in their registration sequence. Observation-only handlers execute concurrently, and fire-and-forget observation dispatches may overlap with subsequent events. Avoid using priority to sequence observation side effects.
api.on(name, handler, opts?) accepts:
| Option | Effect |
|---|---|
matcher | Non-empty list of canonical OpenClaw tool ids that before_tool_call or after_tool_call handle, for example exec, apply_patch, or spawn_agent. Leave out to match every tool. Empty lists, wildcards, blanks, and provider-specific aliases are not permitted. |
priority | Determines ordering; larger values execute first. |
registrationId | Stable identifier for a single registration inside a plugin. Skill evaluators use it as evaluatorId; otherwise the plugin id applies. |
timeoutMs | Per-hook await budget. Once it lapses, OpenClaw stops waiting on that handler and proceeds. The handler and its side effects are not cancelled. Omit to rely on the runner's default per-hook timeout. |
eligibleTriggers | For before_agent_reply only, restricts host dispatch to one or more of cron, heartbeat, or user. |
requiresToolAuthority | For before_prompt_build only, executes the handler after the host finalizes the current turn's tool surface and provides ephemeral ctx.toolAuthority. Suitable for context retrieval that must follow tool policy. |
The host enforces trigger eligibility before invoking 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. Each value must be a
positive integer 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 receives 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.
Policy hooks before_tool_call and before_install use a 15-second default per
handler. A timeout fails closed: the tool call or installation is rejected
instead of continuing without a policy decision.
gateway_stop uses a five-second default per handler. Timed-out handlers are
logged and shutdown continues so plugin cleanup cannot consume the Gateway
process watchdog.
Outbound modifying hooks message_sending and reply_payload_sending use a
15-second default per handler. If one times out, OpenClaw logs the plugin error
and continues with the latest payload so the serialized delivery lane can
settle. Set a larger per-hook budget for plugins that intentionally do slower
work before delivery.
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.
Each hook receives event.context.pluginConfig, the resolved config for the
plugin that registered that handler. OpenClaw injects it per handler without
mutating the shared event object other plugins see.
Hook catalog
Hooks are grouped 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 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 prompt context, narrow the current turn's submitted tools, or perform authorized post-policy enrichment |
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 | Change tool parameters, stop execution, or demand sign-off |
after_tool_call | Watch tool outcomes, failures, and how long they took |
resolve_exec_env | Add plugin-owned environment variables to exec |
tool_result_persist | Modify the assistant message generated from a tool result |
before_message_write | Review or halt an ongoing message write (uncommon) |
Messages and delivery
| Hook | Purpose |
|---|---|
inbound_claim | Take ownership of an incoming message for the plugin tied to its conversation binding |
channel_pairing_requested | Watch newly created DM pairing requests |
message_received | See inbound content, sender, thread, and metadata |
message_sending | Change outbound content or stop delivery |
reply_payload_sending | Modify or cancel normalized reply payloads prior to sending |
message_sent | See whether outbound delivery succeeded or failed |
before_dispatch | Check or alter an outbound dispatch before channel handoff |
reply_dispatch | Join the final reply-dispatch pipeline |
inbound_claim does not act as a global pre-routing broadcast. OpenClaw calls it only for the plugin that owns the message's core-managed conversation binding. To block a standard agent turn before model input while keeping the original prompt out of the transcript, apply before_agent_run. To cut an agent turn short with a generated reply or silence, use before_agent_reply.
Sessions and compaction
| Hook | Purpose |
|---|---|
session_start / session_end | Follow session lifecycle boundaries. reason can be new, reset, idle, daily, compaction, deleted, shutdown, restart, or unknown. shutdown/restart trigger from the Gateway shutdown finalizer when the process stops or restarts with active sessions, letting plugins (memory, transcript stores) close ghost rows rather than leaving them open across restarts. The finalizer is time-bounded, so a slow plugin cannot hold up SIGTERM/SIGINT. |
before_compaction / after_compaction | Watch or annotate compaction cycles |
before_reset | See session-reset events (/reset, programmatic resets) |
Shutdown and restart share a single 2-second total session_end drain budget across all active sessions and plugin handlers; the budget is not per handler. Return promptly or keep finalization bounded and persistence crash-consistent. If the budget runs out, OpenClaw logs session-end-drain timed out and proceeds with shutdown, so unfinished plugin work may be interrupted.
For sessions.create calls with parentSessionKey and emitCommandHooks: true, a distinct child always receives session_start. Callers decide whether the parent also gets terminal session_end via succeedsParent: true indicates successor, false indicates parallel child. Omitting it keeps 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- track when a subagent starts and finishes.subagent_delivery_target- a compatibility hook for delivering completions when no core session binding can map a route.- When OpenClaw determines the child session's native model prior to launch,
subagent_spawnedbundlesresolvedModelandresolvedProvider. subagent_endedcontainstargetSessionKey(the identity, which aligns withsubagent_spawned.childSessionKey),targetKind(either"subagent"or"acp"),reason, an optionaloutcome(one of"ok","error","timeout","killed","reset", or"deleted"), an optionalerror,runId,endedAt,accountId, andsendFarewell. It excludesagentIdandchildSessionKey; to match it with the correspondingsubagent_spawnedevent, usetargetSessionKey.
Lifecycle
| Hook | Purpose |
|---|---|
gateway_start / gateway_stop | Begin or halt plugin-managed services alongside the Gateway |
cron_reconciled | Sync with the full Gateway cron state following startup or a reload |
cron_changed | Monitor cron lifecycle changes owned by the Gateway (additions, updates, removals, starts, completions, schedules) |
before_install | Examine staged skill or plugin installation content from a loaded plugin runtime |
skill_proposal_evaluate | Assess a single Skill Workshop draft and return attributed findings, metrics, or a verdict |
skill_proposal_changed | Watch durable Skill Workshop proposal lifecycle events after they are committed |
skill_changed | Observe committed live-skill creation, modification, and deletion events |
Skill lifecycle and evaluation
For static analyzers, security scanners, benchmarks, model-based graders, or other external evaluators, use skill_proposal_evaluate. OpenClaw provides an immutable candidate bundle that includes file hashes and a tree hash. For update proposals, the complete current skill is also provided as baseline. Text files are delivered in UTF-8; binary files use base64.
Evaluator registrations execute in parallel. Assign each evaluator a consistent registrationId:
api.on(
"skill_proposal_evaluate",
async (event) => {
const score = await evaluateBundle(event.candidate, event.baseline);
return {
evaluatorVersion: "rules-2026-07",
mode: "baseline-comparison",
decision: score.regressed ? "revise" : "pass",
summary: score.summary,
metrics: score.metrics,
findings: score.findings,
};
},
{ registrationId: "quality-regression", timeoutMs: 90_000 },
);
When correlationId appears in evaluation input, OpenClaw passes it to the evaluator event for both manual and apply-triggered evaluations. This value serves as caller-supplied correlation metadata, not as authenticated identity or proof of authorization. An authorization plugin must generate or replace the value via a trusted entry point, tie it to the intended operation, and validate and consume it on its own.
Persisted outcomes identify the evaluator, plugin id, plugin package version, status, and the returned result. Timeouts and thrown errors are logged as attributed error outcomes; they do not cause the entire evaluation to fail. Applying a proposal is prevented only when a completed evaluator returns decision: "block". Apply revalidates the evaluated target tree under the Workshop mutation lock, so any drift in live skill assets requires reevaluation. The combined stored evaluator result is limited to 512 KiB.
skill_proposal_changed triggers after the matching proposal row and append-only lifecycle event are committed. It includes the event id, sequence, exact proposal revision hash, optional correlation id, and evaluation outcomes. skill_changed triggers after a live skill create, update, or removal is committed and includes before/after artifacts with content, tree, declared, and source versions when available.
These hooks are primitives, not an optimization scheduler. A plugin or external controller can observe a durable proposal event, evaluate its exact revision hash, revise with that hash and a correlation id, then repeat. OpenClaw does not automatically revise proposals or run an unbounded evaluation loop. Event replay is byte-bounded and returns nextSequence when another page is 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.
Debug runtime hooks
Use before_model_resolve to change provider or model for an agent turn - it runs before model resolution. llm_output only runs after a model attempt produces assistant output.
For proof of the effective session model, inspect runtime registrations, then use openclaw sessions or the Gateway session/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
The following items are passed to before_tool_call:
event.toolNameevent.params- optionally
event.toolKindandevent.toolInputKind, which act as host-authoritative disambiguators when tools intentionally share names. For instance, outer code-modeexecinvocations usetoolKind: "code_mode_exec"and passtoolInputKind: "javascript" | "typescript"whenever the input language is recognized - optionally
event.derivedPaths, which provides best-effort target path suggestions derived by the host for common tool wrappers likeapply_patch; these paths might be partial or broader than what the tool actually accesses, such as when inputs are malformed or incomplete - optionally
event.runId - optionally
event.toolCallId - contextual fields including
ctx.agentId,ctx.sessionKey,ctx.sessionId,ctx.runId,ctx.toolKind,ctx.toolInputKind, and the diagnosticctx.trace - optionally
ctx.abortSignal, which triggers an abort if the parent tool call gets cancelled; handlers should forward it to cancellable I/O and detach any registered listeners - optionally
ctx.requester, which identifies the host-derived requester behind the current message run. It may carrychannel,accountId,senderId,senderIsOwner, and provider-specificroleIds. Any absent fields are unverified rather than confirmed negatives; fail closed when policy depends on them.
Its return value can be:
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;
};
};
Behavior of guards for typed lifecycle hooks:
block: trueends processing and bypasses lower-priority handlers.block: falsecounts as no decision.paramsmodifies the tool parameters before execution.requireApprovalhalts the agent run and requests user input through plugin approvals./approvecan grant both exec and plugin approvals. In Codex app-server report-mode nativePreToolUserelays, this hands off to the corresponding app-server approval request; refer to Codex harness runtime.- A lower-priority
block: truecan still reject even after a higher-priority hook has already requested approval. onResolutiongets the final decision, which is one ofallow-once,allow-always,deny,timeout, orcancelled.
Sender-aware policy in one file
A standalone plugin file lets you encode deployment-specific policy directly in code, avoiding an extra configuration schema. The sample grants all tools to owners, permits configured maintainers a restricted tool and message-action set, and surfaces /fix to senders already admitted 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, then restart the Gateway:
{
agents: {
entries: {
"maintenance-agent": {
default: true,
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"] },
},
}
The agent bound to the maintenance conversation must be named by AGENT_ID. That binding routes normal messages and /fix to the agent; the standalone file remains the only authority for owner-versus-maintainer tool policy.
requireAuth: true relies on each channel's existing sender admission. On Discord, a guild or channel users/roles allowlist can admit the maintenance audience. Other channels may use stable sender ids. After that, the hook applies the finer per-tool decision to every tool call in the run, including Codex native PreToolUse calls. It can block a tool the model sees, but it cannot introduce a tool the host left out. Sandbox, exec approval, owner-only core-tool, and channel policies still apply; the hook cannot override them.
Scope sender and role ids to a specific channel/account pair as shown; both are provider-local namespaces. Keep the allowlists tight. 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 a missing ctx.requester should pass; the example denies it for the scoped agent.
See Plugin permission requests for
approval routing, decision behavior, and when to prefer requireApproval
over optional tools or exec approvals.
Plugins needing host-level policy can register trusted tool policies through
api.registerTrustedToolPolicy(...). These execute before ordinary
before_tool_call hooks and before standard hook decisions. Bundled trusted
policies run first; installed-plugin trusted policies run next in plugin-load
order; ordinary before_tool_call hooks follow. Bundled plugins keep the
existing trusted-policy path. Installed plugins must be explicitly enabled and
list every policy id in contracts.trustedToolPolicies; undeclared ids
are refused before registration. Policy ids are scoped to the registering
plugin, so different plugins may reuse the same local id. Reserve this tier
for host-trusted gates like workspace policy, budget enforcement, or
protected workflow safety.
Trusted policies can assign matcher the same canonical tool-id list that before_tool_call accepts. If the matcher is omitted, the match-all behavior stays in effect.
Exec environment hook
With resolve_exec_env, plugins are able to add environment variables to exec tool invocations prior to command execution. The following inputs are provided:
event.sessionKeyevent.toolName, which is"exec"in all current casesevent.host, taking one of"gateway","sandbox", or"node"- context fields including
ctx.agentId,ctx.sessionKey,ctx.messageProvider, andctx.channelId
A Record<string, string> should be returned so it can be merged into the exec environment. Handlers execute in priority order, and for any given key, later results take precedence over earlier ones.
Before merging, hook output passes through the host exec environment key policy. PATH is always removed, since command resolution and safe-bin checks rely on it. Also dropped are invalid keys, dangerous host override keys like 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). The filtered plugin environment appears in Gateway approval and audit metadata and gets sent along with node-host execution requests.
Tool result persistence
Tool results may carry structured details intended for UI rendering, diagnostics, media routing, or plugin-owned metadata. Treat details as runtime metadata rather than prompt content:
- OpenClaw removes
toolResult.detailsbefore provider replay and compaction input, so metadata never becomes part of the model context. - Persisted session entries retain only a limited
details. When details grow too large, a compact summary andpersistedDetailsTruncated: truetake their place. tool_result_persistandbefore_message_writeexecute prior to the final persistence cap. Keep returneddetailssmall, and don't place prompt-relevant text exclusively indetails; model-visible tool output belongs incontent.
Prompt and model hooks
For new plugins, rely on the phase-specific hooks:
before_model_resolve: gets only the prompt and attachment metadata at hand. Respond withproviderOverrideormodelOverride.agent_turn_prepare: receives the prompt, the prepared session messages, and any exactly-once queued injections drained for this session. Reply withprependContextorappendContext.before_prompt_build: takes the prompt and session messages. ReturnprependContext,appendContext,systemPrompt,prependSystemContext,appendSystemContext, ortoolsAllow.toolsAllowcan only reduce the host-resolved tool surface for the current turn;[]submits no optional tools, while omitting it keeps the existing surface as is. When multiple hooks return restrictions, they are intersected. The embedded runner and Copilot harness apply this field to their turn-scoped submitted tool surfaces. The Codex app-server harness rejects restrictive values because its dynamic tools are thread-scoped and Codexturn/starthas no tool-surface override; use the embedded or Copilot runtime when a plugin requires this policy.before_prompt_buildwith{ requiresToolAuthority: true }: executes in a second, post-policy phase. Use it when prompt enrichment reads data through a tool-backed capability and the same turn must be allowed to call that tool. See Authorized prompt enrichment.heartbeat_prompt_contribution: runs only for heartbeat turns and returnsprependContextorappendContext. Meant for background monitors that need to summarize current state without altering user-initiated turns.
Authorized prompt enrichment
Register before_prompt_build with requiresToolAuthority: true when a plugin must verify the finalized per-turn tool policy before retrieving context:
api.on(
"before_prompt_build",
async (event, ctx) => {
const authority = ctx.toolAuthority;
if (!authority?.allows("memory_search")) {
return;
}
const recalledContext = await recallForPrompt(event.prompt);
authority.assertActive();
return { prependContext: recalledContext };
},
{ requiresToolAuthority: true },
);
The host excludes this handler from the ordinary prompt-build phase. After all ordinary hooks and tool restrictions settle, a supported runtime invokes it with ctx.toolAuthority bound to that exact active turn and finalized tool surface. Embedded, CLI, Copilot, and Codex runtimes support this phase. If a runtime cannot prove the authority, it skips the handler.
Treat toolAuthority as an ephemeral capability:
allows(toolName)checks a canonical tool id against the finalized surface and also verifies that the capability is still active.assertActive()rejects after abort, cancellation, run replacement, lifecycle rotation, or hook dispatch completion. Call it after awaited work and before committing plugin-owned side effects.fingerprintis opaque cache-partitioning input. It is not a bearer token or authorization proof; never persist, transmit, or compare it as authority.- Return only
prependContextorappendContextfrom this phase. It cannot replace the system prompt or changetoolsAllowafter policy has settled.
The host revalidates authority after each awaited handler and discards stale enrichment. A retained toolAuthority object fails closed after dispatch.
This option requires a host that implements the post-policy phase. Published plugins must set package.json openclaw.compat.pluginApi to a range beginning with the first OpenClaw version they build against for this contract. Older hosts skip incompatible packages during discovery and reject incompatible installs or updates. Do not publish a package that uses this option while claiming compatibility with an older plugin API; an older host may otherwise treat an unknown option as an ordinary pre-policy hook.
before_agent_run runs 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 fail closed.
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 retained in 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 including agent_end include event.runId when OpenClaw can identify the active run; the same value is also on ctx.runId. Cron-driven runs also expose ctx.jobId (the originating cron job id) on the agent-turn context so hooks can scope metrics, side effects, or state to a specific scheduled job. ctx.jobId is not part of the before_tool_call tool context.
For channel-originated runs, ctx.channel and ctx.messageProvider identify the provider surface such as discord or telegram, while ctx.channelId is the conversation target identifier when OpenClaw can derive one from the session key or delivery metadata.
When sender identity is available, agent hook contexts also include:
ctx.senderId- a sender identifier scoped to the channel (Feishuopen_idor Discord user IDs, for instance). This gets filled in when the run starts from a user message that carries known sender metadata.ctx.chatId- a conversation identifier native to the transport (Feishuchat_idor Telegramchat_id, for example). It is populated when the originating channel supplies a native conversation ID.ctx.channelContext.sender.id- the same sender ID found inctx.senderId, placed inside a channel-owned object that plugins can extend with fields specific to that channel.ctx.channelContext.chat.id- the same conversation ID asctx.chatId, stored under a channel-owned object that plugins can enrich with channel-specific fields.
The nested id fields are the only ones defined by Core. When channel plugins pass richer sender or chat metadata through the inbound helper, they can extend PluginHookChannelSenderContext or PluginHookChannelChatContext using data from openclaw/plugin-sdk/channel-inbound:
declare module "openclaw/plugin-sdk/channel-inbound" {
interface PluginHookChannelSenderContext {
unionId?: string;
userId?: string;
}
}
Channel plugins forward these fields via the inbound SDK helper:
buildChannelInboundEventContext({
// ...
channelContext: {
sender: { id: senderOpenId, unionId, userId },
chat: { id: chatId },
},
});
These fields are optional and are missing for runs that originate from the system itself, such as heartbeat, cron, or exec-event.
ctx.senderExternalId stays around as a deprecated field for source compatibility with older plugins. Core never populates it; new channel-specific sender identities belong under ctx.channelContext.sender through module augmentation.
agent_end serves as an observation hook. Gateway and persistent harness paths execute it fire-and-forget after the turn completes, whereas short-lived one-shot CLI paths await the hook promise before process cleanup, letting trusted plugins flush terminal observability or capture state. The hook runner enforces a 30 second timeout so a stuck plugin or embedding endpoint cannot keep the hook promise pending indefinitely. A timeout gets logged and OpenClaw moves on; it does not cancel network work owned by the plugin unless the plugin itself supplies an abort signal.
For provider-call telemetry that must not receive raw prompts, history, responses, headers, request bodies, or provider request IDs, use model_call_started and model_call_ended. These hooks carry stable metadata like runId, callId, provider, model, optional api/transport, terminal durationMs/outcome, and upstreamRequestIdHash whenever OpenClaw can compute a bounded provider request-id hash. Once the runtime has resolved context-window metadata, the hook event and context also include contextTokenBudget, the effective token budget after model configuration, fixed model contracts, and runtime discovery, plus contextWindowSource and contextWindowReferenceTokens when a lower cap has been applied.
before_agent_finalize fires only when a harness is about to accept a natural final assistant answer. It is not the /stop cancellation path and does not trigger when the user aborts a turn. Return { action: "revise", reason } to request one more model pass from the harness before finalization, { action: "finalize", reason? } to force finalization, or omit a result to proceed. Handlers get a 15s default budget; if that times out, OpenClaw logs the failure and proceeds with the original final answer. Codex native Stop hooks are relayed into this hook as OpenClaw before_agent_finalize decisions.
When action: "revise" is returned, plugins may include retry metadata to keep the extra model pass bounded and replay-safe:
type BeforeAgentFinalizeRetry = {
instruction: string;
idempotencyKey?: string;
maxAttempts?: number;
};
instruction gets 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 permit before continuing with the natural final answer.
Non-bundled plugins that need raw conversation hooks (before_model_resolve, agent_turn_prepare, before_prompt_build, before_agent_reply, llm_input, llm_output, before_agent_finalize, agent_end, or before_agent_run) must set:
{
"plugins": {
"entries": {
"my-plugin": {
"hooks": {
"allowConversationAccess": true
}
}
}
}
}
agent_turn_prepare and before_prompt_build also alter prompt construction, so they demand conversation access and remain bound by plugins.entries.<id>.hooks.allowPromptInjection. Per plugin, prompt-mutating hooks and durable next-turn injections can be disabled by setting that option to false.
Session extensions and next-turn injections
Workflow plugins can persist compact JSON-compatible session state using api.session.state.registerSessionExtension(...), with updates applied through the Gateway's sessions.pluginPatch method. Registered extension state is projected into session rows via pluginExtensions, so Control UI and other clients can render plugin-owned status without needing to understand the plugin's internals. Although api.registerSessionExtension(...) remains functional, it is deprecated, and the api.session.state namespace is now the recommended approach.
When a plugin needs durable context to reach the next model turn exactly once, api.session.workflow.enqueueNextTurnInjection(...) is the appropriate choice; the top-level api.enqueueNextTurnInjection(...) serves as a deprecated alias with identical behavior. OpenClaw flushes queued injections before prompt hooks, discards any that have expired, and removes duplicates using idempotencyKey on a per-plugin basis. This seam fits approval resumes, policy summaries, background monitor deltas, and command continuations that must be visible to the model on the following turn without being baked into the permanent system prompt.
Cleanup behavior forms part of the contract. Callbacks for session extension cleanup and runtime lifecycle cleanup receive reset, delete, disable, or restart. For reset, delete, or disable operations, the host clears the owning plugin's persistent session extension state and pending next-turn injections; a restart preserves durable session state while cleanup callbacks give plugins the chance to release scheduler jobs, run context, and other out-of-band resources tied to the old runtime generation.
Message hooks
For channel-level routing and delivery policy, message hooks are the tool:
message_received: inspect inbound content, sender,threadId,messageId,senderId, optional run/session correlation, orderedmedia, normalizedlocation, stableproviderUpdateidentity when the channel provides it, and metadata.message_sending: modifycontentor respond with{ cancel: true }.reply_payload_sending: alter normalizedReplyPayloadobjects (coveringpresentation,delivery, media refs, and text) or respond with{ cancel: true }.message_sent: observe the final outcome, whether success or failure.
For audio-only TTS replies, content can hold the hidden spoken transcript even when the channel payload carries no visible text or caption. Modifying that content only changes the transcript visible to hooks; it does not become a rendered media caption.
Events from reply_payload_sending may include usageState, a best-effort live snapshot of per-turn model, usage, and context. Durable delivery, recovered replay, and replies lacking exact run correlation will omit this field.
When available, message hook contexts expose stable correlation fields: ctx.sessionKey, ctx.runId, ctx.messageId, ctx.senderId, ctx.trace, ctx.traceId, ctx.spanId, ctx.parentSpanId, and ctx.callDepth. Inbound and before_dispatch contexts additionally expose reply metadata when the channel provides visibility-filtered quoted message data: replyToId, replyToIdFull, replyToBody, replyToSender, and replyToIsQuote. These first-class fields should be used before falling back to legacy metadata.
before_dispatch receives the canonical inbound messageId in both its event and its context.
Typed threadId and replyToId fields take priority over channel-specific metadata.
Inbound claim and message-received events present media?: PluginHookMediaFact[] as the standard attachment interface. Every 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, mediaStagingPending: true, and originalMedia holds the provider-side details. Avoid reading originalMedia.path as local data until a later staged event provides media.
The singular and plural mediaPath, mediaUrl, mediaType, mediaPaths, mediaUrls, mediaTypes, and corresponding originalMedia* metadata properties are deprecated aliases kept for compatibility. New hooks should rely on the typed top-level arrays.
Decision rules:
message_sendingcombined withcancel: trueends the chain.message_sendingpaired withcancel: falsecounts as no decision.- A rewritten
contentpasses on to lower-priority hooks unless a subsequent hook cancels delivery. reply_payload_sendingexecutes after payload normalization and before channel delivery, including replies sent back to the source channel. Handlers run one after another, and each handler observes the latest payload produced by higher-priority handlers.reply_payload_sendingpayloads do not expose runtime trust markers such astrustedLocalMedia; plugins may alter payload structure but cannot grant local media trust.message_sendingmay returncancelReasonand a boundedmetadataalong with a cancellation. New message lifecycle APIs surface this as a suppressed delivery outcome with reasoncancelled_by_message_sending_hook; legacy direct delivery continues to return an empty result array for compatibility.message_sentis observation-only. Handler errors get logged and do not alter the delivery result.
Install hooks
Apply security.installPolicy for operator-owned allow/warn/block decisions. That policy runs from OpenClaw config, 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 have already been loaded, such as Gateway-backed install flows. It suits plugin-owned observations, warnings, and compatibility checks, but it is not the primary enterprise or host security boundary for installs. The builtinScan field stays in the event payload for compatibility, yet OpenClaw no longer performs built-in install-time dangerous-code blocking, so it is an empty ok result. Return additional findings or { block: true, blockReason } to halt the install in that process.
block: true is terminal. block: false counts as no decision. Handler failures block the install fail-closed.
Gateway lifecycle
Use gateway_start to launch general plugin services and gateway_stop to release long-running resources. The cron scheduler may still be loading when gateway_start runs, so do not treat it as the baseline signal for an external cron projection.
The legacy api.on("deactivate", ...) alias was removed in August 2026. Use gateway_stop for cleanup; see the migration note.
Do not depend 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 triggers for cron lifecycle events owned by Gateway, delivering a typed event payload that covers added, updated, removed, started, finished, and scheduled reasons. Included in the event is a PluginHookGatewayCronJob snapshot, which contains state.nextRunAtMs, state.lastRunStatus, and state.lastError when available, along with a PluginHookGatewayCronDeliveryStatus of not-requested | delivered | not-delivered | unknown. Events for removed items happen after the commit: they fire only once durable deletion has succeeded, and they still include the deleted job's snapshot so external schedulers can sync their state.
An event of type scheduled is also post-commit: it fires only after a durable write has succeeded in altering an existing job's effective nextRunAtMs, while excluding that job's explicit added, updated, or removed lifecycle event. The top-level event.nextRunAtMs holds the committed next wake; if it's missing, the job has no upcoming wake. Treat these events as hints for reconciliation rather than an ordered delta log. Use them as coalescible hints to reread the scheduler last captured by cron_reconciled; never adopt the scheduler from a cron_changed context. Let OpenClaw remain the authority for due checks and execution.
Safe external cron projection
Instead of forwarding cron event deltas, project a complete wake snapshot. The external adapter's replaceAll operation must be atomic and idempotent, and it 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 a single 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. 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
- Migrating to the Plugin SDK: current deprecations and the schedule for removal
- How to build plugins
- Plugin SDK at a glance
- Where plugins start executing
- Hooks used internally
- Inside the plugin architecture