How the Conversion Pipeline Handles Hooks and Events in the Compound Engineering Plugin

The conversion pipeline processes Claude hooks through three stages: loading and merging hook definitions from src/parsers/claude.ts, mapping Claude hook events to OpenCode events via the HOOK_EVENT_MAP in src/converters/claude-to-opencode.ts, and rendering TypeScript handler blocks that become executable OpenCode plugins.

The Compound Engineering Plugin converts Claude Code plugins into OpenCode bundles, transforming proprietary hook definitions into standardized event handlers. When a plugin contains Claude-style hooks, the conversion pipeline handles the translation through a systematic three-stage process that preserves the original logic while adapting it to the OpenCode event system.

Stage 1: Loading and Merging Claude Hook Definitions

The pipeline begins by ingesting hook definitions from the source Claude plugin. The loadHooks function in src/parsers/claude.ts (lines 21-45) reads the default hooks/hooks.json file and any additional hook files referenced in the plugin manifest.

const hooks = await loadHooks(root, manifest.hooks)   // src/parsers/claude.ts

If a hooks field is present in plugin.json or a hooks/hooks.json file exists, both are read and merged via mergeHooks into a single ClaudeHooks object. This unified structure serves as the input for the subsequent conversion stages.

Stage 2: Mapping Claude Hook Events to OpenCode Events

Once loaded, the pipeline translates Claude hook event names into their OpenCode equivalents. The convertClaudeToOpenCode function calls convertHooks, which references the HOOK_EVENT_MAP constant defined in src/converters/claude-to-opencode.ts (lines 48-62).

const HOOK_EVENT_MAP: Record<string, HookEventMapping> = {
  PreToolUse:   { events: ["tool.execute.before"], type: "tool" },
  PostToolUse:  { events: ["tool.execute.after"],  type: "tool" },
  // … other mappings …
};

Each Claude hook name (e.g., PreToolUse) maps to one or more OpenCode event identifiers (tool.execute.before). The mapping also specifies the handler type—such as tool, session, permission, or message—which determines how the subsequent code generation handles conditional logic and action rendering. Unmapped events are collected and added as comments in the generated file for debugging purposes.

Stage 3: Rendering Hook Handlers

The final stage generates executable TypeScript code. For every matching event, renderHookHandlers creates a handler block. The function iterates over Claude matchers, builds conditional statements, and embeds hook actions (command, prompt, or agent).

for (const [eventName, matchers] of Object.entries(hookMap)) {
  const mapping = HOOK_EVENT_MAP[eventName];
  if (!mapping) { unmappedEvents.push(eventName); continue; }

  for (const event of mapping.events) {
    handlerBlocks.push(
      renderHookHandlers(event, matchers, {
        useToolMatcher: mapping.type === "tool" || mapping.type === "permission",
        requireError: !!mapping.requireError,
        note: mapping.note,
      })
    );
  }
}

When the hook is a tool hook, the matcher's matcher string is split into individual tool names. If a specific tool list is present, the pipeline generates a condition such as input.tool === "read" || input.tool === "write".

Action rendering depends on the hook.type:

  • commandawait $\`` (optionally guarded by the condition)
  • prompt → a comment describing the prompt
  • agent → a comment describing the agent hook

The result is a TypeScript object where each OpenCode event is an async handler returning the composed statements. This file (converted-hooks.ts) becomes part of the OpenCode bundle returned by convertClaudeToOpenCode and is written to the target output directory via src/targets/opencode.ts.

Code Examples

Example Claude Hook Definition

Here is a typical Claude hook configuration in hooks/hooks.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "read | write",
        "hooks": [
          { "type": "command", "command": "log-start", "timeout": 5 }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "prompt", "prompt": "User said: {{input.message}}" }
        ]
      }
    ]
  }
}

Generated OpenCode Hook File

The pipeline generates the following converted-hooks.ts:

// Unmapped Claude hook events: … (none in this example)
import type { Plugin } from "@opencode-ai/plugin"

export const ConvertedHooks: Plugin = async ({ $ }) => {
  return {
    "tool.execute.before": async (input) => {
      // PreToolUse → tool.execute.before
      if (input.tool === "read" || input.tool === "write") { await $`log-start` }
      // timeout: 5s (not enforced)
    },
    "message.created": async (input) => {
      // UserPromptSubmit → message.created
      // Prompt hook for *: User said: {{input.message}}
    }
  }
}

export default ConvertedHooks

Notice how the matcher becomes a conditional if statement, and each Claude hook type is turned into a comment or an executable command.

Summary

  • The conversion pipeline processes Claude hooks through three distinct stages: loading and merging definitions, mapping events via HOOK_EVENT_MAP, and rendering TypeScript handlers.
  • The loadHooks function in src/parsers/claude.ts ingests and merges hook files into a unified ClaudeHooks object.
  • The HOOK_EVENT_MAP in src/converters/claude-to-opencode.ts translates Claude event names like PreToolUse into OpenCode events like tool.execute.before.
  • The renderHookHandlers function generates conditional logic and action blocks, converting command hooks into executable shell calls and prompt/agent hooks into descriptive comments.
  • The final output is a converted-hooks.ts file exported as an OpenCode plugin via src/targets/opencode.ts.

Frequently Asked Questions

How does the conversion pipeline handle unsupported Claude hook events?

Unmapped events are collected during the conversion process and added as comments at the top of the generated converted-hooks.ts file. This allows developers to identify which hooks require manual implementation while ensuring the conversion does not fail on unrecognized event types.

What is the difference between tool hooks and message hooks in the conversion process?

Tool hooks (such as PreToolUse and PostToolUse) trigger the useToolMatcher flag in renderHookHandlers, which generates conditional statements checking input.tool against specific tool names. Message hooks (such as UserPromptSubmit) map to message.created events and typically render as comments describing the prompt content rather than executable conditions.

Can the conversion pipeline handle multiple hook files?

Yes. The loadHooks function in src/parsers/claude.ts reads both the default hooks/hooks.json file and any additional hook files referenced in the plugin manifest's hooks field. The mergeHooks utility combines these into a single ClaudeHooks object, allowing distributed hook definitions to be consolidated before conversion.

How are command timeouts handled in the generated OpenCode plugin?

The conversion pipeline preserves timeout values as comments in the generated code (e.g., // timeout: 5s (not enforced)), but does not implement automatic timeout enforcement in the rendered handler. Developers must manually implement timeout logic in the OpenCode environment if strict timeout control is required for command hooks.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →