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:
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:
{
"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:
#!/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:
{
"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
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
| Runtime | Where | Useful detail |
|---|---|---|
| Claude Code | ~/.claude/projects/<project>/<session>.jsonl | Context added by a hook appears as an attachment of type hook_additional_context. |
| Codex | ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl | The first session_meta record names the originator, which separates desktop sessions from terminal ones. |
| OpenCode | ~/.local/share/opencode/opencode.db | A 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
| Task | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Register | hooks in settings.json | hooks.json or [hooks] | plugin file |
| Extra step | accept workspace trust | trust each hook with /hooks | restart |
| Block a tool | deny or exit 2 | deny or exit 2 (no ask) | throw |
| Add context for the model | additionalContext, 10,000 characters | additionalContext, about 2,500 tokens by default | SDK session.prompt with noReply |
| Desktop sessions on our Mac | Observed | Observed after trust | Not 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
noReplymessage reaching a live session. - Codex events other than
PreToolUsefiring from the desktop app, and CodexadditionalContextreaching 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