Build a Kanban State Machine for AI Agents in Plain Node.js

Intermediate10m readFull-stack developers

Build a Kanban State Machine for AI Agents in Plain Node.js When you run more than one AI agent at a time, a chat log stops being enough. You need to know — at a glance — what is running, what is waiting on you, and what is queued.

Primary Focus

ai development

AI Tools Covered

nodejsai-agentsstate-machine

What You'll Learn

  • The Five States
  • Legal Transitions
  • Tasks and the Board
  • Rendering the Columns
  • The Processing Loop
  • The Human-Approval Gate

Guide Curriculum

Modeling Agent Work as a State Machine

Learn key concepts

2 lessons
  • The Five States1m
  • Legal Transitions1m

The Board

Learn key concepts

2 lessons
  • Tasks and the Board1m
  • Rendering the Columns1m

The Engine and Human Approval

Learn key concepts

3 lessons
  • The Processing Loop1m
  • The Human-Approval Gate2m
  • The Complete Runnable File3m

Preview: First Lesson

Modeling Agent Work as a State Machine

The Five States

Objectives:

  • Define the five states an agent task moves through.
  • Encode legal transitions and reject everything else.

A coding agent's task is never just "doing" or "done." At any moment it is in exactly one of a few states. We model five:

  • ready — queued, nothing has happened yet.
  • running — actively executing.
  • blocked — paused, waiting on a human decision.
  • done — finished successfully.
  • failed — finished unsuccessfully (errored or rejected).

Representing these as a frozen object gives you a single source of truth and avoids typo bugs from loose strings scattered across the codebase:

const State = Object.freeze({
  READY: 'ready',
  RUNNING: 'running',
  BLOCKED: 'blocked',
  DONE: 'done',
  FAILED: 'failed',
});

The whole point of a Kanban board is that each of these states is a column. A task is a card, and orchestration is just moving cards between columns under controlled rules.

Free Access

Start learning with this comprehensive guide

This guide includes:

3 modules with 7 lessons
10m estimated reading time

About the Author

H
✨ Vibe Coder
@hiram-clark

Hiram Clark is the founder and managing editor of vybecoding.ai and sets editorial direction for the guides and news published here. Articles are drafted with AI assistance and edited before publication. He works hands-on with the AI development tools, workflows, and infrastructure covered on the site.

Full Guide Content

Complete lesson text — start the interactive course above for exercises and progress tracking.

Module 1Modeling Agent Work as a State Machine

1.1The Five States

Objectives:
  • Define the five states an agent task moves through.
  • Encode legal transitions and reject everything else.

A coding agent's task is never just "doing" or "done." At any moment it is in exactly one of a few states. We model five:

  • ready — queued, nothing has happened yet.
  • running — actively executing.
  • blocked — paused, waiting on a human decision.
  • done — finished successfully.
  • failed — finished unsuccessfully (errored or rejected).

Representing these as a frozen object gives you a single source of truth and avoids typo bugs from loose strings scattered across the codebase:

const State = Object.freeze({
  READY: 'ready',
  RUNNING: 'running',
  BLOCKED: 'blocked',
  DONE: 'done',
  FAILED: 'failed',
});

The whole point of a Kanban board is that each of these states is a column. A task is a card, and orchestration is just moving cards between columns under controlled rules.

1.2Legal Transitions

The reason to use a state machine instead of a free-floating status field is that not every move is legal. A task should never jump from ready straight to done without running. It should never go from done back to running. We encode the legal moves explicitly:

const TRANSITIONS = Object.freeze({
  [State.READY]: [State.RUNNING],
  [State.RUNNING]: [State.BLOCKED, State.DONE, State.FAILED],
  [State.BLOCKED]: [State.READY],
  [State.DONE]: [],
  [State.FAILED]: [],
});

function canTransition(from, to) {
  return TRANSITIONS[from].includes(to);
}

Read the map as a graph: ready → running, and from running a task can become blocked, done, or failed. A blocked task can only go back to ready — and that single edge is the human-approval gate we build in Module 3. done and failed are terminal: empty arrays mean no exit. Any code that tries an unlisted move will be rejected, which turns an entire category of orchestration bugs into a loud error instead of silent corruption.


Module 2The Board

2.1Tasks and the Board

Objectives:
  • Build a Task card and a Board that owns the cards.
  • Render the board's columns to the terminal.

A Task is a card. It carries its identity, the work to run, whether it needs human sign-off, and its current state:

class Task {
  constructor({ id, name, run, requiresApproval = false }) {
    this.id = id;
    this.name = name;
    this.run = run;                 // async () => void — the actual work
    this.requiresApproval = requiresApproval;
    this.approved = false;
    this.state = State.READY;       // every card starts in Ready
    this.error = null;
  }
}

The Board owns all tasks and is the only thing allowed to change a task's state. Centralizing the move through one guarded method is what makes the transition rules from Lesson 2 actually enforceable:

class Board {
  constructor(tasks) {
    this.tasks = tasks;
  }
  column(state) {
    return this.tasks.filter((t) => t.state === state);
  }
  move(task, to) {
    if (!canTransition(task.state, to)) {
      throw new Error(`Illegal transition ${task.state} -> ${to} for "${task.name}"`);
    }
    task.state = to;
  }
}
column(state) is your selector — it returns every card currently in a column, which is exactly what the engine and the renderer both need.

2.2Rendering the Columns

A board you cannot see is useless. Add a render() method that prints each column and its cards. This is the terminal-native version of the Running / Blocked / Ready view:

render() {
    const cols = [State.READY, State.RUNNING, State.BLOCKED, State.DONE, State.FAILED];
    const lines = cols.map((c) => {
      const names = this.column(c).map((t) => t.name).join(', ') || '-';
      return `  ${c.toUpperCase().padEnd(8)} | ${names}`;
    });
    console.log('\n--- BOARD ---\n' + lines.join('\n') + '\n');
  }

Add that method inside the Board class. Every time the engine moves a card, a fresh render shows you the new layout — the same glanceable feedback loop that makes a Kanban surface more useful than scrolling a chat history.


Module 3The Engine and Human Approval

3.1The Processing Loop

Objectives:
  • Write the processing loop that drains the Ready column.
  • Implement the approval gate that blocks a task until a human unblocks it.
  • Assemble and run the complete file.

The engine repeatedly pulls the first card out of Ready and processes it, until no Ready cards remain. Because a blocked task can only re-enter the pipeline through ready, the loop naturally drains all reachable work:

class Engine {
  constructor(board) {
    this.board = board;
  }

  async run() {
    let task;
    while ((task = this.board.column(State.READY)[0])) {
      await this.processOne(task);
    }
    console.log('=== All tasks settled ===');
    this.board.render();
  }
}

3.2The Human-Approval Gate

processOne is where the state machine earns its keep. A task first moves to running. If it requires approval and has not been approved yet, it moves to blocked and the engine pauses for a human answer. Approval moves it back to ready (so the loop runs it for real); rejection routes it to failed:
const readline = require('node:readline');

function ask(question) {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      rl.close();
      resolve(answer.trim().toLowerCase());
    });
  });
}

// add this method inside the Engine class:
  async processOne(task) {
    this.board.move(task, State.RUNNING);

    if (task.requiresApproval && !task.approved) {
      this.board.move(task, State.BLOCKED);
      console.log(`PAUSE  "${task.name}" is BLOCKED -- needs human approval.`);
      this.board.render();
      const answer = await ask(`   Approve "${task.name}"? (y/n) `);
      if (answer === 'y' || answer === 'yes') {
        task.approved = true;
        this.board.move(task, State.READY);     // unblock -> back to Ready
        console.log(`OK     "${task.name}" approved.\n`);
      } else {
        this.board.move(task, State.READY);
        this.board.move(task, State.RUNNING);
        this.board.move(task, State.FAILED);     // reject -> Failed
        task.error = 'Rejected by human reviewer';
        console.log(`STOP   "${task.name}" rejected.\n`);
      }
      return;
    }

    try {
      await task.run();
      this.board.move(task, State.DONE);
      console.log(`DONE   "${task.name}"\n`);
    } catch (err) {
      task.error = err.message;
      this.board.move(task, State.FAILED);
      console.log(`FAIL   "${task.name}": ${err.message}\n`);
    }
  }

The key move is blocked → ready. An approved task is requeued, not run inline, so it flows through the exact same ready → running → done path as any other card. There are no special cases — the board's rules hold for every task, every time.

3.3The Complete Runnable File

Here is the whole orchestrator in one dependency-free file. Save it as kanban-agents.cjs and run it with node kanban-agents.cjs:

'use strict';
const readline = require('node:readline');

const State = Object.freeze({
  READY: 'ready',
  RUNNING: 'running',
  BLOCKED: 'blocked',
  DONE: 'done',
  FAILED: 'failed',
});

const TRANSITIONS = Object.freeze({
  [State.READY]: [State.RUNNING],
  [State.RUNNING]: [State.BLOCKED, State.DONE, State.FAILED],
  [State.BLOCKED]: [State.READY],
  [State.DONE]: [],
  [State.FAILED]: [],
});

function canTransition(from, to) {
  return TRANSITIONS[from].includes(to);
}

class Task {
  constructor({ id, name, run, requiresApproval = false }) {
    this.id = id;
    this.name = name;
    this.run = run;
    this.requiresApproval = requiresApproval;
    this.approved = false;
    this.state = State.READY;
    this.error = null;
  }
}

class Board {
  constructor(tasks) {
    this.tasks = tasks;
  }
  column(state) {
    return this.tasks.filter((t) => t.state === state);
  }
  move(task, to) {
    if (!canTransition(task.state, to)) {
      throw new Error(`Illegal transition ${task.state} -> ${to} for "${task.name}"`);
    }
    task.state = to;
  }
  render() {
    const cols = [State.READY, State.RUNNING, State.BLOCKED, State.DONE, State.FAILED];
    const lines = cols.map((c) => {
      const names = this.column(c).map((t) => t.name).join(', ') || '-';
      return `  ${c.toUpperCase().padEnd(8)} | ${names}`;
    });
    console.log('\n--- BOARD ---\n' + lines.join('\n') + '\n');
  }
}

function ask(question) {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      rl.close();
      resolve(answer.trim().toLowerCase());
    });
  });
}

class Engine {
  constructor(board) {
    this.board = board;
  }
  async processOne(task) {
    this.board.move(task, State.RUNNING);
    if (task.requiresApproval && !task.approved) {
      this.board.move(task, State.BLOCKED);
      console.log(`PAUSE  "${task.name}" is BLOCKED -- needs human approval.`);
      this.board.render();
      const answer = await ask(`   Approve "${task.name}"? (y/n) `);
      if (answer === 'y' || answer === 'yes') {
        task.approved = true;
        this.board.move(task, State.READY);
        console.log(`OK     "${task.name}" approved.\n`);
      } else {
        this.board.move(task, State.READY);
        this.board.move(task, State.RUNNING);
        this.board.move(task, State.FAILED);
        task.error = 'Rejected by human reviewer';
        console.log(`STOP   "${task.name}" rejected.\n`);
      }
      return;
    }
    try {
      await task.run();
      this.board.move(task, State.DONE);
      console.log(`DONE   "${task.name}"\n`);
    } catch (err) {
      task.error = err.message;
      this.board.move(task, State.FAILED);
      console.log(`FAIL   "${task.name}": ${err.message}\n`);
    }
  }
  async run() {
    let task;
    while ((task = this.board.column(State.READY)[0])) {
      await this.processOne(task);
    }
    console.log('=== All tasks settled ===');
    this.board.render();
  }
}

async function main() {
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
  const board = new Board([
    new Task({ id: 't1', name: 'Read repo', run: async () => { await wait(20); } }),
    new Task({ id: 't2', name: 'Write migration', requiresApproval: true, run: async () => { await wait(20); } }),
    new Task({ id: 't3', name: 'Run tests', run: async () => { await wait(20); } }),
    new Task({ id: 't4', name: 'Deploy', requiresApproval: true, run: async () => { await wait(20); } }),
  ]);
  await new Engine(board).run();
  process.exit(0);
}

main();

Run it:

node kanban-agents.cjs

You will be prompted to approve Write migration and Deploy — the two tasks marked requiresApproval. Approve one and reject the other to watch a card travel running → blocked → ready → running → done versus running → blocked → failed. The board re-renders at every move so you always see exactly where each agent stands.


Where to Take It Next

This compact, dependency-free core is the same shape that powers an Agent Command Center: a Kanban surface backed by a guarded state machine. To grow it toward production, swap the in-memory array for a persisted store so the board survives a restart, replace the wait() stubs with real agent calls (a shell command, an API request, a model invocation), and turn the terminal renderer into a web view that polls the board. The discipline that keeps it sane stays the same — every task lives in exactly one column, and it can only get there through a legal, auditable transition.