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.
This 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.
The pattern
A 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:
phase_a → result-a.json
phase_b → result-b.json
phase_c → result-c.json
phase_d → final-output.md
Some 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.
Claude Code: Stop and SubagentStop hooks
Claude Code plugins declare hooks in plugin.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/stop_hook.py",
"timeout": 10
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/subagent_stop_hook.py",
"timeout": 10
}
]
}
]
}
}
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.
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.
The 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.
The Stop hook
This is the core of the Claude Code orchestrator. It runs on every response:
def run_hook(logger):
ctx = get_session_context()
if not ctx:
return ExitCode.ALLOW_EXIT.value
ctx.state = load_state(ctx)
if not ctx.state or not ctx.state.workflow_active:
return ExitCode.ALLOW_EXIT.value
# Check if current phase artifact exists → transition
result = check_phase_completion(ctx, logger)
if result is not None:
return result
# Check if we need to start current phase
result = check_phase_startup(ctx, logger)
if result is not None:
return result
# Handle step loop phases
if ctx.state.phase == Phase.EXECUTE:
result = handle_step_loop(ctx, logger)
if result is not None:
return result
return ExitCode.ALLOW_EXIT.value
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:
def check_phase_completion(ctx, logger):
state = ctx.state
current_artifact = WorkflowNavigator.get_artifact(state.phase)
if not current_artifact:
return None
artifact_path = ctx.output_dir / current_artifact
if not artifact_path.exists():
return None
next_phase = WorkflowNavigator.get_next_phase(state.phase)
ctx.state = update_state_fields(ctx, phase=next_phase)
prompt = generate_startup_prompt(ctx, next_phase, logger)
if prompt:
print(prompt)
return ExitCode.REINJECT_PROMPT.value
return ExitCode.ALLOW_EXIT.value
The SubagentStop hook
This handles step loops. When a step subagent finishes, it processes the result:
def run_hook(logger):
ctx = get_session_context()
if not ctx:
return ExitCode.ALLOW_EXIT.value
ctx.state = load_state(ctx)
if not ctx.state or not ctx.state.workflow_active:
return ExitCode.ALLOW_EXIT.value
if ctx.state.phase != Phase.EXECUTE:
return ExitCode.ALLOW_EXIT.value
step_result_file = ctx.output_dir / "step-result.json"
if not step_result_file.exists():
return ExitCode.ALLOW_EXIT.value
process_step_result(ctx, step_result_file, logger)
return ExitCode.ALLOW_EXIT.value
process_step_result archives the result and advances state — next step, retry, or phase complete:
def handle_step_success(ctx, current_step, total_steps, logger):
next_step = current_step + 1
if next_step < total_steps:
update_state_fields(ctx, current_step=next_step, step_attempts=0)
else:
update_state_fields(ctx, phase=Phase.PHASE_C)
def handle_step_failure(ctx, current_step, step_attempts, total_steps, max_attempts, logger):
next_attempt = step_attempts + 1
if next_attempt < max_attempts:
update_state_fields(ctx, step_attempts=next_attempt)
else:
next_step = current_step + 1
if next_step < total_steps:
update_state_fields(ctx, current_step=next_step, step_attempts=0)
else:
update_state_fields(ctx, phase=Phase.PHASE_C)
It always exits 0. The Stop hook fires next, reads the state that SubagentStop updated, and injects the appropriate prompt.
OpenCode: session.idle events
OpenCode plugins register an event handler in TypeScript:
let _pluginLoaded = false;
export const WorkflowPlugin: Plugin = async ({ client, directory }) => {
if (_pluginLoaded) return {};
_pluginLoaded = true;
const orchestrator = new Orchestrator(client, directory);
return {
event: async ({ event }) => {
if (event.type === 'session.updated') return;
if (event.type === 'session.idle') {
const props = event.properties as { sessionID?: string };
if (props.sessionID) {
await orchestrator.onSessionIdle(props.sessionID);
}
}
},
};
};
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.
The orchestrator
class Orchestrator {
private advancing = false;
private sessionIds = new Set<string>();
async onSessionIdle(sessionId: string): Promise<void> {
if (!this.sessionIds.has(sessionId)) return;
if (this.advancing) return;
this.advancing = true;
try {
await this.checkAndAdvance(sessionId);
} finally {
this.advancing = false;
}
}
private async checkAndAdvance(_sessionId: string): Promise<void> {
const state = await this.loadState();
if (!state || !state.active) return;
if (state.phase === 'completed') return;
if (state.stopRequested) {
await this.saveState({ ...state, active: false });
return;
}
const artifact = PHASE_ARTIFACTS[state.phase];
if (artifact) {
const exists = await this.fileExists(`${state.outputDir}/${artifact}`);
if (!exists) return;
}
const nextPhase = getNextPhase(state.phase);
await this.saveState({ ...state, phase: nextPhase });
if (nextPhase === 'completed') return;
await this.createSessionAndPrompt(state, nextPhase);
}
}
The 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():
private async createSessionAndPrompt(
state: WorkflowState,
phase: string,
prompt: string,
): Promise<void> {
const result = await this.client.session.create({
body: {
parentID: state.parentSessionId,
title: `Workflow: ${phase}`,
},
});
const newSessionId = result.data?.id;
if (!newSessionId) return;
this.sessionIds.add(newSessionId);
await new Promise((resolve) => setTimeout(resolve, 500));
await this.client.session.promptAsync({
path: { id: newSessionId },
body: { parts: [{ type: 'text', text: prompt }] },
});
}
Each phase runs in its own child session — fresh context, full token budget, no carry-over from previous phases.
Step loops
Same pattern as Claude Code. Each step writes a result file, the orchestrator reads it, archives it, and decides: advance, retry, or skip.
private async handleStep(state: WorkflowState): Promise<void> {
let stepData: StepResult | null = null;
try {
const raw = await readFile(`${state.outputDir}/step-result.json`, 'utf-8');
stepData = JSON.parse(raw);
} catch {
return;
}
await this.archiveStepResult(state, stepData);
await unlink(`${state.outputDir}/step-result.json`);
if (stepData.success) {
const nextStep = state.currentStep + 1;
if (nextStep < state.totalSteps) {
await this.saveState({ ...state, currentStep: nextStep, stepAttempts: 0 });
await this.createSessionAndPrompt(state, `step-${nextStep + 1}`, stepPrompt);
} else {
await this.transitionToNextPhase(state);
}
} else {
const nextAttempt = state.stepAttempts + 1;
if (nextAttempt < state.maxStepAttempts) {
await this.saveState({ ...state, stepAttempts: nextAttempt });
await this.createSessionAndPrompt(state, `retry-step-${state.currentStep + 1}`, retryPrompt);
} else {
await this.saveState({ ...state, currentStep: state.currentStep + 1, stepAttempts: 0 });
await this.createSessionAndPrompt(state, `step-${state.currentStep + 2}`, stepPrompt);
}
}
}
In 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().
Stop and resume
The orchestrator supports stop/resume through a flag in state:
async stop(): Promise<string> {
const state = await this.loadState();
if (!state?.active) return 'Not running.';
await this.saveState({ ...state, stopRequested: new Date().toISOString() });
return 'Stop requested. Current phase will finish.';
}
async resume(sessionId: string): Promise<string> {
const state = await this.loadState();
if (!state) return 'No session found.';
const updated = await this.saveState({
...state,
stopRequested: undefined,
active: true,
parentSessionId: sessionId,
});
const artifactExists = await this.checkArtifact(updated.phase);
if (artifactExists) {
await this.checkAndAdvance(sessionId);
} else {
const prompt = buildPhasePrompt(updated, updated.phase);
await this.createSessionAndPrompt(updated, updated.phase, prompt);
}
return `Resumed from phase: ${updated.phase}`;
}
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.
What's different
Both 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:
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.
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.
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.
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).
Language — Claude Code hooks: Python. OpenCode plugins: TypeScript. The state format (state.json) and artifact files are identical across both.
Both Claude Code and OpenCode can support this type of workflow using their respective hooks mechanisms.