openclaw/docs/plugins/hooks.md
Peter Steinberger 3a342c5701
feat: expose final worker prompts to admission hooks (#182142)
Cloud-worker turns bypassed the Gateway's before_agent_run plugin hooks, so plugins could not see or adjust the final system prompt and input of turns executed on a worker. Workers now request Gateway turn admission before running: the Gateway prepares the hook input itself (never trusting worker-supplied event content), runs the hooks with their normal per-hook deadlines, and binds the approval to the exact turn claim. Persistence, proxied inference, Gateway tools and live events all require that approval; cancelled runs still settle their terminal receipt without approved worker content. The worker allows up to 10 minutes for admission because sequential hooks with 15 s deadlines can exceed the default 30 s RPC timeout. Workers without the admission protocol feature keep the prior path.
2026-10-23 21:53:46 -07:00

19 KiB

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 the hook 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 let the default hybrid reload mode apply it and inspect:

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. Run openclaw plugins reload <id> after changing plugin code. With the default hybrid reload mode, hook policy changes hot-reload the existing plugin runtime.

  • 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, before_agent_run, and before_channel_ingress. Bundled plugins are allowed unless this option is explicitly false.
  • session_end remains available as a metadata-only lifecycle hook without that grant. Its bounded ctx.endedTranscript reader is available only when the effective conversation-access policy allows it; see the session lifecycle contract.
  • 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.

Incognito sessions do not dispatch llm_input or llm_output observations. Their agent_end hooks still receive run identity, success, and duration for cleanup and settlement, but receive empty messages and no error text. Policy, provider, approval, and explicitly invoked tool hooks remain active. This boundary does not sandbox plugins or disable native harness telemetry.

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. By default in hybrid reload mode, editing plugins.entries.<id>.config replaces the plugin instance and reruns registration with the new settings.

Choose a hook

Task Hook
Reply without a model call before_agent_reply → { handled: true, reply }; omit reply for silence
Sanitize a channel event before durable ingress before_channel_ingress → { event }
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 and by Gateway admission for OpenClaw worker turns. Workers with the worker-turn-admission-v1 capability report their final system prompt after completing host runtime facts and skill references. The Gateway supplies its authoritative prepared triggering prompt, history, channel, account, sender, and run identity to the plugin gate; worker-provided prompt and history are ignored. Approval is bound to the active turn claim and is required for persistence, Gateway-proxied inference, Gateway tools, and content-bearing live events. The worker also waits for approval before worker-local inference. Blocks and hook failures persist only the redacted block message; terminal cleanup receipts omit worker-provided content before approval.

The Gateway cannot verify host-local system prompt bytes: a buggy or compromised worker can misreport systemPrompt. Plugins must not treat that field as a trust boundary or use it as proof of worker behavior.

Older workers retain Gateway admission before the user message is persisted or the worker launches. That path omits systemPrompt because the worker completes it afterward. Policies that require final system context must reject an absent systemPrompt or use an updated worker or a supported local runner. Do not rely on this hook as a Codex or Copilot input gate. Native tool, transcript, and compaction boundaries also differ. See Codex hook boundaries and Agent harness plugins.

Before durable channel ingress

Check api.runtime.capabilities?.includes("channel-ingress-transform-v1") before registering before_channel_ingress. A plugin that requires pre-storage privacy must refuse unsupported hosts and verify that its handler is registered; an absent or permission-blocked handler does not protect intake. Existing openclaw plugins inspect <id> --runtime --json reports registered typedHooks.

The handler receives { event } and { channelId, accountId, signal }. event is the provider's raw transport value, not a normalized chat message. Validate its shape in the policy plugin, read current settings through api.runtime.config.current(), and return { event: replacement }; void or an omitted event keeps the preceding value. Higher-priority handlers run first, and each handler receives an isolated copy of the latest replacement. Do not mutate retained values after returning them.

Discord emits this hook inside ordered admission before serializing or writing its durable queue. Other channel plugins can opt in through the ingress monitor preparation contract. The channel revalidates provider identity and lane after transformation. Errors, timeouts (15 seconds by default), cancellation, and retired plugin authority refuse intake; they never fall back to storing the original event. Error diagnostics omit policy exception contents because they may contain private payloads. Pass signal into policy I/O and bound plugin-owned work.

The hook does not rewrite existing queue rows or run again when an already sanitized row is claimed. Direct queue callers retain their own preparation contract. It runs before inbound_claim, message_received, and transcript hooks, which cannot protect the raw durable ingress payload.

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; use openclaw plugins reload <id> after code changes.
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. Removal eligibility is tracked per surface in the plugin compatibility registry, as a removeAfter date or an explicit removal gate, not at a major-version boundary. Migrate now:

  • 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-auth → command-status rename - see Plugin SDK migration → Active deprecations.

Where each section moved

Every section of the single-page version now lives on this page or on one of the five child pages below. The anchors from the single-page version still resolve here.

Hook reference

Hook reference — Registration rules, execution contracts, per-handler budgets, and the complete typed hook catalog.

Tool call policy hooks

Tool call policy hooks — Parameter rewrites, blocks, approvals, exec environment contributions, and transcript persistence.

Prompt and session hooks

Prompt and session hooks — Model resolution, prompt construction, finalization, and durable plugin-owned session state.

Message and delivery hooks

Message and delivery hooks — Inbound interception, reply takeover, and outbound delivery policy.

Gateway and install lifecycle hooks

Gateway and install lifecycle hooks — Install-time checks, Gateway service lifecycle, and safe external cron projection.