OceanCode
Hooks
A hook runs your own command (or an HTTP endpoint) at a fixed point in a run and hands back a
typed decision the engine acts on. Hooks are configured in a Claude Code settings.json /
hooks.json shape, so a hook you already wrote for Claude Code runs here unchanged.
The nine events
A hook is registered against one of nine lifecycle events:
| Event | Fires |
|---|---|
PreToolUse | Before a tool runs — can deny it or rewrite its input. |
PostToolUse | After a tool returns. |
Stop | When a turn is about to settle — can continue the run instead. |
PermissionDenied | When a permission check refuses an action. |
UserPromptSubmit | When you submit a prompt — can add context. |
SessionStart | When a session opens. |
SessionEnd | When a session closes. |
SubagentStop | When a spawned sub-agent settles. |
PreTide | Before the ledger cuts context out of the window (past the tideline). The old event name PreShelve still fires it as an alias. |
PreToolUse and PostToolUse carry the tool_name, so a handler can scope itself to one tool.
The settings.json shape
Handlers live in a settings.json (or hooks.json) under the same keys Claude Code uses — the
event name maps to a list of hook groups, each group a matcher plus one or more handlers:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "./scripts/guard.sh", "timeout": 10 }
]
}
]
}
}A command handler is run through <shell> -c <command> (shell defaults to sh) in the
project directory, with the event payload as JSON on stdin and these variables on its
environment: OC_HOOK_EVENT (the event name), OC_PROJECT_DIR and CLAUDE_PROJECT_DIR (both
the project directory, so a Claude Code hook script that reads $CLAUDE_PROJECT_DIR works
unchanged). An http handler POSTs the payload to a URL with your headers. Hooks are discovered
as capabilities (see 09 · Capabilities and plugins) and, because they
execute on your behalf, they run only from a trusted source.
The payload is the Claude Code shape: every event object carries hook_event_name and cwd
(inserted only when absent, never overwriting a field the event already set); SessionStart
carries source: "startup", SessionEnd carries reason: "exit". A group's matcher is a
regex tested against tool_name for PreToolUse / PostToolUse / PermissionDenied, against
source for SessionStart and against reason for SessionEnd; the other events ignore it.
An absent or empty matcher matches everything. One documented gap: session_id is not available
at the hook seam, so a handler that keys on it sees no such field.
Where hooks are read from — and when they run
At boot (and on every reload) oceancode walks every ecosystem's hook files — its own
.oceancode/, Claude Code's .claude/ and Grok Build's .grok/ — in this order, and merges
their groups per event, so earlier tiers' handlers run first:
| Source | Tier | Runs when |
|---|---|---|
.oceancode/hooks.json in the project | local | always — the local .oceancode/ tier is your machine's own copy, trusted. |
.claude/settings.json, .claude/settings.local.json in the project | repo | only when the project is trusted (oceancode trust repo on, or t in /hooks); otherwise held. |
~/.claude/settings.json | user | always — the user tier is trusted. |
.grok/hooks.json in the project, ~/.grok/hooks.json | repo / user | Grok Build's hook files, gated exactly like the repo and user tiers above. |
<plugin>/hooks/hooks.json of an installed plugin | plugin | only when the plugin is enabled and trusted; ${CLAUDE_PLUGIN_ROOT} in its commands is replaced with the plugin's root. |
A held source is still parsed and listed — with its handlers — so you can see exactly what
trusting it would enable. The reasons are held: untrusted (repo tier, project not trusted),
held: disabled plugin, unreadable and invalid.
Since 2026-09-10 the three Claude Code settings files are also watched: saving one reloads the
hooks (the same path r in /hooks and POST /hooks/reload take) together with the
Claude Code settings mirror — no reload needed
after editing ~/.claude/settings.json.
On the claude CLI route there is no double firing. When a session's provider route is the
claude command-line protocol, the claude subprocess reads the very same ~/.claude/settings.json,
.claude/settings.json, .claude/settings.local.json and its installed plugins' hooks/hooks.json,
and runs those hooks itself. On that route oceancode therefore dispatches only its own
.oceancode/ hooks (the ocean_code ecosystem, local and user tiers) and leaves every Claude
Code-shared source to Claude Code — a UserPromptSubmit handler in .claude/settings.local.json
fires once per prompt, not twice. Every other route (Anthropic API, OpenAI-compatible, Grok, …)
dispatches everything in the table above, since nothing else would. /hooks and GET /hooks
still list all sources on every route; the by-source report marks the shared ones
(shared_with_claude_code).
/hooks and oceancode hooks list
In Ocean pager, /hooks opens the Hooks tab of the capabilities picker (toggle and reload go
through the engine); see 19 · Ocean pager.
Without a session:
oceancode hooks list # one line per source, then its handlers indented
oceancode hooks list --json # {"repo_trusted": bool, "sources": [...]}
oceancode hooks add PreToolUse --matcher Bash --command ./scripts/guard.sh [--timeout 10] [--scope user|project|local]
oceancode hooks remove PreToolUse --matcher Bash --command ./scripts/guard.sh [--scope user|project|local]list ends with HOOKS_OK sources=<n> handlers=<m> loaded=<k>; add prints HOOK_ADDED <path>
and remove prints HOOK_REMOVED <path> (--scope defaults to project; remove of a handler
that is not there exits 1 with hooks remove: no such handler); --dir <d> and --home <h>
select another project or home. The scope names are Claude Code's: user, project and
local are the three files. Note that both project files — .claude/settings.json and
.claude/settings.local.json — are listed at tier repo: they live in the checkout, so they run
only when the project is trusted. (local the scope is Claude Code's name for the git-ignored
project file; local the tier is .oceancode/, your machine's private copy.) Over HTTP a client reads GET /hooks, reloads with
POST /hooks/reload, toggles repo trust with POST /trust/repo {"on": true|false}, adds with
POST /hooks {event, matcher, command, timeout?, scope} and removes with
POST /hooks/remove {path, event, matcher, target} — every one answers the same
{repo_trusted, sources} document.
The decision contract
A handler communicates back two ways: its exit code and a JSON object on stdout.
| Signal | Meaning |
|---|---|
exit 2 | Deny. On PreToolUse the tool does not run; on Stop it is a deny that does not count toward the Stop cap. |
| any other non-zero, or a spawn / timeout / IO error | No opinion — fail-open, the run proceeds. |
stdout hookSpecificOutput.permissionDecision | An explicit allow / deny / ask. |
stdout updatedInput | Rewrites the tool input before it runs. |
stdout additionalContext | Extra text folded into the model's context. |
stdout stopReason | On Stop, the reason the run should continue rather than settle. |
Grok Build handlers may also return updatedToolOutput (honoured only for a model-visible copy)
and defer. Everything but an explicit deny fails open: a handler that crashes, times out or
prints garbage never wedges a run.
Only command and http handlers run here, and only the nine events above. A handler of another
type — Claude Code's prompt and agent hooks — or one registered under an event this runner does
not implement (Claude Code's Notification, PreCompact, SubagentStart, …) is skipped and
counted (skipped: <n> unsupported handlers beside the source in /hooks), never fatal: the other
handlers in the same file still load.
Continuing a Stop
A Stop handler that wants the run to keep going returns a stopReason — the engine treats that
as a continuation and drains again. To keep a runaway handler from looping forever, at most 8
Stop continuations are honoured per run; past that the run settles. An exit-2 on Stop is a
plain Deny and does not count against those 8.
Provenance and replay
Every hook outcome is recorded with its provenance — which source, which handler, what it
decided — so a run can be replayed and audited: you can see exactly which hook denied a tool or
continued a turn, and from which tier it came. oceancode inspect lists every hook source with
loaded or held beside it.
See also
- 09 · Capabilities and plugins — discovery, tiers, and the trust gate hooks depend on.
- 11 · MCP servers — the other trust-gated capability.
- 02 · Configuration —
OC_HOOK_EVENTandOC_PROJECT_DIR, set on every command handler. - 19 · Ocean pager —
/hooksin the terminal UI. - 07 · Headless and server —
oceancode hooks listandoceancode trust.