Home › Learn › Claude Code, Codex and OpenCode hooks

Tutorial · 15 min read · Claude Code · Codex · OpenCode

Claude Code hooks, Codex hooks and OpenCode plugins, in the terminal and the desktop apps

The Claude and Codex desktop apps run the same agent runtime you would start in a terminal, and that runtime reads your user-level hook configuration. OpenCode's documentation says the same about its desktop app. So a short script can log every tool call a session makes, including sessions started from a desktop app, and can answer with a decision or a note for the model.

Why desktop sessions run your hooks

On our test Mac, the Claude desktop app starts a bundled claude executable with --setting-sources=user,project,local. That option loads your ~/.claude/settings.json, and the hooks in it. Observed Anthropic's documentation states that the desktop app and the CLI read the same configuration files, and that hooks fire in the terminal, the IDE extensions and the desktop app. Vendor docs

The Codex desktop app starts a bundled codex app-server process. Once the hooks in ~/.codex/hooks.json were trusted, they fired for sessions started from the desktop app. Observed OpenCode's desktop app runs an OpenCode server alongside it and loads plugins from the same directories as the terminal UI. Vendor docs

To see the runtime behind an open desktop app, list its processes:

Terminal
ps -axo command | grep -E 'claude|codex app-server|opencode' | grep -v grep

The arguments show which settings files the runtime loads, and therefore which hooks will run.

Claude Code: a hook in settings.json

User-level hooks go under the hooks key of ~/.claude/settings.json and apply to every project. A project can add its own in .claude/settings.json. Vendor docs This configuration sends every prompt and every tool call to one script:

~/.claude/settings.json
{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "python3 /path/to/hooks/watch.py" } ] }
    ],
    "PreToolUse": [
      { "matcher": "*",
        "hooks": [ { "type": "command", "command": "python3 /path/to/hooks/watch.py" } ] }
    ]
  }
}

For each event the runtime starts your command, writes one JSON object to its standard input and reads its standard output. The script below appends each event to a log file, adds a note to each prompt, and refuses Read calls on files whose names end in .env:

/path/to/hooks/watch.py
#!/usr/bin/env python3
import json, sys, time, pathlib

event = json.load(sys.stdin)
with (pathlib.Path.home() / "hook-events.jsonl").open("a") as log:
    log.write(json.dumps({"at": time.time(), **event}) + "\n")

name = event.get("hook_event_name")
if name == "UserPromptSubmit":
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "UserPromptSubmit",
        "additionalContext": "Note from a local watcher: run the tests before you finish."}}))
elif name == "PreToolUse":
    path = str(event.get("tool_input", {}).get("file_path", ""))
    if path.endswith(".env"):
        print(json.dumps({"hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": "The local watcher keeps .env files out of the conversation."}}))
sys.exit(0)

The .env check is a demonstration and does not keep secrets out. It sees only the file_path argument of file tools, so cat .env in a shell command, a search tool, or a file named .env.local gets past it.

What arrives on standard input

Every event carries session_id, transcript_path, cwd and hook_event_name, and most events also carry permission_mode. Tool events add tool_name, tool_input and tool_use_id, and PostToolUse adds tool_response. Vendor docs When we ran the script, the log held those common fields for both events, plus prompt for UserPromptSubmit and the three tool fields for PreToolUse. Observed

How a hook answers

Printing nothing and exiting with status 0 lets the action go ahead. On PreToolUse a hook can print a permissionDecision of allow, deny or ask, with a reason, and a deny from any hook wins over the others. Exit status 2 also blocks the call and uses standard error as the reason. Vendor docs A fourth decision, defer, is honoured only in non-interactive claude -p runs; interactive sessions, including the desktop app, ignore it. Vendor docs

To add information for the model, print additionalContext. It works on events including UserPromptSubmit, PreToolUse and PostToolUse, up to 10,000 characters per field. Vendor docs We have seen both denials and added context in transcripts of sessions started from the Claude desktop app. Observed

In an interactive session, hooks from settings files wait until you accept the workspace trust dialog for that folder. Vendor docs

Codex: hooks.json, then trust

Codex reads ~/.codex/hooks.json (or a [hooks] table in config.toml), plus the same files in the .codex/ folder of a project you have trusted. Hooks are on by default. Vendor docs The registration looks like this:

~/.codex/hooks.json
{
  "hooks": {
    "PreToolUse": [
      { "hooks": [ { "type": "command", "command": "python3 /path/to/hooks/watch.py" } ] }
    ]
  }
}

The same script reads Codex's event format. When we gave it a PreToolUse event built from the Codex documentation, it logged the event and allowed the call. Observed Its .env check looks for Claude's file_path argument, so for Codex you would match the shell command in tool_input.command instead.

Trusting the hook

Codex records trust against a hash of each hook and skips any hook that is new or has changed until you trust it. Vendor docs Open the Codex CLI in a terminal, type /hooks, and trust the entries you added; do it again after every edit. If codex is not on your PATH, the desktop app includes a copy, which on our test Mac was /Applications/ChatGPT.app/Contents/Resources/codex. Observed

Trust is granted per event. On our machine only PreToolUse was trusted, and only PreToolUse fired. Shell commands run from the desktop app reached the hook as Bash tool calls. Observed

How a Codex hook answers

A hook denies a call with permissionDecision: "deny" or with exit status 2. Codex parses "ask" but does not support it: the hook run is marked as failed and the tool call continues. Vendor docs

The documentation describes additionalContext for PreToolUse. By default each message is limited to roughly 2,500 tokens, and a handler can change that with additionalContextLimit. Standard input carries session_id, transcript_path (which can be null), cwd, hook_event_name and model, and tool events add tool_name, tool_input and tool_use_id. Vendor docs

OpenCode: a plugin file

OpenCode loads JavaScript plugins from ~/.config/opencode/plugins/ for every project and from .opencode/plugins/ for one project. A plugin is an async function; it receives a context object that includes an SDK client, and it returns the hooks it implements. Vendor docs

~/.config/opencode/plugins/watch.js
import { appendFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"

const log = (entry) =>
  appendFileSync(join(homedir(), "opencode-events.jsonl"),
    JSON.stringify({ at: Date.now(), ...entry }) + "\n")

export const Watch = async () => ({
  "tool.execute.before": async (input, output) => {
    // A throw here blocks the tool call, so a logging error must never escape.
    try { log({ tool: input.tool, session: input.sessionID, args: output.args }) } catch {}
    if (String(output.args?.filePath ?? "").endsWith(".env")) {
      throw new Error("The local watcher keeps .env files out of the conversation.")
    }
  },
  event: async ({ event }) => {
    try { if (event.type === "session.idle") log({ idle: event.properties.sessionID }) } catch {}
  },
})

Throwing inside tool.execute.before stops the tool call. OpenCode's documentation uses this pattern to protect .env files, Vendor docs and it blocked the call on our install. Observed The same limits as the Claude script apply: only the filePath argument is checked.

session.idle has no hook of its own and arrives through the event hook. Vendor docs The plugin above logged both kinds of entry. Observed

The plugin context's SDK client can also add a message to a session without asking the model to reply, using client.session.prompt({ path: { id }, body: { noReply: true, parts: [{ type: "text", text }] } }). Vendor docs We have not yet seen this reach a live session. Not verified

Restart OpenCode after adding or changing a plugin.

Transcripts on disk

A hook sees an action before it happens. The runtime's transcript records what was said, and each runtime writes one to disk while the session runs: Observed

RuntimeWhereUseful detail
Claude Code~/.claude/projects/<project>/<session>.jsonlContext added by a hook appears as an attachment of type hook_additional_context.
Codex~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonlThe first session_meta record names the originator, which separates desktop sessions from terminal ones.
OpenCode~/.local/share/opencode/opencode.dbA SQLite database rather than JSON lines.

Claude Code and Codex hook events include the transcript path, although Codex may send it as null; OpenCode plugin events do not include it. Vendor docs These file formats are internal to each vendor and change between versions, so tie any parser to the version you tested.

Side by side

TaskClaude CodeCodexOpenCode
Registerhooks in settings.jsonhooks.json or [hooks]plugin file
Extra stepaccept workspace trusttrust each hook with /hooksrestart
Block a tooldeny or exit 2deny or exit 2 (no ask)throw
Add context for the modeladditionalContext, 10,000 charactersadditionalContext, about 2,500 tokens by defaultSDK session.prompt with noReply
Desktop sessions on our MacObservedObserved after trustNot verified

Running a hook safely

A hook sees file contents and shell commands in tool_input, including anything the model pasted into them, so keep its logs as private as the files themselves.

A PreToolUse hook with the matcher * runs before every tool call in every session, including desktop sessions you have forgotten about. Keep it fast.

Choose what should happen when the hook crashes or times out. A logger should let the tool run anyway. A check you depend on should block the call and give a reason.

To remove a hook, delete its entry from the settings file, hooks.json or the plugins folder. Codex also keeps a trust record for each hook in config.toml under [hooks.state]. Observed

What we haven't verified

On 2026-09-25 we ran the Claude Code and OpenCode examples on this page as published, each with its own isolated configuration. The log files filled, the .env reads were refused, and Claude repeated the watcher's note. We did not run the Codex configuration end to end: we checked that it parses and gave the script a Codex-shaped event, which it logged and allowed.

  • The OpenCode desktop app. We tested the terminal UI; what this page says about the desktop app comes from OpenCode's documentation.
  • An OpenCode noReply message reaching a live session.
  • Codex events other than PreToolUse firing from the desktop app, and Codex additionalContext reaching the model. Those hooks were not trusted on our machine.
  • Whether the Claude desktop app shows its own workspace trust prompt before hooks from settings files run.

Newer versions than the ones we tested were available on the day we checked: Claude Code 2.1.282, Codex CLI 0.157.0 and OpenCode 1.18.32. Hook contracts change, so check the vendor documentation before relying on a detail.

Vendor documentation: Claude Code hooks · Claude desktop · Codex hooks · OpenCode plugins · OpenCode SDK · OpenCode troubleshooting (desktop app)

Last verified 2026-09-25 · review by 2026-10-25