mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 21:24:46 +00:00
Add optional context budget/source/reference metadata to plugin hook contexts plus llm_output and sanitized model_call_* hook events. Thread the existing resolved context-window info through Pi embedded runs, CLI harness runs, and Codex app-server hook emission so plugins can observe the effective budget after agent/model/config caps. Document the metadata and cover the CLI, Pi, Codex app-server, and model-call paths with focused tests. Fixes #64327.
457 lines
20 KiB
Markdown
457 lines
20 KiB
Markdown
---
|
|
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
|
|
---
|
|
|
|
Plugin hooks are in-process extension points for OpenClaw plugins. Use them
|
|
when a plugin needs to inspect or change agent runs, tool calls, message flow,
|
|
session lifecycle, subagent routing, installs, or Gateway startup.
|
|
|
|
Use [internal hooks](/automation/hooks) instead when you want a small
|
|
operator-installed `HOOK.md` script for command and Gateway events such as
|
|
`/new`, `/reset`, `/stop`, `agent:bootstrap`, or `gateway:startup`.
|
|
|
|
## Quick start
|
|
|
|
Register typed plugin hooks with `api.on(...)` from your 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,
|
|
timeoutBehavior: "deny",
|
|
},
|
|
};
|
|
},
|
|
{ priority: 50 },
|
|
);
|
|
},
|
|
});
|
|
```
|
|
|
|
Hook handlers run sequentially in descending `priority`. Same-priority hooks
|
|
keep registration order.
|
|
|
|
`api.on(name, handler, opts?)` accepts:
|
|
|
|
- `priority` - handler ordering (higher runs first).
|
|
- `timeoutMs` - optional per-hook budget. When set, the hook runner aborts that
|
|
handler after the budget elapses and continues with the next one, instead of
|
|
letting slow setup or recall work consume the caller's configured model
|
|
timeout. Omit it to use the default observation/decision timeout that the
|
|
hook runner applies generically.
|
|
|
|
Operators can also 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 configured value must
|
|
be a positive integer no greater than 600000 milliseconds. Prefer per-hook
|
|
overrides for known slow hooks so one plugin does not get a longer budget
|
|
everywhere.
|
|
|
|
Each hook receives `event.context.pluginConfig`, the resolved config for the
|
|
plugin that registered that handler. Use it for hook decisions that need
|
|
current plugin options; OpenClaw injects it per handler without mutating the
|
|
shared event object seen by other plugins.
|
|
|
|
## Hook catalog
|
|
|
|
Hooks are grouped by the surface they extend. Names in **bold** accept a
|
|
decision result (block, cancel, override, or require approval); all others are
|
|
observation-only.
|
|
|
|
**Agent turn**
|
|
|
|
- `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 dynamic context or system-prompt text before the model call
|
|
- `before_agent_start` - compatibility-only combined phase; prefer the two hooks above
|
|
- **`before_agent_run`** - inspect the final prompt and session messages before model submission and optionally 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**
|
|
|
|
- `model_call_started` / `model_call_ended` - observe sanitized provider/model call metadata, timing, outcome, and bounded request-id hashes without prompt or response content
|
|
- `llm_input` - observe provider input (system prompt, prompt, history)
|
|
- `llm_output` - observe provider output, usage, and the resolved `contextTokenBudget` when available
|
|
|
|
**Tools**
|
|
|
|
- **`before_tool_call`** - rewrite tool params, block execution, or require approval
|
|
- `after_tool_call` - observe tool results, errors, and duration
|
|
- **`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**
|
|
|
|
- **`inbound_claim`** - claim an inbound message before agent routing (synthetic replies)
|
|
- `message_received` - observe inbound content, sender, thread, and metadata
|
|
- **`message_sending`** - rewrite outbound content or cancel 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
|
|
|
|
**Sessions and compaction**
|
|
|
|
- `session_start` / `session_end` - track session lifecycle boundaries. The event's `reason` is one of `new`, `reset`, `idle`, `daily`, `compaction`, `deleted`, `shutdown`, `restart`, or `unknown`. The `shutdown` and `restart` values fire from the gateway shutdown finalizer when the process is stopped or restarted while sessions are still active, so downstream plugins (such as memory or transcript stores) can finalize ghost rows that would otherwise be left in an open state 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)
|
|
|
|
**Subagents**
|
|
|
|
- `subagent_spawning` / `subagent_delivery_target` / `subagent_spawned` / `subagent_ended` - coordinate subagent routing and completion delivery
|
|
|
|
**Lifecycle**
|
|
|
|
- `gateway_start` / `gateway_stop` - start or stop plugin-owned services with the Gateway
|
|
- `cron_changed` - observe gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled)
|
|
- **`before_install`** - inspect skill or plugin install scans and optionally block
|
|
|
|
## Debug runtime hooks
|
|
|
|
Use `before_model_resolve` when a plugin needs to switch the 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. When debugging
|
|
provider payloads, start the Gateway with `--raw-stream` and
|
|
`--raw-stream-path <path>`; those flags write raw model stream events to a jsonl
|
|
file.
|
|
|
|
## Tool call policy
|
|
|
|
`before_tool_call` receives:
|
|
|
|
- `event.toolName`
|
|
- `event.params`
|
|
- optional `event.derivedPaths`, containing best-effort host-derived target path
|
|
hints for well-known tool envelopes such as `apply_patch`; when present,
|
|
these paths may be incomplete or may 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.jobId` (set on cron-driven runs), and diagnostic `ctx.trace`
|
|
|
|
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;
|
|
timeoutBehavior?: "allow" | "deny";
|
|
pluginId?: string;
|
|
onResolution?: (
|
|
decision: "allow-once" | "allow-always" | "deny" | "timeout" | "cancelled",
|
|
) => Promise<void> | void;
|
|
};
|
|
};
|
|
```
|
|
|
|
Hook 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. The `/approve` command can approve both exec and plugin approvals.
|
|
- A lower-priority `block: true` can still block after a higher-priority hook
|
|
requested approval.
|
|
- `onResolution` receives the resolved approval decision - `allow-once`,
|
|
`allow-always`, `deny`, `timeout`, or `cancelled`.
|
|
|
|
Bundled plugins that need host-level policy can register trusted tool policies
|
|
with `api.registerTrustedToolPolicy(...)`. These run before ordinary
|
|
`before_tool_call` hooks and before external plugin decisions. Use them only
|
|
for host-trusted gates such as workspace policy, budget enforcement, or
|
|
reserved workflow safety. External plugins should use normal `before_tool_call`
|
|
hooks.
|
|
|
|
### 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. Hooks should still 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`, or `appendSystemContext`.
|
|
- `heartbeat_prompt_contribution`: runs only for heartbeat turns and returns
|
|
`prependContext` or `appendContext`. It is intended for background monitors
|
|
that need to summarize current state without changing user-initiated turns.
|
|
|
|
`before_agent_start` remains for compatibility. Prefer the explicit hooks above
|
|
so your plugin does not depend on a legacy combined phase.
|
|
|
|
`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 can read the prompt. `reason` is internal;
|
|
`message` is the user-facing replacement. The only supported outcomes are
|
|
`pass` and `block`; 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.
|
|
|
|
`before_agent_start` and `agent_end` include `event.runId` when OpenClaw can
|
|
identify the active run. The same value is also available on `ctx.runId`.
|
|
Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
|
|
plugin hooks can scope metrics, side effects, or state to a specific scheduled
|
|
job.
|
|
|
|
For channel-originated runs, `ctx.messageProvider` is 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.
|
|
|
|
`agent_end` is an observation hook and runs fire-and-forget after the turn. 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/config/agent caps, 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.
|
|
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`,
|
|
`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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Prompt-mutating hooks and durable next-turn injections can be disabled per plugin
|
|
with `plugins.entries.<id>.hooks.allowPromptInjection=false`.
|
|
|
|
### Session extensions and next-turn injections
|
|
|
|
Workflow plugins can persist small JSON-compatible session state with
|
|
`api.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.
|
|
|
|
Use `api.enqueueNextTurnInjection(...)` when a plugin needs durable context to
|
|
reach the next model turn exactly once. 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, and metadata.
|
|
- `message_sending`: rewrite `content` 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.
|
|
|
|
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`. Prefer
|
|
these first-class fields before reading legacy metadata.
|
|
|
|
Prefer typed `threadId` and `replyToId` fields before using channel-specific
|
|
metadata.
|
|
|
|
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.
|
|
- `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
|
|
|
|
`before_install` runs after the built-in scan for skill and plugin installs.
|
|
Return additional findings or `{ block: true, blockReason }` to stop the
|
|
install.
|
|
|
|
`block: true` is terminal. `block: false` is treated as no decision.
|
|
|
|
## Gateway lifecycle
|
|
|
|
Use `gateway_start` for plugin services that need Gateway-owned state. The
|
|
context exposes `ctx.config`, `ctx.workspaceDir`, and `ctx.getCron?.()` for
|
|
cron inspection and updates. Use `gateway_stop` to clean up long-running
|
|
resources.
|
|
|
|
Do not rely on the internal `gateway:startup` hook for plugin-owned runtime
|
|
services.
|
|
|
|
`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 still carry the deleted job snapshot so external schedulers can
|
|
reconcile state. Use `ctx.getCron?.()` and `ctx.config` from the runtime
|
|
context when syncing external wake schedulers, and keep OpenClaw as the
|
|
source of truth for due checks and execution.
|
|
|
|
## 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#active-deprecations).
|
|
- **`before_agent_start`** remains for compatibility. New plugins should use
|
|
`before_model_resolve` and `before_prompt_build` instead of the combined
|
|
phase.
|
|
- **`onResolution` in `before_tool_call`** now uses the typed
|
|
`PluginApprovalResolution` union (`allow-once` / `allow-always` / `deny` /
|
|
`timeout` / `cancelled`) instead of a free-form `string`.
|
|
|
|
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#active-deprecations).
|
|
|
|
## 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)
|