SLOPSHOPPER

ix-flow

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

newbandguardcommandprocesstimer
★ 1v0.3.0MITupdated 2026-10-05agent-ix/ix-flow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ix-flow
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /flow-status ⎿ ix-flow: Usage: /flow-status RUN_ID [ABSOLUTE_STATE_DIR] | demo | off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="logo.png" alt="IX Flow" width="100%" />

IX Flow

Discord IX Skills

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.

Setup

If this plugin is uninitialized or a command fails, follow the plugin setup guide for its required CLIs, configuration, and local diagnosis.

Community help

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

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/ — run make install-smoke.

Author a workflow

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.

Use a workflow

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.

Concepts

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.

  • Flow — a workflow definition: phases, transitions, gates, invariants (def.yaml).
  • Skill — the agent's instructions for running a flow (SKILL.md).
  • Run — one live instance of a flow, identified by a run id.
  • Phase — a named state; a run sits in exactly one phase at a time.
  • Gate — a hitl transition that pauses for human approval.
  • Invariant — a predicate that must hold before a transition succeeds.

See docs/guide.md for the full guide — gates, invariants, interviews, artifact templates, the run lifecycle, and the complete command reference.

Development

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.

Evals

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,
});

License

MIT — see LICENSE.

Source 1 files
hooks/register.ts 534 lines
1import 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