Files
openclaw/docs/plugins/hooks.md
2026-08-26 23:12:06 -07:00

71 KiB

summary, title, doc-schema-version, read_when
summary title doc-schema-version read_when
Plugin hooks: intercept agent, tool, message, session, and Gateway lifecycle events Plugin hooks 1
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 let a native OpenClaw plugin observe or change agent runs, tool calls, message delivery, and lifecycle events. Register a typed handler with api.on("hook_name", handler) and return the result documented for that hook.

There are three different hook systems:

You want to… Use
Change prompts, gate tools, customize replies, or integrate plugin lifecycle Typed plugin hooks on this page: api.on("before_tool_call", ...)
Run an operator-installed script for /new, /reset, /stop, or bootstrap events Internal hooks: HOOK.md and colon event names such as command:new or agent:bootstrap
Trigger an agent from an external service over HTTP Webhooks: Gateway HTTP endpoints

Plugins can also register internal hooks with api.registerHook(...). That is not the typed API: registering an underscore name such as before_tool_call there produces a warning, and the typed runner never invokes that registration. Use api.on(...) for every hook in this page's catalog.

Quick start

This example replies to a user message containing hook-demo-check without calling the model. It assumes you already have a working Gateway and can send it a normal chat message. For package metadata, publishing, and install options, see Building plugins and Plugin manifest.

Create a local hook-demo directory with these files:

{
  "name": "hook-demo",
  "version": "1.0.0",
  "type": "module",
  "openclaw": { "extensions": ["./index.ts"] }
}
{
  "id": "hook-demo",
  "name": "Hook Demo",
  "activation": { "onStartup": true },
  "configSchema": { "type": "object", "additionalProperties": false }
}
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";

export default definePluginEntry({
  id: "hook-demo",
  name: "Hook Demo",
  description: "Reply to a hook check without a model call.",
  register(api) {
    api.on(
      "before_agent_reply",
      (event) => {
        if (event.cleanedBody.includes("hook-demo-check")) {
          return { handled: true, reply: { text: "Hook is working." } };
        }
      },
      { eligibleTriggers: ["user"] },
    );
  },
});

Review local plugin code before loading it: native plugins run in the Gateway process. Link and enable the directory (--force acknowledges installing from a local source):

openclaw plugins install --link ./hook-demo --force
openclaw plugins enable hook-demo

Grant this plugin access to conversation hooks in openclaw.json:

{
  "plugins": {
    "entries": {
      "hook-demo": {
        "enabled": true,
        "hooks": { "allowConversationAccess": true }
      }
    }
  }
}

Merge that entry into your existing config, then restart and inspect:

openclaw gateway restart
openclaw plugins inspect hook-demo --runtime --json

Send hook-demo-check as a normal chat message. Expect Hook is working.; other messages continue through the normal agent path. If the hook does not run, see Troubleshooting.

Despite its name, cleanedBody is the prepared run prompt and can contain channel context. The example matches a distinctive marker instead of assuming the field is only the sender's raw text.

Permissions and scope

Hook registration does not bypass plugin loading rules. The plugin must be loaded and enabled; plugins.enabled, plugins.allow, and plugins.deny still apply. Restart the Gateway after changing plugin code or hook configuration.

  • Non-bundled plugins need explicit plugins.entries.<id>.hooks.allowConversationAccess: true for before_model_resolve, agent_turn_prepare, before_prompt_build, before_agent_reply, llm_input, llm_output, before_agent_finalize, agent_end, and before_agent_run. Bundled plugins are allowed unless this option is explicitly false.
  • allowPromptInjection: false blocks agent_turn_prepare, before_prompt_build, heartbeat_prompt_contribution, and durable next-turn injections. It defaults to allowed, but does not grant conversation access. The first two hooks therefore need both permissions.
  • These are specific registration gates, not a sandbox or a universal filter for every hook that can see message data. Install only plugins you trust.

A typed handler receives (event, ctx). The event describes the operation; the second argument carries hook-specific context. Fields such as ctx.agentId, ctx.sessionKey, and ctx.runId are optional on many hooks and may be absent for the emitting path. A registration is not automatically scoped to one agent or session: check the context in your handler when needed.

Read your plugin's resolved settings from api.pluginConfig inside the registration closure. Typed hooks do not receive a universal event.context.pluginConfig field; that field belongs to the internal api.registerHook(...) event contract.

Choose a hook

Task Hook
Reply without a model call before_agent_reply{ handled: true, reply }; omit reply for silence
Add context or narrow tools for a turn before_prompt_build
Gate model input on a supported runner before_agent_run{ outcome: "block", reason, message? }
Block a tool or request approval before_tool_call
Rewrite the full outgoing reply, including media reply_payload_sending
Rewrite outgoing text or cancel a send message_sending
Collect model timing without raw conversation text model_call_started / model_call_ended
Flush state after a turn or at shutdown agent_end / gateway_stop

The catalog is the registration API, not a promise that every runtime emits every hook. For example, before_agent_run is implemented by the embedded and CLI runners; do not rely on it as a Codex or Copilot input gate. Native tool, transcript, and compaction boundaries also differ. See Codex hook boundaries and Agent harness plugins.

Registration and execution

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

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

Kind Execution contract
Modify Sequential; results merge according to the hook's contract below. Returning a rewrite does not generally change the event passed to later handlers.
Claim Sequential; the first { handled: true } wins and skips remaining handlers.
Gate Sequential; a block stops remaining handlers.
Observe Handlers run concurrently; return values are ignored. The emitter may await completion or dispatch fire-and-forget.
Sync modify/gate Synchronous, in priority order; each handler sees the latest message. Promises are ignored with a warning.
Evaluate Skill evaluators run concurrently and produce separate attributed outcomes.

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

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

Option Effect
matcher Non-empty list of canonical OpenClaw tool ids handled by before_tool_call or after_tool_call, such as exec, apply_patch, or spawn_agent. Omit to match all tools. Empty lists, wildcards, blanks, and provider-specific aliases are invalid.
priority Ordering; higher runs first.
registrationId Stable identity for one registration inside a plugin. Skill evaluators use it as evaluatorId; otherwise the plugin id is used.
timeoutMs Per-handler asynchronous await budget. Expiry applies the hook's failure policy below; it does not cancel the handler or its side effects. Omit to use the runner's default, if any.
eligibleTriggers For before_agent_reply only, limits host dispatch to one or more of cron, heartbeat, or user.
requiresToolAuthority For before_prompt_build only, runs the handler after the host finalizes the current turn's tool surface and supplies ephemeral ctx.toolAuthority. Use this for context retrieval that must follow tool policy.

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

Operators can set hook budgets without patching plugin code:

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

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

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

The standard runner applies these defaults per handler:

Hooks Default timeout On thrown error or timeout
before_agent_run, before_tool_call, before_install 15 seconds Fail closed: block the run, tool call, or install
before_agent_finalize, before_prompt_build, message_sending, reply_payload_sending, resolve_exec_env 15 seconds Log and skip the failed handler; retain other successful results
agent_end, before_compaction, after_compaction, skill_changed, skill_proposal_changed 30 seconds Log and continue
channel_pairing_requested 2 seconds Log and continue
gateway_stop 5 seconds Log and continue shutdown
skill_proposal_evaluate 120 seconds Record an attributed error outcome
Other asynchronous hooks, including claim hooks No runner timeout unless configured Log and continue
tool_result_persist, before_message_write No asynchronous timeout Synchronous errors are logged; failed results are ignored

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

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

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

Hook catalog

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

Agent turn

Hook Kind Purpose
before_model_resolve Modify Override provider or model before session messages load
agent_turn_prepare Modify Inspect drained plugin turn injections and add context before prompt hooks
before_prompt_build Modify Add prompt context, narrow the current turn's submitted tools, or perform authorized post-policy enrichment
before_agent_run Gate Inspect the final prompt and session messages before model submission; can block the run
before_agent_reply Claim Short-circuit the model turn with a synthetic reply or silence
before_agent_finalize Modify Inspect the natural final answer and request one more model pass
agent_end Observe Observe final messages, success state, and run duration
heartbeat_prompt_contribution Modify Add heartbeat-only context for background monitor and lifecycle plugins

Conversation observation

Hook Kind Purpose
model_call_started / model_call_ended Observe Sanitized provider/model call metadata: timing, outcome, bounded request-id hashes. No prompt or response content.
llm_input Observe Provider input: system prompt, prompt, history
llm_output Observe Provider output, usage, and the resolved contextTokenBudget when available

Tools

Hook Kind Purpose
before_tool_call Modify / gate Rewrite tool params, block execution, or require approval
after_tool_call Observe Observe tool results, errors, and duration
resolve_exec_env Modify Contribute plugin-owned environment variables to exec
tool_result_persist Sync modify Rewrite a toolResult message before transcript persistence
before_message_write Sync modify / gate Rewrite or block a message before transcript persistence

Messages and delivery

Hook Kind Purpose
inbound_claim Claim Claim an inbound message for the plugin that owns its conversation binding
channel_pairing_requested Observe Observe newly created DM pairing requests
message_received Observe Observe inbound content, sender, thread, and metadata
message_sending Modify / gate Rewrite outbound content or cancel delivery
reply_payload_sending Modify / gate Mutate or cancel normalized reply payloads before delivery
message_sent Observe Observe outbound delivery success or failure
before_dispatch Claim Handle an inbound message before the normal model dispatch
reply_dispatch Claim Own reply generation and dispatch instead of the default model path

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

Sessions and compaction

Hook Kind Purpose
session_start / session_end Observe Track session lifecycle boundaries
before_compaction / after_compaction Observe Observe compaction boundaries; no rewrite or veto result
before_reset Observe Observe session-reset events (/reset, programmatic resets)

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

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

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

Subagents

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

Lifecycle

Hook Kind Purpose
gateway_start / gateway_stop Observe Start or stop plugin-owned services with the Gateway
cron_reconciled Observe Reconcile against the complete Gateway cron state after startup or reload
cron_changed Observe Observe Gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled)
before_install Modify / gate Inspect staged skill or plugin install material from a loaded plugin runtime
skill_proposal_evaluate Evaluate Evaluate one exact Skill Workshop draft and return attributed findings, metrics, or a decision
skill_proposal_changed Observe Observe durable Skill Workshop proposal lifecycle events after they commit
skill_changed Observe Observe committed live-skill create, update, and removal events

Skill lifecycle and evaluation

Use skill_proposal_evaluate for static analyzers, security scanners, benchmarks, model-based graders, or other third-party evaluators. OpenClaw passes an immutable candidate bundle with file hashes and a tree hash. Update proposals also include the complete current skill as baseline. Text files use UTF-8 content; binary files use base64.

Evaluator registrations run concurrently. Give each evaluator a stable 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 evaluation input includes correlationId, OpenClaw forwards it to the evaluator event for both manual and apply-triggered evaluations. This value is caller-supplied correlation metadata, not authenticated identity or proof of authorization. An authorization plugin must mint or replace the value through a trusted entry point, bind it to the intended operation, and validate and consume it itself.

Stored outcomes identify the evaluator, plugin id, plugin package version, status, and returned result. Timeouts and thrown errors are recorded as attributed error outcomes; they do not fail the whole evaluation. Among evaluator outcomes, only a completed decision: "block" vetoes apply. Other Workshop validation and ownership checks still apply. Apply revalidates the evaluated target tree under the Workshop mutation lock, so any live skill asset drift requires reevaluation. The complete persisted evaluation envelope is capped at 512 KiB.

skill_proposal_changed fires after the matching proposal row and append-only lifecycle event commit. It carries the event id, sequence, exact proposal revision hash, optional correlation id, and evaluation outcomes. skill_changed fires after a live skill create, update, or removal commits and includes optional before/after artifacts with content and tree hashes, plus declared and source versions when available.

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 switch provider or model for an agent turn - it runs before model resolution. llm_output describes an attempt's output when the runtime emits it; assistantTexts can be empty and lastAssistant absent, so the event alone does not prove a successful final answer.

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

before_tool_call receives:

  • event.toolName
  • event.params
  • optional event.toolKind and event.toolInputKind, host-authoritative discriminators for tools that intentionally share names; for example, outer code-mode exec calls use toolKind: "code_mode_exec" and include toolInputKind: "javascript" | "typescript" when the input language is known
  • optional event.derivedPaths, best-effort host-derived target path hints for well-known tool envelopes such as apply_patch; these paths may be incomplete or over-approximate what the tool will actually touch (for example, with malformed or partial inputs)
  • optional event.runId
  • optional event.toolCallId
  • context fields such as ctx.agentId, ctx.sessionKey, ctx.sessionId, ctx.runId, ctx.toolKind, ctx.toolInputKind, and diagnostic ctx.trace
  • optional ctx.abortSignal, which aborts when the owning tool call is cancelled; handlers should pass it to cancellable I/O and remove any listeners they register
  • optional ctx.requester, the host-derived requester that initiated the current message run. It can 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.
  • Return params to rewrite host-owned tool parameters. Each handler sees an isolated copy of the original event, not prior returned rewrites. The last returned params wins until an approval is requested.
  • The first requireApproval wins, and its plugin id is stamped by the host. It freezes the selected parameter snapshot: later handlers can block but cannot change the approved parameters.
  • Native tool relays can have narrower contracts. Codex native tools support blocking and observation, but parameter rewrites are rejected; see Codex hook boundaries.
  • 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.

For example, add this inside register(api) to ask before a host-owned exec call. No conversation-access opt-in is needed for before_tool_call:

api.on(
  "before_tool_call",
  () => ({
    requireApproval: {
      title: "Run command",
      description: "Allow this exec tool call?",
      severity: "info",
      timeoutMs: 60_000,
    },
  }),
  { matcher: ["exec"], priority: 50 },
);

Sender-aware policy in one file

A standalone plugin file can keep 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: {
    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"] },
  },
}

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.

Trusted policies may set matcher to the same canonical tool-id list accepted by before_tool_call. Omit the matcher to retain match-all behavior.

Exec environment hook

resolve_exec_env lets plugins contribute environment variables to OpenClaw exec tool invocations before the command runs. It is not a hook for every harness-native shell. 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

Return a Record<string, string> to merge into the exec environment. Handlers run in priority order; later results override earlier results for the same key.

Hook output is filtered through the host exec environment key policy before merging. PATH is always dropped (command resolution and safe-bin checks depend 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 dropped. The filtered plugin env is included in Gateway approval/audit metadata and forwarded to node-host execution requests.

Tool result persistence

tool_result_persist and before_message_write are synchronous hooks. Do not make their handlers async: returned promises are ignored with a warning. Each handler receives the message returned by the previous handler. tool_result_persist returns { message } to replace a tool result; before_message_write can return { message } or { block: true } to prevent that transcript write. Blocking persistence is not a tool-execution veto.

These hooks operate on OpenClaw-owned transcript writes. They do not rewrite Codex-native tool records; see Codex transcript boundaries.

Tool results can include structured details for UI rendering, diagnostics, media routing, or plugin-owned metadata. Treat details as runtime metadata, not prompt content:

  • OpenClaw strips toolResult.details before provider replay and compaction input so metadata does not become model context.
  • Persisted session entries keep only bounded details. Oversized details are replaced with a compact summary and persistedDetailsTruncated: true.
  • tool_result_persist and before_message_write run before the final persistence cap. Keep returned details small and avoid placing prompt-relevant text only in details; put model-visible tool output in content.

Prompt and model hooks

Use the phase-specific hooks for new plugins:

  • 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 queued injections consumed for this session. Return prependContext or appendContext.
  • before_prompt_build: receives the current prompt and session messages. Return prependContext, appendContext, systemPrompt, prependSystemContext, appendSystemContext, or toolsAllow. toolsAllow can only narrow the host-resolved tool surface for the current turn; [] submits no optional tools, while omitting it leaves the existing surface unchanged. Restrictions returned by multiple hooks 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 Codex turn/start has no tool-surface override; use the embedded or Copilot runtime when a plugin requires this policy.
  • before_prompt_build with { requiresToolAuthority: true }: runs 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 returns prependContext or appendContext. Intended for background monitors that need to summarize current state without changing user-initiated turns.

On the embedded and CLI prompt-preparation paths, ordering is: drain queued injections → agent_turn_prepare → heartbeat contribution (if applicable) → ordinary before_prompt_build → finalized tool policy → authorized prompt enrichment. agent_turn_prepare and queued-injection draining are not currently wired into the Codex or Copilot prompt paths.

For multiple registrations, the first defined provider/model override and systemPrompt win. Context additions concatenate in priority order, and tool restrictions intersect. A nested ordinary before_prompt_build dispatch on the same runner is skipped while its outer dispatch is active; other hook families and independent turns remain available.

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.
  • fingerprint is opaque cache-partitioning input. It is not a bearer token or authorization proof; never persist, transmit, or compare it as authority.
  • Return only prependContext or appendContext from this phase. It cannot replace the system prompt or change toolsAllow after 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.

On the embedded and CLI runners, before_agent_run runs after prompt construction and before model submission, including llm_input observation. On the embedded path it also precedes prompt-local image loading. 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.

Hooks that expose event.runId, such as agent_end and before_agent_finalize, receive it when OpenClaw can identify the active run; the same value is also on ctx.runId. Prompt hooks do not all have an event runId field, so use their typed context for correlation. Cron-driven runs can also expose ctx.jobId (the originating cron job id) when supplied by the emitter, so hooks can scope metrics, side effects, or state to a specific scheduled job. Do not assume every agent event carries it. 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 - channel-scoped sender ID (e.g. Feishu open_id, Discord user ID). Populated when the run originates from a user message with known sender metadata.
  • ctx.chatId - transport-native conversation identifier (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, under a channel-owned object plugins can extend with channel-specific fields.
  • ctx.channelContext.chat.id - the same conversation ID as ctx.chatId, under a channel-owned object plugins can extend with channel-specific fields.

Core only defines 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 those fields through the inbound SDK helper:

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

These fields are optional and 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 live under ctx.channelContext.sender through module augmentation.

agent_end is an observation hook. Channel-backed paths generally run it fire-and-forget after the turn, while local one-shot paths can 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 default per-handler timeout so a wedged plugin or embedding endpoint cannot leave the hook promise pending forever. 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 configuration, fixed model contracts, and runtime discovery, plus contextWindowSource and contextWindowReferenceTokens when a lower cap was applied.

These provider-call hooks are currently emitted by the embedded model-call path. A harness exposing llm_input / llm_output does not automatically expose the same provider-call telemetry. In external harnesses, LLM events describe adapter-visible input and output, not necessarily the raw provider request or complete native history.

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 keeps decisions from other handlers. With no revision decision, normal finalization continues. Multiple revise reasons are combined; any finalize decision overrides revision requests. This hook requires a finalization integration: the embedded runner and native hook relay provide it, but the Copilot harness does not currently dispatch it. 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 bound repeated revision requests within a run:

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 across equivalent finalize decisions within a run; without a key, it hashes the instruction. maxAttempts defaults to one extra pass for that key. Use a plugin-specific key to avoid sharing a budget with another plugin. A harness can apply a tighter overall revision limit; the embedded runner allows at most three.

Conversation access and prompt mutation have separate permission gates; see Permissions and scope before enabling these hooks.

Session extensions and next-turn injections

Workflow plugins can persist small JSON-compatible session state with api.session.state.registerSessionExtension(...) and update it through the Gateway sessions.pluginPatch method. Session rows project registered extension state through pluginExtensions, letting Control UI and other clients render plugin-owned status without learning plugin internals. api.registerSessionExtension(...) still works but is deprecated in favor of the api.session.state namespace.

Use api.session.workflow.enqueueNextTurnInjection(...) when a plugin needs durable context queued for the next prompt build (the top-level api.enqueueNextTurnInjection(...) is a deprecated alias with the same behavior). On the embedded and CLI prompt-preparation paths, OpenClaw drains queued injections before prompt hooks. It drops expired entries and entries whose plugin is inactive or has prompt injection disabled. idempotencyKey deduplicates unexpired pending entries for the same plugin and session; the key can be reused after consumption. Drained entries are reused across retries within the active run, but consuming an entry is not a receipt that the model saw it: a later failure can prevent submission. This is the right seam 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 are part of the contract. Session extension cleanup and runtime lifecycle cleanup callbacks receive reset, delete, disable, or restart. The host removes the owning plugin's persistent session extension state and pending next-turn injections for reset/delete/disable; restart keeps durable session state while cleanup callbacks let plugins release scheduler jobs, run context, and other out-of-band resources for the old runtime generation.

Disable cleanup preserves model-locked sessions owned by that plugin's harness. Restart preserves extension state and pending injections, but can clear stale promoted top-level session fields.

Message hooks

For inbound interception, before_dispatch receives the incoming message before ordinary model dispatch. Return { handled: true, text: "..." } to send a final reply, or { handled: true } to handle it without text. This is a claim, not an API for rewriting outbound or inbound content.

reply_dispatch is the advanced takeover seam: it receives the finalized message context and a host dispatcher, and a handled result reports queuedFinal and delivery counts. Use before_agent_reply for a simple synthetic reply, and the sending hooks below to transform outgoing payloads.

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, normalized location, stable providerUpdate identity when supplied by the channel, 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/caption. Rewriting that content updates the hook-visible transcript only; it is not rendered as a media caption.

reply_payload_sending events may include usageState, a best-effort live per-turn model/usage/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. Prefer these first-class fields before reading legacy metadata.

before_dispatch receives the canonical inbound messageId in both its event and context.

Prefer typed threadId and replyToId fields before using channel-specific metadata.

Inbound claim and message-received events expose media?: PluginHookMediaFact[] as the canonical attachment API. Each fact can carry path, url, contentType, kind, transcribed, messageId, and workspaceDir; array position is attachment identity. When a remote attachment has not been staged locally yet, media is omitted, mediaStagingPending: true, and originalMedia contains the provider-side facts. Do not treat originalMedia.path as locally readable until a later staged event supplies media.

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

Decision rules:

  • message_sending with cancel: true is terminal.
  • message_sending with cancel: false is treated as no decision.
  • Each message_sending handler receives the original event content. The last returned content wins; a later handler can still cancel delivery.
  • reply_payload_sending runs after payload normalization and before channel delivery, including replies routed back to the originating channel. Handlers run sequentially and each handler sees the latest payload produced by higher-priority handlers.
  • reply_payload_sending payloads do not expose runtime trust markers such as trustedLocalMedia; plugins can edit payload shape but cannot grant local media trust.
  • message_sending can return cancelReason and bounded metadata with a cancellation. New message lifecycle APIs expose this as a suppressed delivery outcome with reason cancelled_by_message_sending_hook; legacy direct delivery keeps returning an empty result array for compatibility.
  • message_sent is observation-only. Handler failures are logged and do not change the delivery result.

Install hooks

Use 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 can run after security.installPolicy in a process where plugin hooks have already been loaded, such as Gateway-backed install flows. Trusted official and bundled install paths can skip this hook; they still run the operator install policy. 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 is 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 fail-closed.

Gateway lifecycle

Use gateway_start to start general plugin services and gateway_stop to clean up long-running resources. The cron scheduler can still be loading when gateway_start runs, so do not use 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 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 fires for Gateway-owned cron lifecycle events with a typed event payload covering added, updated, removed, started, finished, and scheduled reasons. The event can include a PluginHookGatewayCronJob snapshot (including state.nextRunAtMs, state.lastRunStatus, and state.lastError when present) plus an optional PluginHookGatewayCronDeliveryStatus of not-requested | delivered | not-delivered | unknown. Removed events are post-commit: they fire only after durable deletion succeeds and still carry the deleted job snapshot so external schedulers can reconcile state.

A scheduled event is post-commit: it fires only after a successful durable write changes 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 it is absent, the job has no next wake. Treat these events as reconciliation hints, not 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 source of truth 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 it must resolve only after the host has durably accepted the snapshot. It must also honor 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.

Troubleshooting

Symptom Check
Plugin loads but the handler never runs Use api.on for typed names, inspect openclaw plugins inspect <id> --runtime --json, and check diagnostics for blocked registrations. Runtime inspection loads the plugin in the inspecting process; restart the Gateway too.
Conversation hook is blocked Set plugins.entries.<id>.hooks.allowConversationAccess: true; for prompt hooks, also check that allowPromptInjection is not false. These keys belong under hooks, not the plugin's config.
Hook works for one runtime or trigger only Check the runtime boundary and eligibleTriggers. Missing context fields are not proof of a different sender, agent, or authorization state.
Persistence rewrite has no effect Return { message } synchronously. An async handler's result is ignored.
A timed-out hook still performs work Timeout ends the host's await, not plugin work. Pass available abort signals through I/O and bound plugin-owned work yourself.
One plugin's rewrite disappears Check the hook's merge rule and priority. message_sending uses the last returned content; reply_payload_sending passes each updated payload onward.

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. Prefer typed fields instead of parsing flat envelope text: inbound_claim exposes event.bodyForAgent; message_received exposes event.content and structured metadata, not a BodyForAgent field. See Plaintext channel envelopes → BodyForAgent.
  • 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.