Repository navigation
Native Claude Code hooks compatibility (PreToolUse, PostToolUse, Stop) #12472
Description
Summary
OpenCode already has excellent Claude Code compatibility for rules (CLAUDE.md), skills (~/.claude/skills/), and can be disabled via OPENCODE_DISABLE_CLAUDE_CODE* env vars. However, Claude Code's hooks system (PreToolUse, PostToolUse, Stop defined in ~/.claude/settings.json) is not supported natively.
Problem
Users who run both Claude Code and OpenCode maintain hooks in ~/.claude/settings.json that enforce guardrails:
- PreToolUse — Block dangerous operations (e.g.,
git pushwithout tests, direct prod DB access, editing governed files without reading the architecture skill first) - PostToolUse — Track state (e.g., mark that tests were run, mark that code files were edited)
- Stop — Verify the dev-loop SOP was followed before the agent stops (tests run, committed, deployed, QA'd)
These hooks are Python/bash scripts that receive JSON on stdin (tool_name, tool_input, transcript_path) and use exit codes to signal behavior (0=allow, 2=block+feedback via stderr).
Without native support, users must either:
- Install
oh-my-opencode(massive opinionated plugin that takes over the entire agent setup just for hook compat) - Write a custom bridge plugin (works for PreToolUse/PostToolUse but Stop hooks lose transcript access and can't re-activate the agent)
Proposed Solution
Add native Claude Code hook execution, similar to how CLAUDE.md and skills are already handled:
- Read hooks from
~/.claude/settings.json(andsettings.local.json) — parse thehooksobject - Map to OpenCode events:
PreToolUse→tool.execute.before— transform tool names (bash→Bash,edit→Edit), pipe JSON stdin, respect exit code 2 as blockPostToolUse→tool.execute.after— same protocol, informationalStop→session.idle— providetranscript_pathwith full session transcript (tool calls + assistant text), and critically: if exit code 2, re-inject stderr as a prompt to continue (this is how Claude Code's Stop hooks enforce SOP completion)
- Disable via env var:
OPENCODE_DISABLE_CLAUDE_CODE_HOOKS=1(consistent with existingOPENCODE_DISABLE_CLAUDE_CODE_*pattern)
Key Gap: Stop Hook Re-activation
The most critical missing piece is Stop hook re-activation. In Claude Code, when a Stop hook exits with code 2, the stderr message is injected back as a prompt and the agent continues working. This is how dev-loop SOPs are enforced — the hook says "you edited code but didn't run tests" and the agent resumes to run tests.
OpenCode's session.idle event is fire-and-forget. The feature request specifically asks for a mechanism where a session.idle handler can signal "don't stop, inject this message instead" — either via return value or a callback.
Protocol Reference
Claude Code hook stdin format:
{
"session_id": "...",
"cwd": "/path/to/project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "git push origin main" },
"transcript_path": "/tmp/transcript.jsonl"
}Exit codes:
0= allow (stderr is informational, shown to LLM but doesn't block)2= block (PreToolUse) or re-activate (Stop) — stderr is the feedback message
Why Native vs Plugin
A plugin can handle PreToolUse/PostToolUse adequately, but Stop hook re-activation requires OpenCode core support — plugins can't force the agent to resume from session.idle. This is why native support matters.
Activity
github-actions commentedon Feb 6, 2026
This issue might be a duplicate of or closely related to existing issues. Please check:
While this issue specifically focuses on hook execution (PreToolUse, PostToolUse, Stop) and the protocol for handling them, #10305 discusses more general settings import. These may be complementary features under the broader Claude Code compatibility umbrella.
Feel free to ignore if your specific use case for hook execution isn't addressed by those existing discussions.
nagy135 commentedon Feb 25, 2026
can we use this Issue as a tracker for progress on this? I would definitelly want my hooks to prevent agents from running
git pushor specific npm commands.parkuman commentedon Feb 25, 2026
This would be especially useful in shared repos where some engineers are using Claude Code while others have opted for OpenCode.
ppiankov commentedon Feb 26, 2026
+1 on this. We have a concrete use case that demonstrates why native hook support matters:
MCP secret enforcement via PreToolUse
We run pastewatch as an MCP server across 5 agents. It provides
pastewatch_read_file/pastewatch_write_filetools that replace secrets with__PW{TYPE_N}__placeholders — secrets stay in local RAM, only placeholders hit the API.The problem: agents default to native
Read/Writeand bypass redaction entirely. Advisory instructions ("please use pastewatch tools for secrets") are unreliable, especially with weaker models.With hooks (Claude Code + Cline today):
A
PreToolUsehook onRead|Write|Editrunspastewatch-cli scan --check --fail-on-severity high --file <path>. If secrets are detected (exit 6), the hook blocks the native tool (exit 2) and tells the agent to usepastewatch_read_fileinstead. Structural enforcement — the agent has no choice.Without hooks (OpenCode today):
Advisory only. The agent might follow the instructions in AGENTS.md, or it might use native Read and send raw API keys to the cloud. No way to enforce it.
This is a real gap. We currently recommend Cline over OpenCode for sensitive data work specifically because of hook support.
Regarding protocol — we use the exact same stdin/exit code protocol described in the issue. Our hooks are portable bash scripts that work in both Claude Code and Cline today. If OpenCode adopts the same protocol, they'd work without modification.
marcusquinn commentedon Mar 1, 2026
Use case: Write-time quality enforcement (linting on every edit)
Adding another concrete use case from the aidevops framework.
We evaluated Plankton (write-time code quality enforcement via Claude Code hooks) and found the pattern compelling but not portable to OpenCode without native hook support.
What Plankton does with hooks today:
PostToolUse on Edit/Write — runs 20+ linters (shellcheck, ruff, biome, markdownlint, etc.) on every edited file, collects violations as structured JSON, and feeds them back to the agent immediately. The agent gets write-time feedback and produces better code during generation — not just through post-hoc correction at commit time.
PreToolUse on Edit/Write — blocks edits to linter config files (
.shellcheckrc,.ruff.toml,biome.json, etc.). This prevents agents from "fixing" violations by weakening rules rather than fixing code. A real and common failure mode.PreToolUse on Bash — enforces package manager policies (blocks
pip install, suggestsuv; blocksnpm, suggestsbun).Stop hook — detects if any protected config files were modified during the session via
git diff, prompts user to restore or keep.What we can do without hooks (current workaround):
What we'd do with native hooks:
The same PostToolUse lint-on-edit pattern, but using our existing
linters-local.shinfrastructure. We already have the linters, the structured output, and the fix automation — we just can't trigger them automatically on every tool call.Watching PR #11525 with interest. The proposed
PreToolUse/PostToolUsewith exit code 2 = block + stderr feedback is exactly the protocol we'd use.chore: add write-time quality enforcement rules (t1368)
chore: add write-time quality enforcement rules (t1368) (#2659)
gl33mer commentedon Mar 5, 2026
Use case: Enable rtk-ai/rtk - reduce token use by llm when using a large set of commands
https://github.com/rtk-ai/rtk
Uses Claude hooks to seamlessly use rtk for a large set of commands with large token savings.
https://x.com/chrysb/status/2029278106603929870
yehudacohen commentedon Mar 8, 2026
The
session.idleworkaround for the Stop hook has a specific limitation: timing.When
session.idlefires, the agent loop has already broken and the session is idle. A plugin can callclient.session.chatto re-prompt, but this starts a new prompt invocation from outside the loop, which is why #15267 exists — inopencode runmode the process teardown races against the re-prompt. It also creates a visible user message in the conversation, which is awkward when the intent is a silent continuation check.A hook that fires before the
breaksolves this differently. The re-entry happens inside the existing loop iteration with no new HTTP call and no risk of the process exiting between the event and the response. The injected message uses the same internalcreateUserMessagepath the loop already uses, so it behaves identically to a normal user turn from the model's perspective.I've opened #16626 specifically for this plugin hook and a draft PR at #16598. Keeping it as a draft to get feedback on approach first. This would be a building block for the Stop hook re-activation described here — a plugin implementing Claude Code Stop hook compatibility could use it to inject the hook's stderr output and continue the loop.
marcusquinn commentedon Mar 8, 2026
The
session.stoppingapproach in #16626 / #16598 makes sense — firing before the break avoids the race and the visible re-prompt thatsession.idlerequires. Happy to test the draft PR when ready.For the broader hooks compat: PreToolUse/PostToolUse mapping to
tool.execute.before/tool.execute.afteris the simpler part (tool name translation + stdin protocol + exit codes). Gettingsession.stoppingright first is the right sequencing.yehudacohen commentedon Mar 10, 2026
Thanks @marcusquinn ! Did one small cleanup pass just to fix variable names, retested, and converted to a proper PR.
marcusquinn commentedon Mar 11, 2026
Thanks for pushing this to a PR. The approach is exactly what we need for reliable stop-hook parity without idle-loop races. I can validate it against the aidevops workflow this week (pre/post tool guardrails + stop-time completion checks) and report back with concrete pass/fail cases.
marcusquinn commentedon Mar 11, 2026
Correction: specifically the session.stopping lifecycle event approach. That timing avoids the idle-loop race and keeps stop-hook continuation checks inside the same loop iteration.
PwccaCode commentedon Apr 7, 2026
Question is after all this time we still dont have this ?
Seems like a pretty big deal...
qdrddr commentedon Apr 30, 2026
I’d like to define
before_llmandafter_llmhooks that let me inject additional context before the model runs and then validate afterward that the model complied with those injected constraints. For example, I could prepend a directive such as “use only these tools:” followed by an allowed‑tools list, and then verify that the model’s generated output intends to call only the permitted tools.guillempuche commentedon Jun 20, 2026
Sharing a concrete migration log from a Claude Code → opencode port as data for this issue:
Translated cleanly:
PreToolUse/PostToolUseblock/approve →tool.execute.before(bonus: also covers MCP tools). Formatters (biome, dprint) → nativeformatterconfig.SessionStartfor new sessions →session.created.Needed workarounds: Soft
systemMessagereminders (lingui extract, domain schema migration check) →experimental.session.compacting(reaches the AI but only at compaction time, not per-edit). Branch context →experimental.chat.system.transform.No equivalent found:
WorktreeRemoveteardown (drop provisioned DB + storage bucket),SubagentStopTypeScript check,Stophook re-entry.Reference:
.claude/settings.json(source hooks)Necmttn commentedon Jun 21, 2026
A compatibility test matrix would keep this from becoming best-effort glue. For
PreToolUse,PostToolUse, andStop, pin payload shape, stdin encoding, exit-code semantics, stdout/stderr handling, timeout behavior, and whether verdicts block, warn, or mutate input/output.Also include subagent/tool-nesting cases and MCP tools, since Claude hooks often guard by tool name plus transcript path. The migration story is only credible if a hook author can predict each event boundary.
Generated with ax.
lukelabonte commentedon Jul 14, 2026
Use case: Compositional bash approval to reduce permission prompts
When an agent uses compound bash commands like
echo "label" && ls .claude/skills/ | head -20, each chained/piped invocation triggers a separatebash: askpermission prompt. Even when every atomic command in the chain would individually pass the permission rules (all paths within the workspace), the compound command as a whole requires user approval.What a PreToolUse hook could do:
&&,|,;)This would eliminate unnecessary approval prompts for routine verification workflows (checking diffs, listing files, verifying grep output) while still blocking genuinely risky compositions.
Current workaround: Agents avoid compound commands by using single-purpose
bashcalls and the dedicated file-operation tools (grep,glob,read) instead ofrg/ls/catin bash. This works but forces verbose, fragmented agent behavior.shridipavansaber commentedon Aug 9, 2026
Sharing a concrete use case for PostToolUse, in case it helps scope the implementation: tailtest, a plugin that generates and runs tests on every file the agent edits.
What it needs from the hook
After each
Edit,MultiEdit, orWritecall, the handler reads the JSON payload from stdin and pulls the edited path out oftool_input.file_path, falling back totool_input.edits[].file_pathfor the multi-edit shape. It then filters (skips test files, generated code, vendor dirs), queues the file, and runs the suite.Three parts of the Claude Code contract turned out to matter more than the event itself, and they are the parts worth copying deliberately:
hookSpecificOutput.additionalContextputs a message into the model's next turn deterministically, rather than hoping it reads console output.PostToolUsefires after a tool call succeeds andPostToolUseFailureafter one fails. Collapsing them into a single event makes "react to a rejected edit" impossible to express.Why this event and not the neighbours
PreToolUse is too early, the file does not exist yet. A turn-end hook works but batches: ten edited files produce one wall of failures after the fact instead of one correction per edit, inside the loop, while the model still has the context to fix it.
Happy to answer questions about the payload shape or the filtering logic if it is useful.
sjh9714 commentedon Aug 22, 2026
I shipped a userland bridge for the two events that map cleanly today.
https://github.com/sjh9714/opencode-claude-code-hooks
Add the package directly to the OpenCode plugin array.
{ "plugin": ["opencode-claude-code-hooks"] }The bridge reads the existing Claude settings at runtime, maps OpenCode tool names to Claude matcher names, sends the Claude-shaped JSON payload on stdin, honors exit code 2 and structured deny results, applies
updatedInput, and adds PostToolUse feedback to the tool result.Users who want a dry run before installing the bridge can use the compatibility CLI in dsh-movein.
I verified the bridge with real child processes and loaded the npm package through OpenCode 1.18.21. The published package is
opencode-claude-code-hooks 0.1.1.Known limits
session.idlehas the timing problem already documented in this threadifhandlers stay manualfix: address PR review findings on #57
m-naoki-m commentedon Sep 5, 2026
I wrote a small plugin that runs Claude Code hooks from
~/.claude/settings.json, to see how far the event mapping actually gets on 1.18.25. Sharing the measurements because they narrow @yehudacohen's point above to a single line.What works, verified by running
opencode runagainst real hook scripts:tool.execute.beforegives everything the hook needs, and throwing from the handler stops the tool. A hook exiting 2 produces✗ echo DENYTEST failed / Error: DENIED_BY_TEST_HOOK, and the reason reaches the model.tool.execute.afterandchat.message.session.idleand can be given a realtranscript_path: I build the Claude Code JSONL fromclient.session.messagesand my Stop hooks read it fine. One caveat worth writing down — the hook must be spawned synchronously. With an async spawn the transcript gets written but the hook's own output never lands, because run teardown outruns the child process.What does not work is the re-injection, and it is not a plugin-side problem. With debug logging I confirmed the plugin reaches
client.session.promptand no exception is thrown, yet the second turn never runs. The cause is here:https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/cli/cmd/run.ts#L793-L798
The
session.idleplugin handler runs server-side, but the CLI loop breaks on the status event without waiting for it. By the time a plugin starts a new turn the CLI has already left the loop and the process is finishing, so the continuation is never mirrored.promptandpromptAsyncboth lose the race — the former because the await never completes, the latter because nothing waits for it at all.So the gap is not "OpenCode lacks the hook events". Three of the four map well enough today that a plugin covers them. The one missing primitive is a way for a plugin to say "do not settle yet" when idle is reached in non-interactive mode.
I am happy to work on this if you want it, but the fix shape is a design call I would rather not guess at. Two directions I can see:
session.statusbefore leaving the loop, so a plugin-initiated turn is picked up.Either would also serve the Ralph/ULW continuation cases that #15267 touched. Let me know which direction you would accept and I will send a patch.