Skip to main content

Hooks

Internal hooks are small JavaScript or TypeScript handlers that run in the Gateway process when OpenClaw emits an event. Use them to save session context, log reset commands, or perform short side effects during message and session lifecycle events. OpenClaw includes bundled hooks for common tasks; you do not need to write a plugin to use them.

Choose the right surface

These are separate systems. hooks.internal configures this page’s event handlers; hooks.enabled configures HTTP ingress. Internal event names such as message:received are not typed plugin names such as message_received.
Internal hooks are trusted code, not sandboxed scripts. They run with the Gateway process’s filesystem, network, and environment access. Review hook code before enabling it, especially code from a workspace or downloaded package.

Quick start

Start with command-logger: it needs no extra binaries or model calls and gives you a concrete file to inspect. Run these commands on the Gateway host, with the same profile and config as that Gateway:
The default hybrid reload mode applies hook config changes without a restart. With reload mode off, run openclaw gateway restart, or restart a foreground Gateway yourself. Add --agent <id> when your configuration has multiple agents and no implicit owner. In a conversation you can safely reset, send /new or /reset as an authorized user. Then inspect the log on the Gateway host:
Look for a new JSON line with "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 proves that a handler ran; openclaw hooks check alone does not. The log contains session and sender identifiers. Disable the hook after trying it if you do not want to retain those records:

Eligible, enabled, and loaded

Keep these three checks separate:
  • Requirements satisfied: the hook’s OS, binaries, environment, and config requirements pass on the host doing the check.
  • Enabled by config: the per-hook/source policy allows it. Workspace hooks require explicit opt-in; bundled and managed hooks do not require that per-hook flag when broad discovery is enabled.
  • Loaded: the running Gateway selected the hook, imported its handler, and registered its events. This also requires the master switch and configured name selection to allow it.
The CLI’s ready, eligible, and loadable fields describe the first two checks plus a nonempty event list. They do not prove that the Gateway imported the handler, that the global selection includes it, or that its event has fired. After changes, verify the actual side effect or hook-specific log. Config reload prepares the selected handlers before replacing them together. If a selected handler cannot load, the previous handlers stay active. An event already running finishes with its original handlers; subsequent events use the new selection. Reload does not replay gateway:startup.

Local, remote, and agent scope

hooks list, info, and check request the selected Gateway’s inventory. An implicit local Gateway can 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. The Gateway loads directory hooks from its selected 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 replies 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.
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:
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:
Disabling leaves the files in place. To use a workspace directory instead, put the two files in <workspace>/hooks/reset-greeting/, then explicitly enable the hook. Workspace placement is not an agent sandbox or a guarantee that the Gateway will load that workspace’s hooks.

Handler implementation

A handler exports a function returning void or Promise<void>. The loader uses the default export unless metadata.openclaw.export names another export. Returned values do not block, cancel, or rewrite the operation. Every event has these fields: Treat context as an observation, not a live state-editing API. Fields vary by producer, and cfg is not present on every event. In particular, patch events carry cloned snapshots. The explicit mutable exception is agent:bootstrap’s context.bootstrapFiles.

Reply delivery

Pushing to event.messages is not a general send-message API: A missing recipient, unsupported route, send policy, or delivery failure can prevent a reply. Append messages before the handler’s promise settles; detached work that pushes later can miss the producer’s delivery step. To control normal agent replies or send cancellation, use the appropriate typed plugin hook.

HOOK.md format

HOOK.md uses YAML frontmatter followed by human-readable Markdown:
name defaults to the directory name; use a unique, stable name. description is shown in reports. The following fields belong under metadata.openclaw: Use hooks.internal.entries.<hookKey>.enabled to control activation, not a top-level enabled flag in HOOK.md. For historical requirement metadata, workspace.dir, browser.enabled, and browser.evaluateEnabled default to true when absent. workspace.dir is not a new setting you need to add to your config.

Configuration

For a predictable selection, enable named hooks rather than turning on broad discovery:
The master switch and selection rules for directory-loaded hooks are: Workspace hooks always need entries.<hookKey>.enabled: true, even with open-ended discovery. For other file hooks, an entry can be selected by its name or hookKey, but settings are read under hookKey. The CLI resolves the name and writes the correct key for you. Adding the first named entry can narrow a previously broad selection; inspect existing hooks before changing it. Per-hook entries accept arbitrary handler-defined fields. The core types enabled as a boolean and env as a string-to-string map; it does not validate custom handler options. For example:
Per-hook env satisfies eligibility checks but does not mutate process.env. On events carrying config, a handler can read it from event.context.cfg?.hooks?.internal?.entries?.["my-hook"]?.env. Other events do not promise a cfg field. Do not log entire config objects or put secrets in examples.
hooks.internal.handlers is retired and fails normal config validation. Before running openclaw doctor --fix, migrate each registered module into a managed or workspace hook directory with HOOK.md and a handler. Doctor removes the old registrations; it does not create executable files. For a legacy-only config with hooks.internal.enabled: true, it also removes that flag to avoid broad discovery. Named entries, nonempty extra directories, and explicit enabled: false are preserved.

Hook discovery

Directory discovery merges hooks by name using these rules: Bundled, managed, workspace, and plugin hook locations are collection directories: discovery inspects their immediate children for hooks or packages whose package.json declares openclaw.hooks. Each explicit hooks.internal.load.extraDirs path can instead be a pack root, a single-hook root, or a collection directory. A pack root loads only its declared hook paths, including nested paths such as ./hooks/my-hook. Each path must point directly to a hook; discovery does not recurse into another pack or collection. A recognized pack with no valid hooks stays 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 example, to select /opt/openclaw-hook-library/my-hook/HOOK.md directly, add that hook’s directory:
To scan the library’s immediate children instead, add /opt/openclaw-hook-library. Only add trusted directories: 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 stay within their hook directory; package and plugin hook paths must stay within their package root. Symlinks escaping those boundaries are rejected. Hook config and selected-workspace changes reload discovery in hybrid mode, including config written by a new hook-pack install or link. Hook files and metadata are not watched; restart after editing them or updating existing hook code, then verify the handler’s actual side effect.

Hook packs

A hook pack is a package whose package.json declares hook directories in openclaw.hooks. Install a reviewed package or local directory through the unified installer:
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

Enable one with openclaw hooks enable <hook-name> and verify its side effect. Startup-only hooks such as boot-md wait for the next Gateway start.

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. Each run uses a fresh temporary agent:<id>:boot:<run-id> session, cleaned up after success or failure. Existing sessions and their history are preserved. 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

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 is not a recognized runtime bootstrap basename. openclaw doctor --fix archives workspace-root TOOLS.md and merges customized content into the ## Tools section of AGENTS.md. Other TOOLS.md files named by patterns are not migrated; point those patterns at AGENTS.md instead.

command-logger details

Appends one JSON line per emitted command event to <stateDir>/logs/commands.log. Fields are timestamp, action, sessionKey, senderId, and source; absent sender/source values become unknown. Core emits /new, /reset, and /stop, not every slash command. The handler awaits the append, logs write errors, and sends no chat confirmation. It does not rotate the log. Set appropriate access and retention for the session and sender identifiers it records. See Log inspection.

compaction-notifier details

Adds a short notice before compaction and a completion notice after successful compaction. Notices can include message counts and before/after token counts when available. They travel through the compaction caller’s notice callback; without a callback that delivers them, enabling the hook does not guarantee a visible message. A before notice without an after notice can indicate a skipped, failed, or interrupted compaction, not a stuck hook. Manual /compact does not supply this hook-message delivery callback, so it is not a reliable way to test the notices.

session-memory details

Saves the ended session’s recent user/assistant text on /new, /reset (including soft reset), or automatic daily/idle rollover. Automatic rollover emits session:auto-reset, not a synthetic command event. Expiry is checked when a subsequent turn is admitted; this is not a timer that writes memory at the daily boundary while the session is idle. The artifact is <workspace>/memory/YYYY-MM-DD-HHMM.md by default, with a numeric suffix if that filename already exists. Dates use agents.defaults.userTimezone, then process TZ when no user timezone is set, and the host timezone as fallback. The file records session identity and the command source or automatic reset reason. The hook captures the departing conversation before a reset closes its active window, then writes the snapshot in the background. Capture is bounded to 4,096 scanned messages and 8 MiB. Manual resets do not await the file write or optional slug-model call; automatic reset dispatch also runs independently of the successor turn. Wait 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. It omits slash-command text, tool messages, inter-session user input, silent reply markers, and duplicate delivery-mirror text. If transcript reading fails, the artifact can record that content was unavailable. The workspace is resolved from event/agent config; you do not need to add a workspace.dir key. With llmSlug: true, conversation text is sent to the configured model to name the file. Failure falls back to a timestamp slug. Leave it off if you want no extra model call for naming.
Saved excerpts are workspace memory artifacts. If session transcript indexing is also enabled, one conversation can be represented by both memory and sessions, adding overlapping results and embedding work. For hook-only recall, set memory.search.sources: ["memory"] and memory.search.rememberAcrossConversations: false; sources alone does not stop cross-conversation recall from adding sessions. For full-transcript recall instead, disable session-memory. These search settings do not disable 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 receive all actions in that family. Do not subscribe the same handler to both command and command:new unless you want it called twice for a new command. session:compact is not a family or a wildcard; subscribe to the two exact compaction keys. Not every incoming transport update or attempted low-level send produces an internal message event. Suppressed/duplicate inbound dispatches and paths with no session key can omit them. These are observation points, not a complete transport audit or a way to block message processing. Fast native-command paths can skip preprocessing events. preprocessed means that phase was passed, not that every attachment or link was successfully understood. Likewise, compaction can skip or fail after its before event, and retries can emit before again. Unknown subscriptions such as 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 has name, path, missing, and optional content. A handler can replace or extend the array, but final path deduplication, session/privacy filtering, and context budgets still apply. session:patch: cloned post-operation sessionEntry, request-shaped patch, and cfg. The patch contains target/expectation fields and submitted settings, not a computed changed-fields diff. Successful Gateway patches can emit even when a submitted value was already present. Supported model-selection paths also emit, including /model, the model picker, and model changes through 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 can contain to, provider, surface, threadId, senderId, senderName, senderUsername, senderE164, guildId, channelName, and topicName. Legacy attachment aliases are mediaPath, mediaUrl, mediaType, mediaPaths, mediaUrls, and mediaTypes; remote-staging metadata can also include mediaRemoteHost, mediaStagingPending, and corresponding originalMediaPath, originalMediaUrl, originalMediaType, originalMediaPaths, originalMediaUrls, and originalMediaTypes. Prefer the structured media arrays. message:transcribed and message:preprocessed contain channelId, cfg, and optional from, to, body, bodyForAgent, timestamp, conversationId, messageId, senderId, senderName, senderUsername, provider, surface, and the structured media fields. Transcribed adds required transcript text; preprocessed adds optional transcript, isGroup, and groupId. bodyForAgent is the enriched body prepared for the agent. mediaPath and mediaType remain deprecated first-attachment aliases. These contexts do not promise accountId or the received event’s metadata object. Each structured media fact can contain path, url, contentType, kind, transcribed, messageId, and workspaceDir. Facts preserve source order. When mediaStagingPending is true, media is withheld and originalMedia describes the original attachments; do not treat remote paths as local files. message:sent contains to, content, success, channelId, and optional error, accountId, conversationId, messageId, isGroup, and groupId. success: false reports failure on a path that emitted an outcome; absence of an event is not proof of either success or failure. Outbound delivery can report one outcome per logical payload rather than per text chunk, and a partial failure can include a message ID for a part already sent. Durable outbound queue settlement can defer the observation; it does not make the hook durable. Do not blindly resend on failure: you can duplicate a delivered part. A send result is not proof that the recipient read the message.

Plugin hooks

Plugin-managed internal hooks appear as plugin:<id> in hooks list. They participate in this event system, but you enable or disable the owning plugin rather than toggling them with hooks enable or hooks disable. The directory loader’s configured-name selection is not a policy gate for typed api.on hooks or a substitute for plugin activation. The legacy api.registerHook API registers internal events. It does not invoke typed lifecycle names such as before_tool_call, message_received, or session_start; registering those names emits a warning directing authors to api.on(...). For new integrations needing typed lifecycle control, use the Plugin hooks reference.

Best practices

Handlers for one event run sequentially: family listeners first, then exact listeners, in registration order within each group. The dispatcher awaits each handler, catches and logs thrown errors, and continues to later handlers. There is no priority option for file hooks. This sequencing does not serialize different events. Message notifications, patch notifications, and automatic reset work can overlap with other events and agent processing. There is no general handler timeout, cancellation signal, durable event queue, automatic retry, or exactly-once guarantee. Restart or process exit can lose in-flight work. Keep side effects short and bounded. Await the work that belongs to the handler, set timeouts on network calls, limit data sizes, and make repeatable operations idempotent. Do not use void doHeavyWork(event) as a general solution: that work escapes the handler’s wait/error boundary and can outlive its session or process. If work needs a durable job lifecycle, use an automation or service that owns it. Filter unrelated events early and avoid logging message bodies, whole config objects, or credentials. Message and session data can be private. Keep only the minimum needed for the side effect, protect output files, and set retention. Long-lived timers, watchers, sockets, and clients belong to a plugin service with an explicit shutdown lifecycle, not a request/event handler.

CLI reference

See openclaw hooks for every public report and toggle option, JSON output fields, exit behavior, and install/update aliases.

Troubleshooting

Hook not discovered

Check the report’s workspaceDir and managedHooksDir with openclaw hooks list --json. Confirm you are inspecting the intended host, profile, and agent. Each hook needs HOOK.md and one supported handler file; a metadata file alone is insufficient. Collection locations inspect immediate children. An explicit extra path or linked root can itself be a hook or pack. For a pack, verify 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. Check 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

Check blockedReason, missing binaries on the Gateway’s PATH, environment, config paths, and OS. A workspace hook is disabled until explicitly enabled. A hook with no declared events is not loadable. 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 and reload mode. A ready report does not override the master switch or name selection and does not mean another agent’s workspace was loaded.
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. If the marker appears but the chat reply does not, check the producer and route under Reply delivery, not just enablement. For session-memory, allow background writing to finish and inspect the resolved agent workspace rather than assuming the default workspace.