Internal Hooks for Automation in OpenClaw
Learn how to use internal hooks to persist session state, log commands, and handle message and session lifecycle events. This guide is for developers who want lightweight automation without writing full plugins.
Read this when
- You want event-driven automation for /new, /reset, /stop, or session and Gateway events
- You want to write, install, enable, or debug an internal hook
- You need to understand hook discovery, event data, or reply delivery
Hooks
Internal hooks are compact JavaScript or TypeScript functions that execute inside the Gateway process whenever OpenClaw fires an event. They are handy for persisting session state, recording reset commands, or carrying out brief side effects tied to message and session lifecycles. OpenClaw ships with prebuilt hooks for frequent scenarios, so writing a plugin is unnecessary.
Choose the right surface
| Goal | Approach |
|---|---|
Store context on /new, log commands, or respond to session and message events | Internal hooks (HOOK.md plus a handler), covered here |
| Change prompts, intercept tools, control responses, or use lifecycle contracts with priorities and return values | Plugin hooks via api.on(...) |
| Have another service begin work through an HTTP request | Webhooks |
| Output telemetry without altering behavior | Diagnostic events |
These mechanisms are distinct. hooks.internal sets up the event handlers on this page; hooks.enabled sets up HTTP ingress. Internal event names like message:received are not typed plugin names such as message_received.
Warning
Internal hooks run as trusted code, not sandboxed scripts. They have the same filesystem, network, and environment access as the Gateway process. Always review hook code before enabling it, particularly code from a workspace or a downloaded package.
Quick start
Begin with command-logger: it requires no extra binaries or model calls and leaves you with a concrete file to examine. Execute these commands on the Gateway host, using the same profile and config as that Gateway:
openclaw hooks list
openclaw hooks info command-logger
openclaw hooks enable command-logger
openclaw gateway restart
gateway restart targets an installed Gateway service. If the Gateway runs in the foreground, stop and start that process instead. Add --agent <id> to hook commands when your setup has multiple agents and no implicit owner.
Within a conversation you can safely reset, send /new or /reset as an authorized user. Then check the log on the Gateway host:
tail -n 5 ~/.openclaw/logs/commands.log
Search for a fresh JSON line containing "action":"new" or "action":"reset", a recent timestamp, and that conversation's sessionKey. With a custom state directory, read <stateDir>/logs/commands.log instead. This confirms that a handler executed; openclaw hooks check alone does not.
The log holds session and sender identifiers. If you do not want to keep those records, turn off the hook after testing it:
openclaw hooks disable command-logger
openclaw gateway restart
Eligible, enabled, and loaded
Treat these three checks as separate:
- Requirements satisfied: the hook's OS, binaries, environment, and config requirements pass on the host performing the check.
- Enabled by config: the per-hook/source policy permits it. Workspace hooks need explicit opt-in; bundled and managed hooks skip that per-hook flag when broad discovery is on.
- Loaded: the running Gateway chose the hook, imported its handler, and registered its events. This also depends on the master switch and configured name selection allowing it.
The CLI's ready, eligible, and loadable fields cover the first two checks plus a nonempty event list. They do not confirm that the Gateway imported the handler, that the global selection includes it, or that its event has fired. After changes, restart and verify the actual side effect or hook-specific log.
Local, remote, and agent scope
hooks list, info, and check ask the selected Gateway for its inventory. An implicit local Gateway may fall back to local discovery when unavailable or when it lacks the report method. A configured remote Gateway or explicit OPENCLAW_GATEWAY_URL does not fall back to your laptop's hooks on failure.
hooks enable and hooks disable always inspect and modify local config.
They do not update a remote Gateway over RPC. Run them on the Gateway host to change that host's hooks.
--agent <id> selects the workspace to inspect, not an isolated hook registry.
The saved hooks.internal.entries.<hookKey> entry is global. Gateway startup
loads directory hooks from its startup workspace into a process-wide registry; it does not load every agent's hooks/ directory merely because you inspected
it. A loaded handler must filter the event's agent or session when it should only act for a particular agent. See Hook discovery.
Writing hooks
This example responds to a reset command and writes a fixed log marker. It does not read message content, call a model, or contact an external service.
Hook structure
On the Gateway host, use a new managed hook directory. The following commands assume the default state directory and that reset-greeting does not already exist; choose another name rather than overwrite an existing hook.
mkdir -p ~/.openclaw/hooks/reset-greeting
cat > ~/.openclaw/hooks/reset-greeting/HOOK.md <<'HOOK'
---
name: reset-greeting
description: "Confirm that a reset hook ran"
metadata:
{ "openclaw": { "events": ["command:new", "command:reset"] } }
---
# Reset greeting
Send a short confirmation after an authorized reset command.
HOOK
cat > ~/.openclaw/hooks/reset-greeting/handler.js <<'HANDLER'
export default function handler(event) {
if (event.type !== "command" || !["new", "reset"].includes(event.action)) {
return;
}
console.log("[reset-greeting] reset hook ran");
event.messages.push("Reset hook ran.");
}
HANDLER
A hook needs HOOK.md and a handler file. Discovery checks, in order, handler.ts, handler.js, index.ts, then index.js, using the first file it finds. The example uses JavaScript so no TypeScript types or SDK imports are needed.
Enable and load it:
openclaw hooks info reset-greeting
openclaw hooks enable reset-greeting
openclaw gateway restart
Send /new in a disposable conversation on a configured chat channel that can route replies, such as a direct message to the bot. Expect Reset hook ran. in that conversation and [reset-greeting] reset hook ran in Gateway logs. /reset triggers the same example. Normal command authorization still applies.
Use an ordinary OpenClaw conversation, not an ACP-bound thread; bound sessions delegate reset handling to their owning runtime. Do not use Control UI/webchat or a sessions.reset RPC as the chat-reply check: those paths do not deliver this hook's event.messages to the UI. The log marker can still show that a reset event ran. See Reply delivery for the exact boundary.
Disable the example when finished:
openclaw hooks disable reset-greeting
openclaw gateway restart
Disabling does not delete the files. If you prefer a workspace directory, place both files in <workspace>/hooks/reset-greeting/ and turn the hook on explicitly. Storing hooks in a workspace does not create an agent sandbox, nor does it ensure the Gateway will load that workspace's hooks.
Handler implementation
A handler exposes a function that yields void or Promise<void>. Unless metadata.openclaw.export designates a different export, the loader relies on the default one. Whatever the handler returns, the operation proceeds without being blocked, cancelled, or altered.
Every event includes the following fields:
| Field | Meaning |
|---|---|
type | Family: command, session, agent, gateway, or message |
action | Action within the family, such as new or compact:before |
sessionKey | Session correlation key; Gateway events use a Gateway key instead |
timestamp | JavaScript Date when the event object was created |
context | Event-specific data described under Event context highlights |
messages | Initially empty string array; only certain producers consume it as replies |
View context as a snapshot for inspection, not a live API for editing state. Fields differ depending on the producer, and cfg may be absent from certain events. Patch events, for instance, carry cloned snapshots. The one explicit mutable exception is agent:bootstrap's context.bootstrapFiles.
Reply delivery
Writing to event.messages is not a general-purpose way to send messages:
| Producer | What happens to event.messages |
|---|---|
Chat command handling for /new and /reset | Awaits handlers, joins strings with blank lines, and attempts a reply to the originating channel/recipient, preserving account and thread context |
Gateway session reset/create RPCs that emit command:new or command:reset | Handlers run, but messages are not routed as chat replies |
session:compact:before and session:compact:after | Forwarded to the caller's compaction-notice callback when present; that callback owns delivery |
| All other core events | Ignored as replies, including /stop, automatic reset, message events, bootstrap, patch, and Gateway lifecycle events |
A reply can fail due to a missing recipient, an unsupported route, a send policy, or a delivery error. Make sure messages are appended before the handler's promise settles; work that pushes later, after detachment, may miss the producer's delivery step. For controlling ordinary agent replies or cancelling sends, use the proper typed plugin hook.
HOOK.md format
HOOK.md relies on YAML frontmatter followed by Markdown meant for humans:
---
name: my-hook
description: "Short description of what this hook does"
homepage: https://example.com/my-hook
metadata:
{ "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
---
# My Hook
Explain the side effects, configuration, and verification steps here.
name falls back to the directory name; pick a unique, stable value.
description appears in reports. Under metadata.openclaw, these fields belong:
| Field | Contract |
|---|---|
events | Array of event keys. A handler cannot be registered unless at least one is supplied. |
export | Name of the exported function; default is what it falls back to. |
hookKey | Key for the config entry; the hook name serves as the default. When discovery runs into collisions, the hook name is still what gets used. |
emoji | Emoji shown in the display. |
homepage | Link to documentation; takes precedence over the top-level homepage, website, or url. |
os | Node platform names that are permitted, such as darwin, linux, or win32. |
requires.bins | PATH must contain every executable that is named. |
requires.anyBins | At least one of the named executables has to be located on PATH. |
requires.env | For each named variable, either a nonblank process value or a per-hook env value is required. |
requires.config | Every dotted config path has to evaluate to something truthy. |
always | Skips the binary, environment, and config checks, but the OS and enablement policy still apply. |
install | Descriptors that carry install info only: kind can be bundled, npm, or git; id, label, package, repository, and bins are optional. This metadata neither installs dependencies nor makes Git specs acceptable to the CLI. |
Activation is controlled through hooks.internal.entries.<hookKey>.enabled, not a top-level enabled flag inside HOOK.md. When the historical requirement metadata is missing, workspace.dir, browser.enabled, and browser.evaluateEnabled are treated as true. You do not need to introduce workspace.dir as a fresh config setting.
Configuration
To get a deterministic selection, opt for named hooks instead of enabling discovery across the board:
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"command-logger": { "enabled": true },
"session-memory": { "enabled": false }
}
}
}
}
The master switch and the rules that govern directory-loaded hook selection are:
| Configuration | Selection |
|---|---|
hooks.internal.enabled: false | Internal hooks are disabled. |
| No master flag, no enabled entries, no extra directories, and no tracked installs | The gateway does not load directory hooks. |
| Named entries present, master flag absent or set to true | The enabled names act as an allowlist; setting enabled: true on the master does not expand it. A name is contributed by any entry that lacks enabled: false. |
| Master flag true, with neither named entries nor named installs | Discovery of eligible hooks runs without restriction. |
| Tracked hook packs that declare hook names | Those names get added to the selection; an explicit per-hook enabled: false still turns off a hook that is not a plugin. |
load.extraDirs is nonempty, or a tracked install has no hook-name list | Discovery is open-ended, not limited to that directory or pack. |
Workspace hooks require entries.<hookKey>.enabled: true at all times, even when discovery is open-ended. For other file hooks, selection can happen by name or by hookKey, but settings are read under hookKey. The CLI figures out the name and writes the proper key on your behalf. Introducing the first named entry can tighten a selection that was previously broad; check the existing hooks before you make that change.
Handler-defined fields are accepted freely in per-hook entries. The core types treat enabled as a boolean and env as a map from string to string; custom handler options are not validated. As an illustration:
{
"hooks": {
"internal": {
"entries": {
"my-hook": {
"enabled": true,
"env": { "MY_HOOK_LABEL": "example" }
}
}
}
}
}
A per-hook env passes the eligibility checks but leaves process.env untouched. When an event carries config, a handler can access it through event.context.cfg?.hooks?.internal?.entries?.["my-hook"]?.env. A cfg field is not guaranteed on other events. Avoid logging full config objects and keep secrets out of examples.
Warning
hooks.internal.handlershas been retired and fails standard config validation. Before you runopenclaw doctor --fix, move every registered module into a managed or workspace hook directory usingHOOK.mdalong with a handler. Doctor clears the old registrations but does not generate executable files. For a config that is legacy-only withhooks.internal.enabled: true, that flag is also removed so broad discovery does not happen. Named entries, nonempty extra directories, and explicitenabled: falsestay intact.
Hook discovery
Hook discovery combines directories by name according to the following rules:
| Source | Location and collision behavior |
|---|---|
| Bundled | Delivered with OpenClaw. |
| Plugin | Hook folders from active plugins; these may override bundled names. |
| Managed | <stateDir>/hooks/, usually ~/.openclaw/hooks/; may override bundled and plugin names. |
| Extra directories | hooks.internal.load.extraDirs; follows the same source policy as managed hooks. Later extra folders take precedence over earlier ones; the managed folder wins over extra folders. |
| Workspace | <workspace>/hooks/; may introduce new names but cannot override bundled, plugin, or managed names. Explicit opt-in is necessary. |
Bundled, managed, workspace, and plugin hook locations are collection
folders: discovery examines their immediate children for hooks or packages
whose package.json specifies openclaw.hooks.
Each explicit hooks.internal.load.extraDirs path can instead serve as a pack root,
a single-hook root, or a collection folder. A pack root loads only its
declared hook paths, including nested paths like ./hooks/my-hook. Each
path must point directly to a hook; discovery does not descend into another
pack or collection. A recognized pack with no valid hooks remains empty rather
than scanning unlisted children. A single-hook root loads its own HOOK.md
and handler. Only an ordinary collection root gets the immediate-child scan.
For instance, to pick /opt/openclaw-hook-library/my-hook/HOOK.md directly,
add that hook's folder:
{
"hooks": {
"internal": {
"load": {
"extraDirs": ["/opt/openclaw-hook-library/my-hook"]
}
}
}
}
To scan the library's immediate children instead, add
/opt/openclaw-hook-library. Only add trusted folders: any extra path
opens hook-name selection across discovery sources beyond named entries,
even when that path selects a single hook or pack.
Handler files must remain inside their hook folder; package and plugin hook
paths must remain inside their package root. Symlinks escaping those boundaries
are rejected. Restart after changing hook files, metadata, or configuration,
then confirm the handler's real side effect; inventory alone does not prove execution.
Hook packs
A hook pack is a package whose package.json declares hook folders in
openclaw.hooks. Install a reviewed package or local directory through the
unified installer:
openclaw plugins install <path-or-spec>
Installation and update flags, npm restrictions, linked-root behavior and trust, and
the deprecated hooks install / hooks update aliases are documented in
Install and update hook packs.
Bundled hooks
| Hook | Events | Purpose |
|---|---|---|
boot-md | gateway:startup | Run workspace BOOT.md instructions at startup. |
bootstrap-extra-files | agent:bootstrap | Add matching workspace bootstrap files to context. |
command-logger | command | Append emitted command events to a JSONL log. |
compaction-notifier | session:compact:before, session:compact:after | Add compaction status notices on supported delivery paths. |
session-memory | command:new, command:reset, session:auto-reset | Save recent conversation excerpts to workspace memory. |
Enable one with openclaw hooks enable <hook-name>, then restart and verify its
side effect. The following sections describe what to expect.
boot-md details
Runs a nonempty BOOT.md from each configured agent's resolved workspace.
Workspaces shared by multiple agents run only once, under the first agent
selected for that workspace. Startup tasks run sequentially; a failed task is
logged and does not prevent later tasks.
This executes instructions through an agent run, not as a shell script and not
as a bootstrap file injection. It uses a temporary agent:<id>:boot session and
preserves the prior session mapping. Normal final-response delivery is disabled;
if the instructions need to notify someone, they must specify a channel and
target for the message tool. Missing or empty files are skipped.
Keep boot instructions short and safe to repeat on every restart. They can use model and tool capabilities, so enabling this hook can cause model calls and outbound side effects.
bootstrap-extra-files config
{
"hooks": {
"internal": {
"entries": {
"bootstrap-extra-files": {
"enabled": true,
"paths": ["packages/*/AGENTS.md"]
}
}
}
}
}
paths is preferred. If it is empty, the handler tries patterns, then files;
these are alternatives, not merged lists. Without patterns, the hook does nothing.
Paths resolve relative to the event's workspace and must remain inside it,
including after symlink resolution. Only these basenames load: AGENTS.md,
SOUL.md, IDENTITY.md, USER.md, BOOTSTRAP.md, and MEMORY.md.
Extra files go through normal bootstrap filtering and injection limits. Reads
are capped at 2 MiB per file. Injection defaults to 20,000 characters per file
and 60,000 total, controlled by bootstrapMaxChars and
bootstrapTotalMaxChars in agent defaults or overrides; USER.md has a separate
4,000-character cap. Duplicate paths are removed. Subagents retain only
AGENTS.md; cron and non-private conversations have additional context/privacy
filters. Inspect the actual injected result with /context detail; see
Context.
TOOLS.md fails as a runtime bootstrap basename.
openclaw doctor --fix packages the workspace-root TOOLS.md and folds any custom content into the ## Tools block of AGENTS.md. Other TOOLS.md files matched by patterns are left unmigrated; redirect those patterns to AGENTS.md instead.
command-logger details
For every emitted command event, one JSON line gets appended to <stateDir>/logs/commands.log. The fields recorded are timestamp, action, sessionKey, senderId, and source; when sender or source values are missing, they default to unknown. Only /new, /reset, and /stop are emitted by Core, not every slash command.
The handler waits for the append to finish, reports write errors to the log, and sends no chat confirmation. Log rotation is not performed. Make sure access and retention are set appropriately for the session and sender identifiers captured here. Refer to Log inspection.
compaction-notifier details
A brief notice is inserted before compaction starts, and a completion notice follows a successful compaction. When available, these notices may carry message counts plus before and after token counts. They are delivered through the compaction caller's notice callback; if no callback exists to pass them along, enabling the hook does not guarantee a visible message. A before notice appearing without an after notice points to a skipped, failed, or interrupted compaction, not a stuck hook. Manual /compact does not provide this hook-message delivery callback, so it is not a dependable way to test the notices.
session-memory details
The ended session's recent user and assistant text is saved to /new, /reset (soft reset included), or automatic daily and idle rollover. Automatic rollover sends session:auto-reset rather than a synthetic command event. Expiry gets checked when the next turn is admitted; this is not a timer that writes memory at the daily boundary while the session sits idle.
By default, the artifact is named <workspace>/memory/YYYY-MM-DD-HHMM.md, and a numeric suffix is appended if that filename already exists. Dates follow agents.defaults.userTimezone, then process TZ when no user timezone is set, with the host timezone as the final fallback. The file holds session identity along with the command source or automatic reset reason.
| Entry option | Default | Behavior |
|---|---|---|
messages | 15 | How many recent user or assistant messages to include; supply a positive integer. |
llmSlug | false | Have a model propose a descriptive filename slug. |
model | Agent default | Optional configured alias, bare model ID on the default provider, or provider/model used for slug generation. |
Before a reset closes the active window, the hook captures the departing conversation and then writes the snapshot in the background. Capture is capped at 4,096 scanned messages and 8 MiB. Manual resets do not wait for the file write or the optional slug-model call; automatic reset dispatch also proceeds independently of the successor turn. Look for Session context saved to ... in logs before expecting the file.
This is a filtered excerpt, not a complete transcript or a model-written summary. Slash-command text, tool messages, inter-session user input, silent reply markers, and duplicate delivery-mirror text are all omitted. If transcript reading fails, the artifact can note that content was unavailable. The workspace is resolved from event and agent config; no workspace.dir key needs to be added.
When llmSlug: true is enabled, conversation text goes to the configured model to generate the filename. If that fails, a timestamp slug is used instead. Turn it off if you prefer no extra model call for naming.
Note
Saved excerpts are workspace memory artifacts. If session transcript indexing is also enabled, a single conversation can show up as both
memoryandsessions, creating overlapping results and extra embedding work. For hook-only recall, setmemory.search.sources: ["memory"]andmemory.search.rememberAcrossConversations: false;sourceson its own does not keep cross-conversation recall from addingsessions. For full-transcript recall instead, disablesession-memory. These search settings do not turn off the hook's file writes or ordinary transcript persistence.
Event types
Subscribe to an exact key below or a bare family (command, session, agent,
gateway, message). Family subscriptions pick up every action within that family.
Avoid subscribing the same handler to both command and command:new unless you
want it invoked twice for a new command. session:compact is neither a family nor a
wildcard; subscribe to the two exact compaction keys.
| Event | Trigger and wait behavior |
|---|---|
command:new | Handling of an authorized new-session command, or a Gateway session operation that triggers new-command hooks; waits for completion. |
command:reset | Authorized reset-command handling or a Gateway session reset; waits for completion. |
command:stop | Stop-command handling after the abort request; waits, but no hook reply is sent. |
session:auto-reset | Existing session swapped out due to daily or idle policy; runs independently of the next turn. |
session:compact:before | Prior to compaction work; waits for completion. |
session:compact:after | Following a successful compaction; waits for completion. |
session:patch | An authorized Gateway patch is applied, or a supported model-selection path saves a change; notified asynchronously. |
agent:bootstrap | Workspace bootstrap resolution before context injection; waits for completion. |
gateway:startup | Scheduled after hook loading and sidecar or channel startup; does not hold up the initial Gateway bind. |
gateway:shutdown | Shutdown starts, before channel or plugin teardown; waits with a time limit. |
gateway:pre-restart | Shutdown has a finite expected restart delay; waits with a time limit. |
message:received | Accepted inbound dispatch with a session key; observed asynchronously. |
message:transcribed | Pre-agent preprocessing has nonempty audio transcript text and a session key; observed asynchronously. |
message:preprocessed | Media or link preprocessing finished or was skipped, with a session key; observed asynchronously. |
message:sent | A delivery owner reports a send outcome with a session key; observed asynchronously. Check context.success. |
Not every incoming transport update or attempted low-level send results in an
internal message event. Suppressed or duplicate inbound dispatches, and paths
lacking a session key, may omit them. These serve as observation points, not a
full transport audit or a mechanism to block message processing. Fast
native-command paths can bypass preprocessing events. preprocessed indicates that
phase was passed, not that every attachment or link was fully understood.
Similarly, compaction can skip or fail after its before event, and retries can
emit before again.
Unknown subscriptions like command:nwe are still registered, but the loader
warns and hooks info reports them. Core does not emit them. A custom key only
fires if custom code explicitly emits it; declaring it in metadata does not
create a trigger.
command:stop observes cancellation command handling. It is not a natural
agent-finalization gate. For that contract, see before_agent_finalize in
Plugin hooks.
Event context highlights
Fields below describe the producer payloads. Values marked optional may be absent; do not assume fields from one event exist on another.
command:new and command:reset: agentId, sessionEntry,
previousSessionEntry, commandSource, senderId, workspaceDir, storePath,
and cfg on the chat command path. Entries and routing metadata depend on the
caller. Gateway reset uses commandSource: "gateway:sessions.reset"; Gateway
agent reset uses gateway:agent, and session creation can use webchat.
Gateway callers omit senderId. Session creation emits new-command hooks only
when requested with emitCommandHooks for an existing parent. Prefer
previousSessionEntry for the session being replaced: chat and Gateway paths
emit at different points in reset, so this is not a universal pre-reset or
successful-reset receipt.
A sessionFile value can be a transcript identifier rather than a readable file
path; do not assume it is JSONL on disk.
command:stop: optional sessionEntry, sessionId, commandSource, and
senderId. It does not carry the full new/reset context.
session:auto-reset: cfg, agentId, workspaceDir, storePath,
sessionEntry identifying the ended sessionId and optional sessionFile,
reason (daily or idle), and optional transcriptArchived, nextSessionId,
and nextSessionKey.
agent:bootstrap: workspaceDir, mutable bootstrapFiles, and optional
cfg, sessionKey, sessionId, agentId. Each bootstrap record carries name,
path, missing, and optional content. While a handler may substitute or grow
the array, path deduplication, session/privacy filtering, and context budgets
remain in force.
session:patch: cloned post-operation sessionEntry, request-shaped patch,
and cfg. The patch carries target/expectation fields plus submitted settings,
not a computed diff of altered fields. Successful Gateway patches can fire even
when a submitted value was already present. Supported model-selection paths
also fire, including /model, the model picker, and model changes via
session_status; a read-only status query does not. This is not a notification
for every session-store write.
Compaction: both phases include sessionId, missingSessionKey,
messageCount, and optional tokenCount. Before also includes
messageCountOriginal and optional tokenCountOriginal. After includes
compactedCount and optional summaryLength, tokensBefore, tokensAfter, and
firstKeptEntryId. Do not infer unavailable token counts as zero.
gateway:startup: cfg, deps, and workspaceDir. Shutdown and
pre-restart: reason and restartExpectedMs (null when no restart is expected
on shutdown). The shutdown wait defaults to 5 seconds; pre-restart adds a
separate 10-second budget. These bound the caller's wait, not the handler's work:
timeout does not cancel promises. Channels have not yet been torn down, but
neither queued agent work nor message delivery is guaranteed to finish before
shutdown. Typed session_end drain behavior belongs to Plugin hooks.
Message context
message:received contains from, content, channelId, and optional
timestamp, accountId, conversationId, messageId, media, originalMedia,
mediaStagingPending, and metadata. Content prefers a nonblank command body,
then raw body, then generic body. It does not select BodyForAgent; the fallback
body is surface-defined rather than stripped of all enrichment by the mapper.
Received metadata may include to, provider, surface, threadId, senderId, senderName, senderUsername, senderE164, guildId, channelName, and topicName. For legacy purposes, attachment aliases are mediaPath, mediaUrl, mediaType, mediaPaths, mediaUrls, and mediaTypes; remote-staging metadata might also carry mediaRemoteHost, mediaStagingPending, plus the matching originalMediaPath, originalMediaUrl, originalMediaType, originalMediaPaths, originalMediaUrls, and originalMediaTypes. The structured media arrays are the recommended option.
Within message:transcribed and message:preprocessed, you get channelId, cfg, and, when present, from, to, body, bodyForAgent, timestamp, conversationId, messageId, senderId, senderName, senderUsername, provider, surface, as well as the structured media fields. Transcription adds the mandatory transcript text; preprocessing brings optional transcript, isGroup, and groupId. The enriched body meant for the agent is bodyForAgent. Both mediaPath and mediaType are deprecated aliases pointing to the first attachment. Neither context guarantees accountId or the metadata object from the received event.
Each structured media fact may carry path, url, contentType, kind, transcribed, messageId, and workspaceDir. The original ordering of facts is maintained. When mediaStagingPending is set to true, media gets suppressed, and originalMedia provides details about the original attachments; never treat remote paths as if they were local files.
Within message:sent, you will find to, content, success, channelId, plus the optional error, accountId, conversationId, messageId, isGroup, and groupId. An outcome generated on a path is what success: false flags as a failure; the lack of an event neither confirms success nor failure. For outbound delivery, a single outcome can be reported per logical payload instead of per text chunk, and a partial failure may include a message ID for a part that was already sent. Settlement of the durable outbound queue can postpone the observation, yet it does not make the hook itself durable. Avoid blind resends on failure, since you might duplicate a part that was already delivered. A send result does not guarantee that the recipient actually read the message.
Plugin hooks
Internal hooks managed by plugins show up as plugin:<id> within hooks list. They are part of this event system, but you control them by enabling or disabling the owning plugin, not by toggling them with hooks enable or hooks disable. The configured-name selection of the directory loader does not act as a policy gate for typed api.on hooks, nor does it replace plugin activation.
The older api.registerHook API is used to register internal events. It does not call typed lifecycle names such as before_tool_call, message_received, or session_start; attempting to register those names triggers a warning that points authors to api.on(...). For new integrations that require typed lifecycle control, consult the Plugin hooks reference.
Best practices
Handlers for a single event execute in sequence: family listeners run first, then exact listeners, each group in registration order. The dispatcher waits on every handler, records and logs any thrown errors, and proceeds to the remaining handlers. File hooks have no priority option available.
This ordering does not serialize distinct events. Message notifications, patch notifications, and automatic reset work can run concurrently with other events and agent processing. There is no universal handler timeout, no cancellation signal, no durable event queue, no automatic retry, and no exactly-once guarantee. In-flight work can be lost on restart or process exit.
Keep side effects short and within limits. Await the work that belongs to the handler, apply timeouts to network calls, cap data sizes, and ensure repeatable operations are idempotent. Do not rely on void doHeavyWork(event) as a general fix: that work escapes the handler's wait/error boundary and may outlive its session or process. For work that needs a durable job lifecycle, use an automation or service that takes ownership of it.
Filter out unrelated events early, and steer clear of logging message bodies, entire config objects, or credentials. Message and session data can be private. Keep only the minimum required for the side effect, protect output files, and set retention policies. Long-lived timers, watchers, sockets, and clients belong in a plugin service with an explicit shutdown lifecycle, not in a request/event handler.
CLI reference
Refer to openclaw hooks for the complete list of public reports and toggle options, JSON output fields, exit behavior, and install/update aliases.
Troubleshooting
Hook not discovered
Use openclaw hooks list --json to examine the report's workspaceDir and managedHooksDir. Verify that you are looking at the intended host, profile, and agent. Each hook requires HOOK.md and one supported handler file; a metadata file by itself is not enough. Collection locations only inspect immediate children. An explicit extra path or linked root can itself serve as a hook or pack. For a pack, confirm that openclaw.hooks lists the intended hook directories directly: nested packs and collections are not followed, and rejected entries do not cause unlisted children to be scanned.
Watch for duplicate names and containment warnings in Gateway logs. A workspace hook cannot override a bundled or managed hook. For extra directories and linked packs, verify the root layout described under Hook discovery.
Hook not eligible
openclaw hooks info my-hook
openclaw hooks list --verbose
Check blockedReason, missing binaries on the Gateway's PATH, environment, config paths, and OS. A workspace hook stays disabled until you explicitly enable it. A hook with no declared events cannot be loaded. Reports can pass requirements without proving that its module imports successfully.
Hook not executing
Check hooks.internal.enabled, the configured-name selection, and the hook's hookKey entry. Restart after changes. A ready report does not override the master switch or name selection, and it does not mean a non-startup agent's workspace was loaded.
openclaw logs --follow
Look for import/export errors, boundary failures, unknown-event warnings, or Hook error [<type>:<action>]. Trigger the exact event again and verify a hook-specific marker or artifact. Ordinary chat text does not trigger command:new; /stop does not send hook replies; a metadata subscription does not invent a custom trigger.
When the marker shows up yet the chat response is missing, verify the producer and route settings in Reply delivery rather than only checking whether the feature is turned on. With session-memory, let background writing complete and examine the resolved agent workspace instead of presuming the default one.