Build a Kanban State Machine for AI Agents in Plain Node.js
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 developmentAI Tools Covered
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
- •The Five States1m
- •Legal Transitions1m
The Board
Learn key concepts
- •Tasks and the Board1m
- •Rendering the Columns1m
The Engine and Human Approval
Learn key concepts
- •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.
Start learning with this comprehensive guide
This guide includes:
About the Author
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
- 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
- Build a
Taskcard and aBoardthat 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
- 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.