Writing hooks: file layout, handler contract, and replies
Covers the hook directory layout, the handler contract, reply delivery, and the HOOK.md metadata fields. Written for developers who want to build and load a custom hook on the Gateway host.
Read this when
- You are writing a new internal hook and need the file layout
- You need the handler signature, event fields, or reply-delivery boundary
- You need the `HOOK.md` frontmatter and `metadata.openclaw` contract
Hook file layout, the handler contract, how replies are delivered, and the HOOK.md metadata fields. This page is part of the Hooks guide.
Writing hooks
The example below answers a reset command and writes a fixed log marker. It never reads message content, calls a model, or contacts an external service.
Hook structure
Create a fresh managed hook directory on the Gateway host. The commands that follow presume the default state directory and that reset-greeting does not exist yet; pick a different name instead of overwriting a hook that is already there.
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
Two things are required for a hook: HOOK.md and a handler file. Discovery looks for handler.ts, handler.js, index.ts, and then index.js, in that order, and takes the first file it finds. JavaScript is used in the example, so no TypeScript types or SDK imports are required.
Turn it on and load it:
openclaw hooks info reset-greeting
openclaw hooks enable reset-greeting
In a disposable conversation on a configured chat channel that can route replies, such as a direct message to the bot, send /new. Reset hook ran. should appear in that conversation, and [reset-greeting] reset hook ran should appear in Gateway logs.
The same example is also triggered by /reset. Ordinary command authorization still applies.
Use a regular OpenClaw conversation rather than an ACP-bound thread, since bound sessions hand reset handling to the runtime that owns them. As the chat-reply check, avoid Control UI/webchat and a sessions.reset RPC: neither path delivers this hook's event.messages to the UI. The log marker can still confirm that a reset event ran. The precise boundary is described in
Reply delivery.
When you are done with the example, disable it:
openclaw hooks disable reset-greeting
Files stay on disk after disabling. If you would rather use a workspace directory, place the two files in <workspace>/hooks/reset-greeting/ and then enable the hook explicitly. A workspace location is neither an agent sandbox nor a promise that the Gateway will load hooks from that workspace.
Handler implementation
A handler exports a function that returns void or Promise<void>. The default export is what the loader uses, unless metadata.openclaw.export names a different export.
Nothing a handler returns blocks, cancels, or rewrites the operation.
These fields are present on every event:
| 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 |
Context is meant for observation, not as a live API for editing state. Which fields appear depends on the producer, and cfg is absent from some events. Patch events, for example, carry cloned snapshots. The one explicit mutable exception is agent:bootstrap's context.bootstrapFiles.
Reply delivery
Pushing to event.messages is not a general-purpose send-message API:
| 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 to arrive for several reasons: no recipient, an unsupported route, send policy, or a delivery error. Append messages before the handler's promise settles, because detached work that pushes afterward may miss the producer's delivery step. For control over normal agent replies or for sending a cancellation, use the appropriate typed plugin hook.
HOOK.md format
HOOK.md consists of YAML frontmatter followed by human-readable Markdown:
---
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 defaults to the directory name, so choose a name that is unique and stable.
description appears in reports. The fields listed below go under
metadata.openclaw.
The metadata.clawdbot layout used before July 2026 is no longer parsed. To bring an older HOOK.md up to date, rename that metadata block to openclaw and leave its event list and remaining fields intact. When an openclaw block is already present, retain its values and merge in only the legacy fields you still need. OpenClaw leaves the file untouched, and any event declarations or requirements inside the old block are disregarded.
| Field | Contract |
|---|---|
events | Array of event keys. Registering a handler requires at least one entry. |
export | Name of the exported function; default is used when unspecified. |
hookKey | Key for the config entry; the hook name is the default. Discovery collisions are still resolved by hook name. |
emoji | Emoji shown in the interface. |
homepage | URL for 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 | Each executable you name has to be present on PATH. |
requires.anyBins | At least one of the named executables has to be present on PATH. |
requires.env | Each named variable requires a nonblank process value, or a per-hook env value. |
requires.config | Every dotted config path has to evaluate as truthy. |
always | Skips binary, environment, and config requirements; OS policy and enablement policy are still enforced. |
install | Informational install descriptors: kind takes the value bundled, npm, or git; optional keys are id, label, package, repository, and bins. This metadata neither installs dependencies nor causes the CLI to accept Git specs. |
Activation is controlled through hooks.internal.entries.<hookKey>.enabled, not by a top-level enabled flag placed in HOOK.md. Where historical requirement metadata is concerned, workspace.dir, browser.enabled, and browser.evaluateEnabled are treated as true when they are missing. workspace.dir is not a new setting for you to add to your config.