Skip to content

Native Claude Code hooks compatibility (PreToolUse, PostToolUse, Stop) #12472

Open

Description

@ArtyMcLabin

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 push without 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:

  1. Install oh-my-opencode (massive opinionated plugin that takes over the entire agent setup just for hook compat)
  2. 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:

  1. Read hooks from ~/.claude/settings.json (and settings.local.json) — parse the hooks object
  2. Map to OpenCode events:
    • PreToolUse → tool.execute.before — transform tool names (bash→Bash, edit→Edit), pipe JSON stdin, respect exit code 2 as block
    • PostToolUse → tool.execute.after — same protocol, informational
    • Stop → session.idle — provide transcript_path with 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)
  3. Disable via env var: OPENCODE_DISABLE_CLAUDE_CODE_HOOKS=1 (consistent with existing OPENCODE_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

  1. github-actions commented on Feb 6, 2026

    @github-actions
    Contributor

    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.

  2. nagy135 commented on Feb 25, 2026

    @nagy135

    can we use this Issue as a tracker for progress on this? I would definitelly want my hooks to prevent agents from running git push or specific npm commands.

  3. parkuman commented on Feb 25, 2026

    @parkuman

    This would be especially useful in shared repos where some engineers are using Claude Code while others have opted for OpenCode.

  4. ppiankov commented on Feb 26, 2026

    @ppiankov

    +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_file tools 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/Write and bypass redaction entirely. Advisory instructions ("please use pastewatch tools for secrets") are unreliable, especially with weaker models.

    With hooks (Claude Code + Cline today):

    A PreToolUse hook on Read|Write|Edit runs pastewatch-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 use pastewatch_read_file instead. 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.

  5. marcusquinn commented on Mar 1, 2026

    @marcusquinn

    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:

    1. 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.

    2. 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.

    3. PreToolUse on Bash — enforces package manager policies (blocks pip install, suggests uv; blocks npm, suggests bun).

    4. 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):

    • Prompt-level instructions ("run shellcheck after editing shell files") — unreliable, especially with weaker models or long sessions
    • Pre-commit git hooks — catches violations at commit time, not edit time (too late for the feedback loop benefit)
    • MCP tools the agent can call explicitly — works but requires the agent to remember to call them

    What we'd do with native hooks:

    The same PostToolUse lint-on-edit pattern, but using our existing linters-local.sh infrastructure. 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/PostToolUse with exit code 2 = block + stderr feedback is exactly the protocol we'd use.

  6. gl33mer commented on Mar 5, 2026

    @gl33mer

    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

  7. yehudacohen commented on Mar 8, 2026

    @yehudacohen

    The session.idle workaround for the Stop hook has a specific limitation: timing.

    When session.idle fires, the agent loop has already broken and the session is idle. A plugin can call client.session.chat to re-prompt, but this starts a new prompt invocation from outside the loop, which is why #15267 exists — in opencode run mode 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 break solves 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 internal createUserMessage path 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.

  8. marcusquinn commented on Mar 8, 2026

    @marcusquinn

    The session.stopping approach in #16626 / #16598 makes sense — firing before the break avoids the race and the visible re-prompt that session.idle requires. Happy to test the draft PR when ready.

    For the broader hooks compat: PreToolUse/PostToolUse mapping to tool.execute.before/tool.execute.after is the simpler part (tool name translation + stdin protocol + exit codes). Getting session.stopping right first is the right sequencing.

  9. yehudacohen commented on Mar 10, 2026

    @yehudacohen

    Thanks @marcusquinn ! Did one small cleanup pass just to fix variable names, retested, and converted to a proper PR.

  10. marcusquinn commented on Mar 11, 2026

    @marcusquinn

    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.

  11. marcusquinn commented on Mar 11, 2026

    @marcusquinn

    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.

  12. PwccaCode commented on Apr 7, 2026

    @PwccaCode

    Question is after all this time we still dont have this ?

    Seems like a pretty big deal...

  13. assigned and unassigned on Apr 26, 2026
  14. qdrddr commented on Apr 30, 2026

    @qdrddr

    I’d like to define before_llm and after_llm hooks 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.

  15. guillempuche commented on Jun 20, 2026

    @guillempuche

    Sharing a concrete migration log from a Claude Code → opencode port as data for this issue:

    Translated cleanly: PreToolUse/PostToolUse block/approve → tool.execute.before (bonus: also covers MCP tools). Formatters (biome, dprint) → native formatter config. SessionStart for new sessions → session.created.

    Needed workarounds: Soft systemMessage reminders (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: WorktreeRemove teardown (drop provisioned DB + storage bucket), SubagentStop TypeScript check, Stop hook re-entry.

    Reference: .claude/settings.json (source hooks)

  16. Necmttn commented on Jun 21, 2026

    @Necmttn

    A compatibility test matrix would keep this from becoming best-effort glue. For PreToolUse, PostToolUse, and Stop, 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.

  17. lukelabonte commented on Jul 14, 2026

    @lukelabonte

    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 separate bash: ask permission 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:

    1. Tokenize the compound bash command into individual commands (split on &&, |, ;)
    2. Check each atomic command against the existing allow/deny rules as if run standalone
    3. Trace pipe boundaries — if a pipe sends data from an allowed command to one that writes outside the sandbox, still require approval
    4. Auto-approve when every leaf command passes individually and no cross-boundary data flow exists

    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 bash calls and the dedicated file-operation tools (grep, glob, read) instead of rg/ls/cat in bash. This works but forces verbose, fragmented agent behavior.

  18. shridipavansaber commented on Aug 9, 2026

    @shridipavansaber

    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, or Write call, the handler reads the JSON payload from stdin and pulls the edited path out of tool_input.file_path, falling back to tool_input.edits[].file_path for 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:

    1. Exit-code semantics are not Unix semantics. Only exit 2 is blocking: stderr is fed back to the model as an error. Exit 1 is treated as non-blocking and the run simply continues, which is the opposite of what most script authors assume. A test runner whose exit code is passed through directly will silently do nothing on a failing suite unless it is mapped to 2.
    2. Structured stdout beats a printed summary. Returning JSON with hookSpecificOutput.additionalContext puts a message into the model's next turn deterministically, rather than hoping it reads console output.
    3. Success and failure are separate events. PostToolUse fires after a tool call succeeds and PostToolUseFailure after 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.

  19. sjh9714 commented on Aug 22, 2026

    @sjh9714

    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.

    npx claude-to-opencode --hooks-only
    npx claude-to-opencode --hooks-only --apply

    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

    • Stop is not bridged because session.idle has the timing problem already documented in this thread
    • prompt, agent, HTTP, async, and conditional if handlers stay manual
    • allow and ask decisions do not bypass OpenCode permissions
    • this is a userland workaround, not a substitute for a native compatibility contract
  20. added a commit that references this issue on Aug 26, 2026
  21. m-naoki-m commented on Sep 5, 2026

    @m-naoki-m

    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 run against real hook scripts:

    • PreToolUse is complete. tool.execute.before gives 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.
    • PostToolUse and UserPromptSubmit map cleanly onto tool.execute.after and chat.message.
    • Stop fires on session.idle and can be given a real transcript_path: I build the Claude Code JSONL from client.session.messages and 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.prompt and 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

    if (
      event.type === "session.status" &&
      event.properties.sessionID === sessionID &&
      event.properties.status.type === "idle"
    ) {
      break
    }

    The session.idle plugin 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. prompt and promptAsync both 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:

    1. Let the idle handler defer the break — e.g. the CLI waits briefly for a subsequent non-idle session.status before leaving the loop, so a plugin-initiated turn is picked up.
    2. Give plugins an explicit continuation signal that the run loop honours, rather than inferring it from status transitions.

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions