Run and author Agent IX workflows, with an animated stage progress bar for Claude Code.

<img src="logo.png" alt="IX Flow" width="100%" />
ix-flow runs agent workflows. A workflow is a small state machine — phases, transitions, and human gates — that your agent advances step by step, pausing for your approval where it matters. You author a workflow; your agent runs it by calling ix-flow.
Workflows are packaged as skills: a flow definition plus instructions that tell the agent how to run it. quoin, for example, ships spec skills that drive ix-flow — you invoke the skill, and the agent does the rest.
If this plugin is uninitialized or a command fails, follow the plugin setup guide for its required CLIs, configuration, and local diagnosis.
If the setup checks leave a reproducible Agent IX ix-flow bug that blocks progress, join the Agent IX Discord. Community help is a last resort for Agent IX product bugs, not a help desk for local credentials, machine setup, third party tools, or unrelated projects. See setup.md for what to include.
Install the CLI your agent calls:
npm i -g @agent-ix/ix-flow
Then add the plugin to your coding agent. The same two skills — ix-flow (runs a workflow) and ix-flow-create (authors a new one) — install into Claude Code, OpenAI Codex, opencode, and GitHub Copilot. Pick your agent below. No Anthropic API key is required — your existing agent subscription is used.
Run these inside Claude Code (they add the /ix-flow and /ix-flow-create commands):
/plugin marketplace add agent-ix/agent-plugins
/plugin install ix-flow@agent-ix
codex plugin marketplace add agent-ix/agent-plugins
codex plugin add ix-flow@agent-ix
Or browse and install ix-flow from the /plugins menu inside the Codex TUI.
Install the skills with the GitHub CLI (requires gh ≥ 2.90.0). --all installs both skills; --scope user makes them available in every repo:
gh skill install agent-ix/ix-flow --all --scope user --agent opencode
With the Copilot CLI:
copilot plugin marketplace add agent-ix/ix-flow
copilot plugin install ix-flow@ix-flow
Or install the skills with the GitHub CLI (requires gh ≥ 2.90.0):
gh skill install agent-ix/ix-flow --all --scope user --agent github-copilot
Author your own workflow below, or install one like quoin that ships its own.
A clean-room, repeatable check of the Claude Code install path lives in
smoke/— runmake install-smoke.
A workflow is two files in a skill directory:
release/
SKILL.md # instructs the agent how to run the flow
workflows/
release/
def.yaml # the flow: phases, transitions, gates
The flow (def.yaml) declares the states and the moves between them. Here a change goes draft → in_review → approved, with the final step gated on human approval:
name: release
version: 0.1.0
initialPhase: draft
phases:
- { name: draft }
- { name: in_review }
- { name: approved, terminal: true }
transitions:
- { from: draft, to: in_review, defaultGate: auto }
- { from: in_review, to: approved, defaultGate: hitl } # pauses for approval
The skill (SKILL.md) tells the agent how to run that flow:
---
name: release
description: Drive a change from draft through review to release.
metadata:
ix-flow-workflows: ./workflows
---
# /release
Start from the run status and follow the reported next actions. Advance the run through its
phases, and stop at the human gate until the change is approved.
Run /ix-flow-create and your agent scaffolds both files for you. See docs/guide.md for the full authoring reference; a complete, runnable version of this workflow is in examples/release.
Run /ix-flow <workflow> and your agent drives the run — creating it, advancing through the phases, and pausing at gates for your approval:
You: /ix-flow release
Agent: ▸ created run, advanced draft → in_review → reached the approval gate
"Ready to release. Approve?"
You: approve
Agent: ▸ recorded approval, advanced to approved
"Released."
Under the hood the agent calls ix-flow to track the run and enforce the gate. Runs persist, so the agent can resume one across sessions.
Claude Code also includes an optional animated workflow progress mod. Use /flow-status RUN_ID for a one-line phase diagram with a colored dot moving along the active connector, approval waits, and terminal completion.
def.yaml).SKILL.md).hitl transition that pauses for human approval.See docs/guide.md for the full guide — gates, invariants, interviews, artifact templates, the run lifecycle, and the complete command reference.
pnpm install
pnpm run build
pnpm test
pnpm run lint
This package builds on @agent-ix/ix-cli-core from the standalone ix-cli-core repo.
Beyond the unit tests, ix-flow uses the shared @agent-ix/cli-agent-evals toolkit to drive real coding-agent CLIs through each workflow skill end-to-end. The suite definition lives in cli-agent-evals.config.mjs; project-specific fixtures, prompts, and assertions remain under evals/.
Live runs use agent-pty + tmux and cost tokens/minutes, so they are opt-in:
make evals # canary subset (one scenario per family)
make evals-all # full corpus (EV-001..EV-022)
make eval FILTER=EV-013
make evals-rebuild
Direct CLI form:
node ../cli-agent-evals/bin/cli-evals.js run \
--suite ./cli-agent-evals.config.mjs \
--canary \
--agent claude \
--model sonnet
Agent plugin setup for authoring/running evals from an agent:
claude plugin marketplace add agent-ix/agent-plugins
claude plugin install cli-agent-evals@agent-ix
codex plugin marketplace add agent-ix/agent-plugins
codex plugin add cli-agent-evals@agent-ix
gh skill install agent-ix/cli-agent-evals --all --scope user --agent opencode
gh skill install agent-ix/cli-agent-evals --all --scope user --agent github-copilot
Minimal integration pattern:
import { defineSuite } from "../cli-agent-evals/dist/index.js";
import { SCENARIOS } from "./evals/scenarios/index.mjs";
export default defineSuite({
name: "ix-flow",
rootDir: import.meta.dirname,
scenarios: SCENARIOS,
});
MIT — see LICENSE.
hooks/register.ts 534 lines1import type { EngineInterface, On } from "claude-code";
2
3// Read-only workflow HUD. Only progress queries are launched by this mod.
4export interface ProgressRun {
5 id: string;
6 name?: string;
7 defName?: string;
8 phase: string;
9 stateVersion: number;
10 phases: Array<{ name: string; terminal?: boolean }>;
11 transitions: Array<{ from: string; to: string }>;
12 events: Array<{ kind: string; payload: Record<string, unknown> }>;
13 openGates?: Array<{ to: string }>;
14}
15export interface ProgressOptions {
16 frame?: number;
17 working?: boolean;
18 blocked?: boolean;
19 width?: number;
20}
21export interface ProgressSegment {
22 text: string;
23 kind:
24 | "label"
25 | "active"
26 | "done"
27 | "pending"
28 | "chaser"
29 | "connector"
30 | "status";
31 completed?: boolean;
32}
33type RGB = readonly [number, number, number];
34type Selection = { id: string; stateDir?: string };
35
36function record(value: unknown): value is Record<string, unknown> {
37 return typeof value === "object" && value !== null && !Array.isArray(value);
38}
39function progressRun(value: unknown): value is ProgressRun {
40 return (
41 record(value) &&
42 typeof value.id === "string" &&
43 typeof value.phase === "string" &&
44 typeof value.stateVersion === "number" &&
45 Number.isSafeInteger(value.stateVersion) &&
46 (value.name === undefined || typeof value.name === "string") &&
47 (value.defName === undefined || typeof value.defName === "string") &&
48 Array.isArray(value.phases) &&
49 value.phases.length > 0 &&
50 value.phases.every(
51 (phase: unknown) =>
52 record(phase) &&
53 typeof phase.name === "string" &&
54 (phase.terminal === undefined || typeof phase.terminal === "boolean"),
55 ) &&
56 value.phases.some(
57 (phase: { name: string }) => phase.name === value.phase,
58 ) &&
59 Array.isArray(value.transitions) &&
60 value.transitions.every(
61 (edge: unknown) =>
62 record(edge) &&
63 typeof edge.from === "string" &&
64 typeof edge.to === "string",
65 ) &&
66 Array.isArray(value.events) &&
67 value.events.every(
68 (event: unknown) =>
69 record(event) &&
70 typeof event.kind === "string" &&
71 record(event.payload),
72 ) &&
73 (value.openGates === undefined ||
74 (Array.isArray(value.openGates) &&
75 value.openGates.every(
76 (gate: unknown) => record(gate) && typeof gate.to === "string",
77 )))
78 );
79}
80
81const FRAMES = ["•────", "─•───", "──•──", "───•─", "────•", "─────"];
82const DEMO_STAGE_TICKS = 12;
83
84function gradient(progress: number, start: RGB, end: RGB): string {
85 const t = Math.max(0, Math.min(1, progress));
86 return (
87 "#" +
88 start
89 .map((value, index) =>
90 Math.round(value + (end[index] - value) * t)
91 .toString(16)
92 .padStart(2, "0"),
93 )
94 .join("")
95 );
96}
97
98export function lineColor(progress: number): string {
99 return gradient(progress, [52, 72, 101], [119, 153, 191]);
100}
101
102export function ballColor(progress: number, frame: number): string {
103 // A shallow rise and fall in intensity during the one-way sweep.
104 const pulse = Math.round(
105 8 * Math.sin((Math.PI * (frame % FRAMES.length)) / (FRAMES.length - 1)),
106 );
107 return gradient(
108 progress,
109 [130 + pulse, 177 + pulse, 235 + pulse],
110 [174 + pulse, 222 + pulse, 247 + pulse],
111 );
112}
113
114export function progressionColor(progress: number): string {
115 const t = Math.max(0, Math.min(1, progress));
116 const rgb = [96, 140, 220].map((start, index) =>
117 Math.round(start + ([100, 190, 215][index] - start) * t),
118 );
119 return (
120 "#" + rgb.map((channel) => channel.toString(16).padStart(2, "0")).join("")
121 );
122}
123
124const clean = (value: unknown): string =>
125 String(value ?? "").replace(/[\x00-\x1f\x7f-\x9f]/g, "");
126
127function commandLocation(command: string): Pick<Selection, "stateDir"> | null {
128 const state =
129 /--state-dir(?:=|\s+)(?:"([^"$`]+)"|'([^']+)'|([^\s;&|$`]+))/.exec(command);
130 const config =
131 /--config-root(?:=|\s+)(?:"([^"$`]+)"|'([^']+)'|([^\s;&|$`]+))/.exec(
132 command,
133 );
134 if (/--state-dir\b/.test(command) && !state) return null;
135 if (/--config-root\b/.test(command) && !config) return null;
136 const stateDir = state?.[1] ?? state?.[2] ?? state?.[3];
137 const configRoot = config?.[1] ?? config?.[2] ?? config?.[3];
138 return {
139 stateDir: stateDir ?? (configRoot ? `${configRoot}/flows` : undefined),
140 };
141}
142
143/** A stable, complete stage strip; only markers, colors, and connectors change. */
144export function progressSegments(
145 run: ProgressRun,
146 {
147 frame = 0,
148 working = false,
149 blocked = false,
150 width = 120,
151 }: ProgressOptions = {},
152) {
153 const terminal = run.phases.some(
154 (phase) => phase.name === run?.phase && phase.terminal,
155 );
156 const waiting = (run.openGates?.length ?? 0) > 0;
157 const moving = working && !blocked && !waiting && !terminal;
158 const completed = new Set(
159 (run.events ?? [])
160 .filter((event) => event.kind === "phase.advanced")
161 .map((event) => event.payload.from),
162 );
163 const prefix = `[${clean(run.name || run.defName)}] `;
164 // Label sizes depend on the complete definition, never the current stage.
165 const labelBudget = Math.max(
166 1,
167 Math.floor(
168 (width -
169 prefix.length -
170 12 -
171 (run.phases.length - 1) * 7 -
172 run.phases.length * 2) /
173 run.phases.length,
174 ),
175 );
176 const segments: ProgressSegment[] = [{ text: prefix, kind: "label" }];
177 run.phases.forEach((phase, index) => {
178 const active = phase.name === run?.phase;
179 const done = active ? terminal : completed.has(phase.name);
180 const marker = active
181 ? blocked
182 ? "⊗"
183 : waiting
184 ? "◇"
185 : terminal
186 ? "✓"
187 : "⊙"
188 : done
189 ? "✓"
190 : "○";
191 const name = clean(phase.name);
192 const label =
193 name.length > labelBudget
194 ? name.slice(0, Math.max(0, labelBudget - 1)) + "…"
195 : name;
196 segments.push({
197 text: `${marker} ${label}`,
198 kind: active && !terminal ? "active" : done ? "done" : "pending",
199 });
200 if (index < run.phases.length - 1) {
201 const adjacent = run.transitions.some(
202 (edge) =>
203 edge.from === phase.name && edge.to === run.phases[index + 1].name,
204 );
205 const track = adjacent
206 ? active && moving
207 ? FRAMES[frame % FRAMES.length]
208 : done
209 ? "━━━━━"
210 : "─────"
211 : " | ";
212 segments.push({
213 text: ` ${track} `,
214 kind: active && moving && adjacent ? "chaser" : "connector",
215 completed: done,
216 });
217 }
218 });
219 const status = blocked
220 ? " · blocked"
221 : waiting
222 ? " · approval"
223 : terminal
224 ? " · done"
225 : "";
226 segments.push({ text: status.padEnd(12), kind: "status" });
227 return segments;
228}
229
230export function progressLine(
231 run: ProgressRun,
232 options: ProgressOptions = {},
233): string {
234 return progressSegments(run, options)
235 .map((segment) => segment.text)
236 .join("");
237}
238
239let selected: Selection | null = null;
240let run: ProgressRun | null = null;
241let error = "";
242let reading = false;
243let working = false;
244let blockedVersion: number | null = null;
245let frame = 0;
246let executable = "ix-flow";
247let selectionVersion = 0;
248let demo = false;
249let demoTicks = 0;
250
251async function refresh($: EngineInterface): Promise<void> {
252 if (!selected || demo || reading) return;
253 reading = true;
254 const version = selectionVersion;
255 try {
256 const argv = [executable, "progress", selected.id, "--json"];
257 if (selected.stateDir) argv.push("--state-dir", selected.stateDir);
258 const result = await $.process.run(argv, { timeoutMs: 3000 });
259 if (version !== selectionVersion) return;
260 const envelope: unknown = JSON.parse(result.stdout);
261 if (!record(envelope)) throw Error("invalid progress response");
262 if (result.exitCode !== 0 || !envelope.ok)
263 throw Error(
264 record(envelope.error) && typeof envelope.error.message === "string"
265 ? envelope.error.message
266 : "progress query failed",
267 );
268 const data = envelope.data;
269 if (!progressRun(data) || data.id !== selected.id)
270 throw Error("invalid progress response");
271 run = data;
272 error = "";
273 if (blockedVersion !== run.stateVersion) blockedVersion = null;
274 } catch (err) {
275 if (version === selectionVersion) {
276 run = null;
277 error = clean(err instanceof Error ? err.message : err).slice(0, 160);
278 }
279 } finally {
280 reading = false;
281 $.ui.invalidate("ui.render");
282 }
283}
284
285export function register(on: On): void {
286 on("session.start", async ($, e, next) => {
287 executable = (await $.env.get("IX_FLOW_MOD_CLI")) || "ix-flow";
288 await $.command.register({
289 name: "flow-status",
290 description: "Watch a run, preview with demo, or hide with off",
291 argumentHint: "RUN_ID [ABSOLUTE_STATE_DIR] | demo | off",
292 });
293 $.clock.every(2000, () => refresh($));
294 $.clock.every(450, () => {
295 if (
296 run &&
297 (working || demo) &&
298 !run.openGates?.length &&
299 blockedVersion === null &&
300 !run.phases.some((phase) => phase.name === run?.phase && phase.terminal)
301 ) {
302 frame++;
303 if (demo) {
304 demoTicks++;
305 if (demoTicks % DEMO_STAGE_TICKS === 0) {
306 const index = run.phases.findIndex(
307 (phase) => phase.name === run?.phase,
308 );
309 const to = run.phases[index + 1]?.name;
310 if (to) {
311 run.events.push({
312 kind: "phase.advanced",
313 payload: { from: run.phase, to },
314 });
315 run.phase = to;
316 run.stateVersion++;
317 frame = 0;
318 }
319 }
320 }
321 $.ui.invalidate("ui.render");
322 }
323 });
324 return next(e);
325 });
326
327 on("command.run", { command: "flow-status" }, async ($, e) => {
328 const args = e.args.trim();
329 if (args === "off") {
330 demo = false;
331 selectionVersion++;
332 selected = null;
333 run = null;
334 error = "";
335 $.ui.invalidate("ui.render");
336 return {};
337 }
338 if (args === "demo") {
339 selectionVersion++;
340 demo = true;
341 selected = { id: "demo" };
342 frame = 0;
343 demoTicks = 0;
344 blockedVersion = null;
345 error = "";
346 run = {
347 id: "demo",
348 name: "preview",
349 phase: "plan",
350 stateVersion: 0,
351 phases: [
352 { name: "plan" },
353 { name: "build" },
354 { name: "review" },
355 { name: "ship", terminal: true },
356 ],
357 transitions: [
358 { from: "plan", to: "build" },
359 { from: "build", to: "review" },
360 { from: "review", to: "ship" },
361 ],
362 events: [],
363 openGates: [],
364 };
365 $.ui.invalidate("ui.render");
366 return {};
367 }
368 const match = /^(\S+)(?:\s+(\/.*))?$/.exec(args);
369 if (!match || !/^[a-zA-Z0-9_-]+$/.test(match[1]))
370 return {
371 text: "Usage: /flow-status RUN_ID [ABSOLUTE_STATE_DIR] | demo | off",
372 };
373 demo = false;
374 selectionVersion++;
375 selected = { id: match[1], stateDir: match[2] };
376 run = null;
377 blockedVersion = null;
378 error = "";
379 await refresh($);
380 return error ? { text: `ix-flow: ${error}` } : {};
381 });
382
383 on("turn.start", async ($, e, next) => {
384 working = true;
385 $.ui.invalidate("ui.render");
386 return next(e);
387 });
388 on("turn.complete", async ($, e, next) => {
389 working = false;
390 await refresh($);
391 $.ui.invalidate("ui.render");
392 return next(e);
393 });
394
395 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
396 const result = await next(e);
397 // Observe completed CLI output and display its run without changing the tool.
398 const command = typeof e.command === "string" ? e.command : "";
399 if (!/\bix-flow\b/.test(command)) return result;
400 try {
401 const output: unknown = result.result;
402 const stdout =
403 record(output) && typeof output.stdout === "string"
404 ? output.stdout
405 : "";
406 let envelope: unknown;
407 try {
408 envelope = JSON.parse(stdout);
409 } catch {
410 const match = /^run: ([a-zA-Z0-9_-]+)\s*$/m.exec(stdout);
411 if (!match) return result;
412 envelope = { instance_id: match[1], ok: !result.isError };
413 }
414 if (!record(envelope)) return result;
415 const failedCommand =
416 /\bix-flow\s+(?:advance|recipe)\s+([a-zA-Z0-9_-]+)(?:\s|$)/.exec(
417 command,
418 );
419 const id =
420 envelope.instance_id ??
421 (envelope.ok === false ? failedCommand?.[1] : undefined);
422 if (typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)) return result;
423 const location = commandLocation(command);
424 if (location === null) return result;
425 if (
426 demo ||
427 selected?.id !== id ||
428 selected?.stateDir !== location.stateDir
429 ) {
430 selectionVersion++;
431 demo = false;
432 selected = { id, ...location };
433 run = null;
434 error = "";
435 }
436 if (selected?.id === id) {
437 if (envelope.state === "invariant_failed" || envelope.ok === false)
438 blockedVersion =
439 typeof envelope.state_version === "number"
440 ? envelope.state_version
441 : (run?.stateVersion ?? null);
442 else blockedVersion = null;
443 await refresh($);
444 }
445 } catch {
446 /* Unrecognized output leaves the selected run unchanged. */
447 }
448 return result;
449 });
450
451 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
452 if (!selected) return next(e);
453 const { Box, Text } = $.ui.resolve(e);
454 const blocked = blockedVersion !== null;
455 const progression = demo
456 ? demoTicks / (DEMO_STAGE_TICKS * 3)
457 : Math.max(
458 0,
459 run?.phases.findIndex((phase) => phase.name === run?.phase) ?? 0,
460 ) / Math.max(1, (run?.phases.length ?? 1) - 1);
461 const segments: ProgressSegment[] = run
462 ? progressSegments(run, {
463 frame,
464 working: working || demo,
465 blocked,
466 width: e.viewport?.columns ?? 120,
467 })
468 : [
469 {
470 text: `[${selected.id}] ⊗ ${error || "loading"}`,
471 kind: "status",
472 },
473 ];
474 const children = segments.map((segment) => {
475 if (segment.kind === "active") {
476 return Text({
477 color: "#c8ced8",
478 bold: true,
479 children: [
480 Text({
481 color: blocked
482 ? "red"
483 : run?.openGates?.length
484 ? "yellow"
485 : "#80d9a0",
486 children: [segment.text.slice(0, 1)],
487 }),
488 segment.text.slice(1),
489 ],
490 });
491 }
492 if (segment.kind === "chaser") {
493 const position = segment.text.indexOf("•");
494 return Text({
495 color: lineColor(progression),
496 children:
497 position < 0
498 ? [segment.text]
499 : [
500 segment.text.slice(0, position),
501 Text({
502 color: ballColor(progression, frame),
503 bold: true,
504 children: ["•"],
505 }),
506 segment.text.slice(position + 1),
507 ],
508 });
509 }
510 return Text({
511 color:
512 segment.kind === "done" ||
513 (segment.kind === "connector" && segment.completed)
514 ? lineColor(progression)
515 : segment.kind === "status" && (error || blocked)
516 ? "red"
517 : "#596474",
518 children: [segment.text],
519 });
520 });
521 return Box({
522 flexDirection: "column",
523 children: [
524 await next(e),
525 Text({
526 color: "gray",
527 wrap: "truncate-end",
528 children,
529 }),
530 ],
531 });
532 });
533}
534