{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreig6ch65goi44jdkjqkuk3vgbqfv7dgniviu6pmuot3kqxp5um6y5i",
    "uri": "at://did:plc:6u4awktizhivwgqxl5j67h4k/app.bsky.feed.post/3meow3r2qr4e2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreiers3eiopnk5ygn4v5wlzig3qjoiufs3tozanm5qzhf3ytm3fm4jq"
    },
    "mimeType": "image/webp",
    "size": 92300
  },
  "description": "I needed a plugin that could run a multi-phase workflow autonomously — start a task, wait for it to finish, check the result, start the next one. I built it in Claude Code using Stop and SubagentStop hooks, then rebuilt it in OpenCode using session.idle events. Both work. The orchestration patterns are different, and so are the tradeoffs.\n\n\nThis post covers the general pattern. The examples are distilled from both implementations but apply to any workflow where phases run sequentially, each prod",
  "path": "/event-driven-claude-code-and-opencode-workflows-with-hooks/",
  "publishedAt": "2026-02-12T21:17:14.000Z",
  "site": "https://www.subaud.io",
  "textContent": "I needed a plugin that could run a multi-phase workflow autonomously — start a task, wait for it to finish, check the result, start the next one. I built it in Claude Code using `Stop` and `SubagentStop` hooks, then rebuilt it in OpenCode using `session.idle` events. Both work. The orchestration patterns are different, and so are the tradeoffs.\n\nThis post covers the general pattern. The examples are distilled from both implementations but apply to any workflow where phases run sequentially, each producing an artifact that gates the next.\n\n## The pattern\n\nA workflow has ordered phases. Each phase produces an artifact file on disk. The orchestrator detects when a phase finishes, checks that the artifact exists, and starts the next phase:\n\n\n    phase_a  → result-a.json\n    phase_b  → result-b.json\n    phase_c  → result-c.json\n    phase_d  → final-output.md\n\n\nSome phases have sub-loops — multiple steps that each run independently, with retries on failure. The orchestrator needs to handle both the phase-to-phase transitions and the step-to-step loop within a single phase.\n\n## Claude Code: Stop and SubagentStop hooks\n\nClaude Code plugins declare hooks in `plugin.json`:\n\n\n    {\n      \"hooks\": {\n        \"Stop\": [\n          {\n            \"hooks\": [\n              {\n                \"type\": \"command\",\n                \"command\": \"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/stop_hook.py\",\n                \"timeout\": 10\n              }\n            ]\n          }\n        ],\n        \"SubagentStop\": [\n          {\n            \"hooks\": [\n              {\n                \"type\": \"command\",\n                \"command\": \"python3 ${CLAUDE_PLUGIN_ROOT}/hooks/subagent_stop_hook.py\",\n                \"timeout\": 10\n              }\n            ]\n          }\n        ]\n      }\n    }\n\n\n`Stop` fires when Claude finishes responding. Exit code 0 means \"done, let Claude stop.\" Exit code 2 means \"reinject the prompt I printed to stdout.\" That's the continuation mechanism — the hook checks state, decides what comes next, prints the prompt for the next phase, and exits 2.\n\n`SubagentStop` fires when a subagent finishes. In phases that use a step loop, each step runs as a subagent. When the subagent stops, `SubagentStop` does the bookkeeping — archives the result, updates state — and exits 0. Then `Stop` fires on the main conversation, reads the updated state, and injects the next step's prompt.\n\nThe split matters. `SubagentStop` handles state changes but doesn't inject prompts, because exit code 2 from `SubagentStop` would go to the subagent's context, not the main conversation. `Stop` handles prompt injection into the main conversation based on whatever state `SubagentStop` left behind.\n\n### The Stop hook\n\nThis is the core of the Claude Code orchestrator. It runs on every response:\n\n\n    def run_hook(logger):\n        ctx = get_session_context()\n        if not ctx:\n            return ExitCode.ALLOW_EXIT.value\n\n        ctx.state = load_state(ctx)\n        if not ctx.state or not ctx.state.workflow_active:\n            return ExitCode.ALLOW_EXIT.value\n\n        # Check if current phase artifact exists → transition\n        result = check_phase_completion(ctx, logger)\n        if result is not None:\n            return result\n\n        # Check if we need to start current phase\n        result = check_phase_startup(ctx, logger)\n        if result is not None:\n            return result\n\n        # Handle step loop phases\n        if ctx.state.phase == Phase.EXECUTE:\n            result = handle_step_loop(ctx, logger)\n            if result is not None:\n                return result\n\n        return ExitCode.ALLOW_EXIT.value\n\n\n`check_phase_completion` looks for the current phase's artifact. If it exists, the hook transitions to the next phase, generates a startup prompt, prints it, and returns exit code 2:\n\n\n    def check_phase_completion(ctx, logger):\n        state = ctx.state\n        current_artifact = WorkflowNavigator.get_artifact(state.phase)\n        if not current_artifact:\n            return None\n\n        artifact_path = ctx.output_dir / current_artifact\n        if not artifact_path.exists():\n            return None\n\n        next_phase = WorkflowNavigator.get_next_phase(state.phase)\n        ctx.state = update_state_fields(ctx, phase=next_phase)\n\n        prompt = generate_startup_prompt(ctx, next_phase, logger)\n        if prompt:\n            print(prompt)\n            return ExitCode.REINJECT_PROMPT.value\n\n        return ExitCode.ALLOW_EXIT.value\n\n\n### The SubagentStop hook\n\nThis handles step loops. When a step subagent finishes, it processes the result:\n\n\n    def run_hook(logger):\n        ctx = get_session_context()\n        if not ctx:\n            return ExitCode.ALLOW_EXIT.value\n\n        ctx.state = load_state(ctx)\n        if not ctx.state or not ctx.state.workflow_active:\n            return ExitCode.ALLOW_EXIT.value\n\n        if ctx.state.phase != Phase.EXECUTE:\n            return ExitCode.ALLOW_EXIT.value\n\n        step_result_file = ctx.output_dir / \"step-result.json\"\n        if not step_result_file.exists():\n            return ExitCode.ALLOW_EXIT.value\n\n        process_step_result(ctx, step_result_file, logger)\n        return ExitCode.ALLOW_EXIT.value\n\n\n`process_step_result` archives the result and advances state — next step, retry, or phase complete:\n\n\n    def handle_step_success(ctx, current_step, total_steps, logger):\n        next_step = current_step + 1\n        if next_step < total_steps:\n            update_state_fields(ctx, current_step=next_step, step_attempts=0)\n        else:\n            update_state_fields(ctx, phase=Phase.PHASE_C)\n\n    def handle_step_failure(ctx, current_step, step_attempts, total_steps, max_attempts, logger):\n        next_attempt = step_attempts + 1\n        if next_attempt < max_attempts:\n            update_state_fields(ctx, step_attempts=next_attempt)\n        else:\n            next_step = current_step + 1\n            if next_step < total_steps:\n                update_state_fields(ctx, current_step=next_step, step_attempts=0)\n            else:\n                update_state_fields(ctx, phase=Phase.PHASE_C)\n\n\nIt always exits 0. The `Stop` hook fires next, reads the state that `SubagentStop` updated, and injects the appropriate prompt.\n\n## OpenCode: session.idle events\n\nOpenCode plugins register an event handler in TypeScript:\n\n\n    let _pluginLoaded = false;\n\n    export const WorkflowPlugin: Plugin = async ({ client, directory }) => {\n      if (_pluginLoaded) return {};\n      _pluginLoaded = true;\n\n      const orchestrator = new Orchestrator(client, directory);\n\n      return {\n        event: async ({ event }) => {\n          if (event.type === 'session.updated') return;\n\n          if (event.type === 'session.idle') {\n            const props = event.properties as { sessionID?: string };\n            if (props.sessionID) {\n              await orchestrator.onSessionIdle(props.sessionID);\n            }\n          }\n        },\n      };\n    };\n\n\n`session.idle` fires when a session finishes and has nothing left to do. Instead of printing a prompt to stdout and returning an exit code, the plugin calls `session.create()` and `session.promptAsync()` directly to spawn the next phase in a new child session.\n\n### The orchestrator\n\n\n    class Orchestrator {\n      private advancing = false;\n      private sessionIds = new Set<string>();\n\n      async onSessionIdle(sessionId: string): Promise<void> {\n        if (!this.sessionIds.has(sessionId)) return;\n        if (this.advancing) return;\n\n        this.advancing = true;\n        try {\n          await this.checkAndAdvance(sessionId);\n        } finally {\n          this.advancing = false;\n        }\n      }\n\n      private async checkAndAdvance(_sessionId: string): Promise<void> {\n        const state = await this.loadState();\n        if (!state || !state.active) return;\n        if (state.phase === 'completed') return;\n\n        if (state.stopRequested) {\n          await this.saveState({ ...state, active: false });\n          return;\n        }\n\n        const artifact = PHASE_ARTIFACTS[state.phase];\n        if (artifact) {\n          const exists = await this.fileExists(`${state.outputDir}/${artifact}`);\n          if (!exists) return;\n        }\n\n        const nextPhase = getNextPhase(state.phase);\n        await this.saveState({ ...state, phase: nextPhase });\n\n        if (nextPhase === 'completed') return;\n\n        await this.createSessionAndPrompt(state, nextPhase);\n      }\n    }\n\n\nThe logic is the same as the `Stop` hook — check artifact, advance phase, generate prompt. The difference is execution model. The `Stop` hook prints a prompt and returns exit code 2. The orchestrator calls `session.create()` and `session.promptAsync()`:\n\n\n    private async createSessionAndPrompt(\n      state: WorkflowState,\n      phase: string,\n      prompt: string,\n    ): Promise<void> {\n      const result = await this.client.session.create({\n        body: {\n          parentID: state.parentSessionId,\n          title: `Workflow: ${phase}`,\n        },\n      });\n\n      const newSessionId = result.data?.id;\n      if (!newSessionId) return;\n\n      this.sessionIds.add(newSessionId);\n      await new Promise((resolve) => setTimeout(resolve, 500));\n\n      await this.client.session.promptAsync({\n        path: { id: newSessionId },\n        body: { parts: [{ type: 'text', text: prompt }] },\n      });\n    }\n\n\nEach phase runs in its own child session — fresh context, full token budget, no carry-over from previous phases.\n\n### Step loops\n\nSame pattern as Claude Code. Each step writes a result file, the orchestrator reads it, archives it, and decides: advance, retry, or skip.\n\n\n    private async handleStep(state: WorkflowState): Promise<void> {\n      let stepData: StepResult | null = null;\n      try {\n        const raw = await readFile(`${state.outputDir}/step-result.json`, 'utf-8');\n        stepData = JSON.parse(raw);\n      } catch {\n        return;\n      }\n\n      await this.archiveStepResult(state, stepData);\n      await unlink(`${state.outputDir}/step-result.json`);\n\n      if (stepData.success) {\n        const nextStep = state.currentStep + 1;\n        if (nextStep < state.totalSteps) {\n          await this.saveState({ ...state, currentStep: nextStep, stepAttempts: 0 });\n          await this.createSessionAndPrompt(state, `step-${nextStep + 1}`, stepPrompt);\n        } else {\n          await this.transitionToNextPhase(state);\n        }\n      } else {\n        const nextAttempt = state.stepAttempts + 1;\n        if (nextAttempt < state.maxStepAttempts) {\n          await this.saveState({ ...state, stepAttempts: nextAttempt });\n          await this.createSessionAndPrompt(state, `retry-step-${state.currentStep + 1}`, retryPrompt);\n        } else {\n          await this.saveState({ ...state, currentStep: state.currentStep + 1, stepAttempts: 0 });\n          await this.createSessionAndPrompt(state, `step-${state.currentStep + 2}`, stepPrompt);\n        }\n      }\n    }\n\n\nIn Claude Code, this is split across two hooks — `SubagentStop` updates state, `Stop` injects prompts. In OpenCode, the orchestrator does both in one place because it has direct access to `session.create()`.\n\n### Stop and resume\n\nThe orchestrator supports stop/resume through a flag in state:\n\n\n    async stop(): Promise<string> {\n      const state = await this.loadState();\n      if (!state?.active) return 'Not running.';\n\n      await this.saveState({ ...state, stopRequested: new Date().toISOString() });\n      return 'Stop requested. Current phase will finish.';\n    }\n\n    async resume(sessionId: string): Promise<string> {\n      const state = await this.loadState();\n      if (!state) return 'No session found.';\n\n      const updated = await this.saveState({\n        ...state,\n        stopRequested: undefined,\n        active: true,\n        parentSessionId: sessionId,\n      });\n\n      const artifactExists = await this.checkArtifact(updated.phase);\n      if (artifactExists) {\n        await this.checkAndAdvance(sessionId);\n      } else {\n        const prompt = buildPhasePrompt(updated, updated.phase);\n        await this.createSessionAndPrompt(updated, updated.phase, prompt);\n      }\n\n      return `Resumed from phase: ${updated.phase}`;\n    }\n\n\n`checkAndAdvance` checks for the flag early and halts if set. Resume clears the flag, re-parents to the current session, and either advances or re-runs the current phase depending on whether its artifact exists.\n\n## What's different\n\nBoth implementations use the same state machine — artifact-gated phase transitions with retry logic on step loops. The differences are in how they interface with the host:\n\n**Continuation mechanism** — Claude Code: print a prompt to stdout, exit code 2. OpenCode: call `session.create()` + `session.promptAsync()`. The Claude Code approach is a text protocol between a shell command and the host. The OpenCode approach is a TypeScript API.\n\n**Context isolation** — Both get fresh context per phase. In Claude Code, skills declare `context: fork` in their frontmatter, which gives each phase invocation a clean context window. In OpenCode, each phase runs in a child session created with `session.create()`. The mechanism is different — skill-level forking vs. explicit session creation — but the result is the same: no phase inherits the previous phase's token usage.\n\n**Hook split** — Claude Code needs two hooks for step loop phases. `SubagentStop` does state updates when a step subagent finishes, `Stop` reads that state and injects the next prompt. Exit code 2 from `SubagentStop` would go to the subagent, not the main conversation, so the work has to be split. In OpenCode, the orchestrator handles both because `session.idle` fires in the plugin's event handler regardless of which session went idle.\n\n**Guards** — Claude Code hooks are shell commands invoked by the host — no re-entrancy risk, no double-load. OpenCode plugins are long-lived TypeScript processes, so they need a `_pluginLoaded` guard (prevents double registration), an `advancing` boolean (prevents re-entrant `checkAndAdvance` calls), a `sessionIds` set (filters idle events from unrelated sessions), and an immediate return on `session.updated` events (prevents infinite loops when updating session titles).\n\n**Language** — Claude Code hooks: Python. OpenCode plugins: TypeScript. The state format (`state.json`) and artifact files are identical across both.\n\nBoth Claude Code and OpenCode can support this type of workflow using their respective hooks mechanisms.",
  "title": "Event-Driven Claude Code and OpenCode Workflows with Hooks",
  "updatedAt": "2026-02-12T21:17:14.781Z"
}