---
summary: "Plugin hooks: intercept agent, tool, message, session, and Gateway lifecycle events"
title: "Plugin hooks"
read_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 are in-process extension points for OpenClaw plugins: inspect or
change agent runs, tool calls, message flow, session lifecycle, subagent
routing, installs, or Gateway startup.

Use [internal hooks](/automation/hooks) instead for a small operator-installed
`HOOK.md` script reacting to command and Gateway events such as `/new`,
`/reset`, `/stop`, `agent:bootstrap`, or `gateway:startup`.

## Quick start

Register typed hooks with `api.on(...)` from the plugin entry:

```typescript
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 can return decisions or modifications run sequentially in
descending `priority`; same-priority handlers keep registration order.
Observation-only handlers run in parallel, and fire-and-forget observation
dispatches can overlap with later events. Do not use priority to order
observation side effects.

`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-hook await budget. When it expires, OpenClaw stops awaiting that handler and moves on. It does not cancel the handler or its side effects. Omit to use the runner's default per-hook timeout.                                                      |
| `eligibleTriggers` | For `before_agent_reply` only, limits host dispatch to one or more of `cron`, `heartbeat`, or `user`.                                                                                                                                                  |

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:

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

`hooks.timeouts.<hookName>` overrides `hooks.timeoutMs`, which overrides the
plugin-authored `api.on(..., { timeoutMs })` value. Each value must be a
positive integer up to 600000 ms. Prefer per-hook overrides for known-slow
hooks so one plugin does not get a longer budget everywhere.

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

Policy hooks `before_tool_call` and `before_install` use a 15-second default per
handler. A timeout fails closed: the tool call or installation is rejected
instead of continuing without a policy decision.

`gateway_stop` uses a five-second default per handler. Timed-out handlers are
logged and shutdown continues so plugin cleanup cannot consume the Gateway
process watchdog.

Outbound modifying hooks `message_sending` and `reply_payload_sending` use a
15-second default per handler. If one times out, OpenClaw logs the plugin error
and continues with the latest payload so the serialized delivery lane can
settle. Set a larger per-hook budget for plugins that intentionally do slower
work before delivery.

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

Each hook receives `event.context.pluginConfig`, the resolved config for the
plugin that registered that handler. OpenClaw injects it per handler without
mutating the shared event object other plugins see.

## Hook catalog

Hooks are grouped by the surface they extend. **Bold** names accept a decision
result (block, cancel, override, or require approval); the rest are
observation-only.

**Agent turn**

| Hook                            | Purpose                                                                                  |
| ------------------------------- | ---------------------------------------------------------------------------------------- |
| `before_model_resolve`          | Override provider or model before session messages load                                  |
| `agent_turn_prepare`            | Consume queued plugin turn injections and add same-turn context before prompt hooks      |
| `before_prompt_build`           | Add prompt context or narrow the current turn's submitted tool surface                   |
| **`before_agent_run`**          | Inspect the final prompt and session messages before model submission; can block the run |
| **`before_agent_reply`**        | Short-circuit the model turn with a synthetic reply or silence                           |
| **`before_agent_finalize`**     | Inspect the natural final answer and request one more model pass                         |
| `agent_end`                     | Observe final messages, success state, and run duration                                  |
| `heartbeat_prompt_contribution` | Add heartbeat-only context for background monitor and lifecycle plugins                  |

**Conversation observation**

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

**Tools**

| Hook                       | Purpose                                                   |
| -------------------------- | --------------------------------------------------------- |
| **`before_tool_call`**     | Rewrite tool params, block execution, or require approval |
| `after_tool_call`          | Observe tool results, errors, and duration                |
| `resolve_exec_env`         | Contribute plugin-owned environment variables to `exec`   |
| **`tool_result_persist`**  | Rewrite the assistant message produced from a tool result |
| **`before_message_write`** | Inspect or block an in-progress message write (rare)      |

**Messages and delivery**

| Hook                        | Purpose                                                                    |
| --------------------------- | -------------------------------------------------------------------------- |
| **`inbound_claim`**         | Claim an inbound message for the plugin that owns its conversation binding |
| `channel_pairing_requested` | Observe newly created DM pairing requests                                  |
| `message_received`          | Observe inbound content, sender, thread, and metadata                      |
| **`message_sending`**       | Rewrite outbound content or cancel delivery                                |
| **`reply_payload_sending`** | Mutate or cancel normalized reply payloads before delivery                 |
| `message_sent`              | Observe outbound delivery success or failure                               |
| **`before_dispatch`**       | Inspect or rewrite an outbound dispatch before channel handoff             |
| **`reply_dispatch`**        | Participate in the final reply-dispatch pipeline                           |

`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`. To short-circuit an agent
turn with a synthetic reply or silence, use `before_agent_reply`.

**Sessions and compaction**

| Hook                                     | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `session_start` / `session_end`          | Track session lifecycle boundaries. `reason` is one of `new`, `reset`, `idle`, `daily`, `compaction`, `deleted`, `shutdown`, `restart`, or `unknown`. `shutdown`/`restart` fire from the Gateway shutdown finalizer when the process stops or restarts with active sessions, so plugins (memory, transcript stores) can finalize ghost rows instead of leaving them open across restarts. The finalizer is bounded so a slow plugin cannot block SIGTERM/SIGINT. |
| `before_compaction` / `after_compaction` | Observe or annotate compaction cycles                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `before_reset`                           | Observe session-reset events (`/reset`, programmatic resets)                                                                                                                                                                                                                                                                                                                                                                                                     |

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 `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_delivery_target` - compatibility hook for completion delivery when no core session binding can project a route.
- `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                             | Purpose                                                                                              |
| -------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `gateway_start` / `gateway_stop` | Start or stop plugin-owned services with the Gateway                                                 |
| `cron_reconciled`                | Reconcile against the complete Gateway cron state after startup or reload                            |
| `cron_changed`                   | Observe Gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled) |
| **`before_install`**             | Inspect staged skill or plugin install material from a loaded plugin runtime                         |
| **`skill_proposal_evaluate`**    | Evaluate one exact Skill Workshop draft and return attributed findings, metrics, or a decision       |
| `skill_proposal_changed`         | Observe durable Skill Workshop proposal lifecycle events after they commit                           |
| `skill_changed`                  | 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`:

```typescript
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. Applying a
proposal is blocked only when a completed evaluator returns
`decision: "block"`. Apply revalidates the evaluated target tree under the
Workshop mutation lock, so any live skill asset drift requires reevaluation.
The combined persisted evaluator result 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 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.

```typescript
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` 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

`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:

```typescript
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.
- `params` rewrites the tool parameters for execution.
- `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](/plugins/codex-harness-runtime#hook-boundaries).
- 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`.

### 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:

```typescript
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:

```json5
{
  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](/plugins/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 `exec`
tool invocations before the command runs. 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 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 any exactly-once queued injections drained 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.
- `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.

`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` - 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`:

```ts
declare module "openclaw/plugin-sdk/channel-inbound" {
  interface PluginHookChannelSenderContext {
    unionId?: string;
    userId?: string;
  }
}
```

Channel plugins pass those fields through the inbound SDK helper:

```ts
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. Gateway and persistent harness paths run
it fire-and-forget after the turn, while short-lived one-shot CLI paths wait
for the hook promise before process cleanup so trusted plugins can flush
terminal observability or capture state. The hook runner applies a 30 second
timeout so a wedged plugin or embedding endpoint cannot leave the hook promise
pending 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.

`before_agent_finalize` runs only when a harness is about to accept a natural
final assistant answer. It is not the `/stop` cancellation path and does not
run when the user aborts a turn. Return `{ action: "revise", reason }` to ask
the harness for one more model pass before finalization, `{ action:
"finalize", reason? }` to force finalization, or omit a result to continue.
Handlers have a 15s default budget; on timeout, OpenClaw logs the failure and
continues with the original final answer.
Codex native `Stop` hooks are relayed into this hook as OpenClaw
`before_agent_finalize` decisions.

When returning `action: "revise"`, plugins can include `retry` metadata to
make the extra model pass bounded and replay-safe:

```typescript
type BeforeAgentFinalizeRetry = {
  instruction: string;
  idempotencyKey?: string;
  maxAttempts?: number;
};
```

`instruction` is appended to the revision reason sent to the harness.
`idempotencyKey` lets the host count retries for the same plugin request
across equivalent finalize decisions, and `maxAttempts` caps how many extra
passes the host will allow before continuing with the natural final answer.

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:

```json
{
  "plugins": {
    "entries": {
      "my-plugin": {
        "hooks": {
          "allowConversationAccess": true
        }
      }
    }
  }
}
```

`agent_turn_prepare` and `before_prompt_build` also mutate prompt construction,
so they require conversation access and remain subject to
`plugins.entries.<id>.hooks.allowPromptInjection`. Prompt-mutating hooks and
durable next-turn injections can be disabled per plugin by setting that option
to `false`.

### 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 to reach the next model turn exactly once (the top-level
`api.enqueueNextTurnInjection(...)` is a deprecated alias with the same
behavior). OpenClaw drains queued injections before prompt hooks, drops
expired injections, and deduplicates by `idempotencyKey` per plugin. 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.

## Message hooks

Use message hooks for channel-level routing and delivery policy:

- `message_received`: observe inbound content, sender, `threadId`,
  `messageId`, `senderId`, optional run/session correlation, 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.
- Rewritten `content` continues to lower-priority hooks unless a later hook
  cancels 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 runs after
`security.installPolicy` only in the OpenClaw process where plugin hooks have
already been loaded, such as Gateway-backed install flows. It is useful for
plugin-owned observations, warnings, and compatibility checks, but it is not
the primary enterprise or host security boundary for installs. The
`builtinScan` field remains in the event payload for compatibility, but
OpenClaw no longer runs built-in install-time dangerous-code blocking, so it
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](/plugins/sdk-migration#deactivate-hook-alias).

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 carries a `PluginHookGatewayCronJob`
snapshot (including `state.nextRunAtMs`, `state.lastRunStatus`, and
`state.lastError` when present) plus a `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.

```typescript
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](/plugins/sdk-migration#removal-timeline).
- **`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](/plugins/sdk-migration#removal-timeline).

## Related

- [Plugin SDK migration](/plugins/sdk-migration) - active deprecations and removal timeline
- [Building plugins](/plugins/building-plugins)
- [Plugin SDK overview](/plugins/sdk-overview)
- [Plugin entry points](/plugins/sdk-entrypoints)
- [Internal hooks](/automation/hooks)
- [Plugin architecture internals](/plugins/architecture-internals)
