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:

OptionEffect
matcherNon-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.
priorityDetermines ordering; larger values execute first.
registrationIdStable identifier for a single registration inside a plugin. Skill evaluators use it as evaluatorId; otherwise the plugin id applies.
timeoutMsPer-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.
eligibleTriggersFor before_agent_reply only, restricts host dispatch to one or more of cron, heartbeat, or user.
requiresToolAuthorityFor 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

HookPurpose
before_model_resolveOverride provider or model before session messages load
agent_turn_prepareConsume queued plugin turn injections and add same-turn context before prompt hooks
before_prompt_buildAdd prompt context, narrow the current turn's submitted tools, or perform authorized post-policy enrichment
before_agent_runInspect the final prompt and session messages before model submission; can block the run
before_agent_replyShort-circuit the model turn with a synthetic reply or silence
before_agent_finalizeInspect the natural final answer and request one more model pass
agent_endObserve final messages, success state, and run duration
heartbeat_prompt_contributionAdd heartbeat-only context for background monitor and lifecycle plugins

Conversation observation

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

Tools

HookPurpose
before_tool_callChange tool parameters, stop execution, or demand sign-off
after_tool_callWatch tool outcomes, failures, and how long they took
resolve_exec_envAdd plugin-owned environment variables to exec
tool_result_persistModify the assistant message generated from a tool result
before_message_writeReview or halt an ongoing message write (uncommon)

Messages and delivery

HookPurpose
inbound_claimTake ownership of an incoming message for the plugin tied to its conversation binding
channel_pairing_requestedWatch newly created DM pairing requests
message_receivedSee inbound content, sender, thread, and metadata
message_sendingChange outbound content or stop delivery
reply_payload_sendingModify or cancel normalized reply payloads prior to sending
message_sentSee whether outbound delivery succeeded or failed
before_dispatchCheck or alter an outbound dispatch before channel handoff
reply_dispatchJoin 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

HookPurpose
session_start / session_endFollow 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_compactionWatch or annotate compaction cycles
before_resetSee 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_spawned bundles resolvedModel and resolvedProvider.
  • subagent_ended contains targetSessionKey (the identity, which aligns with subagent_spawned.childSessionKey), targetKind (either "subagent" or "acp"), reason, an optional outcome (one of "ok", "error", "timeout", "killed", "reset", or "deleted"), an optional error, runId, endedAt, accountId, and sendFarewell. It excludes agentId and childSessionKey; to match it with the corresponding subagent_spawned event, use targetSessionKey.

Lifecycle

HookPurpose
gateway_start / gateway_stopBegin or halt plugin-managed services alongside the Gateway
cron_reconciledSync with the full Gateway cron state following startup or a reload
cron_changedMonitor cron lifecycle changes owned by the Gateway (additions, updates, removals, starts, completions, schedules)
before_installExamine staged skill or plugin installation content from a loaded plugin runtime
skill_proposal_evaluateAssess a single Skill Workshop draft and return attributed findings, metrics, or a verdict
skill_proposal_changedWatch durable Skill Workshop proposal lifecycle events after they are committed
skill_changedObserve 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.toolName
  • event.params
  • optionally event.toolKind and event.toolInputKind, which act as host-authoritative disambiguators when tools intentionally share names. For instance, outer code-mode exec invocations use toolKind: "code_mode_exec" and pass toolInputKind: "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 like apply_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 diagnostic ctx.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 carry channel, accountId, senderId, senderIsOwner, and provider-specific roleIds. 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: true ends processing and bypasses lower-priority handlers.
  • block: false counts as no decision.
  • params modifies the tool parameters before execution.
  • requireApproval halts the agent run and requests user input through plugin approvals. /approve can grant both exec and plugin approvals. In Codex app-server report-mode native PreToolUse relays, this hands off to the corresponding app-server approval request; refer to Codex harness runtime.
  • A lower-priority block: true can still reject even after a higher-priority hook has already requested approval.
  • onResolution gets the final decision, which is one of allow-once, allow-always, deny, timeout, or cancelled.

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.sessionKey
  • event.toolName, which is "exec" in all current cases
  • event.host, taking one of "gateway", "sandbox", or "node"
  • context fields including ctx.agentId, ctx.sessionKey, ctx.messageProvider, and ctx.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.details before 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 and persistedDetailsTruncated: true take their place.
  • tool_result_persist and before_message_write execute prior to the final persistence cap. Keep returned details small, and don't place prompt-relevant text exclusively in details; model-visible tool output belongs in content.

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 with providerOverride or modelOverride.
  • agent_turn_prepare: receives the prompt, the prepared session messages, and any exactly-once queued injections drained for this session. Reply with prependContext or appendContext.
  • before_prompt_build: takes the prompt and session messages. Return prependContext, appendContext, systemPrompt, prependSystemContext, appendSystemContext, or toolsAllow. toolsAllow can 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 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 }: 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 returns prependContext or appendContext. 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.
  • 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.

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 (Feishu open_id or 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 (Feishu chat_id or Telegram chat_id, for example). It is populated when the originating channel supplies a native conversation ID.
  • ctx.channelContext.sender.id - the same sender ID found in ctx.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 as ctx.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, ordered media, normalized location, stable providerUpdate identity when the channel provides it, and metadata.
  • message_sending: modify content or respond with { cancel: true }.
  • reply_payload_sending: alter normalized ReplyPayload objects (covering presentation, 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_sending combined with cancel: true ends the chain.
  • message_sending paired with cancel: false counts as no decision.
  • A rewritten content passes on to lower-priority hooks unless a subsequent hook cancels delivery.
  • reply_payload_sending executes 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_sending payloads do not expose runtime trust markers such as trustedLocalMedia; plugins may alter payload structure but cannot grant local media trust.
  • message_sending may return cancelReason and a bounded metadata along with a cancellation. New message lifecycle APIs surface this as a suppressed delivery outcome with reason cancelled_by_message_sending_hook; legacy direct delivery continues to return an empty result array for compatibility.
  • message_sent is 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_claim and message_received handlers. Read BodyForAgent and the structured user-context blocks instead of parsing flat envelope text. 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.

6,812 words · updated Aug 25, 2026