The Kanban API service moves Forge's task management from a flat markdown file (Task Board.md) to a structured Supabase-backed system with a full REST API. This makes the dashboard fully bidirectional — tasks can be created, moved, updated, completed, and deleted from the GUI without string-munging a markdown file over HTTP. The service runs on the VPS as a standalone Express/TypeScript process on port 5016, follows Forge service conventions (/health, /docs, /v1/*), and integrates with Ralph's existing ralph_queue table so tasks can flow seamlessly from the human kanban board into the autonomous agent pipeline. Task Board.md becomes a generated export — still committed by the guardian, still readable by Claude sessions — but no longer the source of truth.
+------------------+
| Vercel App |
| (Dashboard) |
+--------+---------+
|
HTTPS (Vercel proxy)
|
v
+------------------+ +------------------+ +------------------+
| Telegram Bot | HTTP | KANBAN API | SQL | Supabase |
| (Commander) +-------->+ :5016 +-------->+ kanban_tasks |
+------------------+ | | | kanban_columns |
| /v1/tasks | | kanban_history |
+------------------+ HTTP | /v1/columns | | ralph_queue |
| Claude Sessions +-------->+ /v1/dispatch | +------------------+
| (rituals, CLI) | | /v1/export |
+------------------+ | /v1/import |
+--------+---------+
|
+--------v---------+
| Task Board.md |
| (generated |
| export, cron) |
+------------------+
POST /v1/tasks → Supabase kanban_tasksPATCH /v1/tasks/:id → updates column/positionPOST /v1/dispatch → inserts into ralph_queueGET /v1/export → writes Task Board.mdPOST /v1/import → parses Task Board.md → seeds kanban_tasksservices/kanban-api/5016forge-kanban-api.service| Method | Path | Description |
|---|---|---|
| GET | /health |
Service health + task count + last sync time |
| GET | /docs |
Returns CLAUDE.md as plain text |
| GET | /v1/tasks |
List tasks with filters (column, project, priority, status) |
| GET | /v1/tasks/:id |
Get single task with history |
| POST | /v1/tasks |
Create a task |
| PATCH | /v1/tasks/:id |
Update task fields (title, column, priority, project, position) |
| DELETE | /v1/tasks/:id |
Soft-delete a task (sets deleted_at) |
| POST | /v1/tasks/:id/done |
Mark task complete (moves to Done, sets completed_at) |
| POST | /v1/tasks/:id/reopen |
Reopen a completed task |
| POST | /v1/tasks/reorder |
Batch update positions within a column |
| GET | /v1/columns |
List all columns with task counts |
| POST | /v1/dispatch |
Send a task to ralph_queue for autonomous execution |
| GET | /v1/export |
Generate Task Board.md from current state |
| POST | /v1/export/write |
Generate and write Task Board.md to disk |
| POST | /v1/import |
Parse Task Board.md and seed database (one-time migration) |
| GET | /v1/stats |
Task counts by column, project, priority; completion rate; velocity |
Three tables in Supabase with RLS policies, views, and triggers:
CREATE TABLE IF NOT EXISTS kanban_columns (
id TEXT PRIMARY KEY, -- 'today', 'soon', 'later', 'waiting', 'inbox', 'done', 'agenda'
label TEXT NOT NULL, -- 'Today', 'Soon', 'Later', etc.
position INT NOT NULL DEFAULT 0, -- display order
description TEXT, -- section description
color TEXT, -- hex color for UI
is_terminal BOOLEAN DEFAULT FALSE, -- 'done' column = terminal
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Seed default columns
INSERT INTO kanban_columns (id, label, position, description, is_terminal) VALUES
('today', 'Today', 0, 'Active focus for today. Max 3-5 items.', FALSE),
('soon', 'Soon', 1, 'Next 2-3 days.', FALSE),
('later', 'Later', 2, 'Backlog, no urgency.', FALSE),
('waiting', 'Waiting', 3, 'Blocked on someone/something external.', FALSE),
('inbox', 'Inbox', 4, 'Uncategorized captures. Triaged during /start.', FALSE),
('done', 'Done', 5, 'Completed tasks with dates. Newest at top.', TRUE)
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS kanban_tasks (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
column_id TEXT NOT NULL REFERENCES kanban_columns(id) DEFAULT 'inbox',
position INT NOT NULL DEFAULT 0,
priority TEXT NOT NULL DEFAULT 'normal'
CHECK (priority IN ('urgent', 'medium', 'normal')),
project TEXT, -- 'FORGE', 'HC', 'MASTERY', etc.
tags TEXT[] DEFAULT '{}',
description TEXT,
due_date DATE,
assignee TEXT, -- 'jason', 'ralph', 'forge'
source TEXT DEFAULT 'manual', -- 'manual', 'import', 'telegram', etc.
agenda_person TEXT, -- for agenda items
agenda_topic TEXT, -- for agenda items
completed_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ, -- soft delete
ralph_queue_id BIGINT REFERENCES ralph_queue(id),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for common query patterns
CREATE INDEX idx_kanban_tasks_column ON kanban_tasks(column_id, position);
CREATE INDEX idx_kanban_tasks_project ON kanban_tasks(project);
CREATE INDEX idx_kanban_tasks_priority ON kanban_tasks(priority);
CREATE INDEX idx_kanban_tasks_active ON kanban_tasks(column_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_kanban_tasks_done ON kanban_tasks(completed_at DESC) WHERE column_id = 'done';
CREATE TABLE IF NOT EXISTS kanban_history (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
action TEXT NOT NULL CHECK (action IN (
'created', 'updated', 'moved', 'completed', 'reopened',
'deleted', 'dispatched', 'imported'
)),
from_column TEXT,
to_column TEXT,
changes JSONB DEFAULT '{}', -- field-level diff
actor TEXT DEFAULT 'system', -- 'jason', 'dashboard', 'telegram', 'ralph'
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_kanban_history_task ON kanban_history(task_id, created_at DESC);
CREATE INDEX idx_kanban_history_time ON kanban_history(created_at DESC);
-- Active board: non-deleted tasks ordered by column + position
CREATE OR REPLACE VIEW v_kanban_board AS
SELECT t.id, t.title, t.column_id, t.position, t.priority,
t.project, t.tags, t.description, t.due_date, t.assignee,
t.agenda_person, t.agenda_topic, t.completed_at,
t.ralph_queue_id, t.source, t.metadata, t.created_at, t.updated_at,
c.label AS column_label, c.position AS column_position
FROM kanban_tasks t
JOIN kanban_columns c ON c.id = t.column_id
WHERE t.deleted_at IS NULL
ORDER BY c.position, t.position, t.created_at DESC;
-- Column summary with counts
CREATE OR REPLACE VIEW v_kanban_summary AS
SELECT c.id AS column_id, c.label, c.position,
COUNT(t.id) FILTER (WHERE t.deleted_at IS NULL) AS task_count,
COUNT(t.id) FILTER (WHERE t.deleted_at IS NULL AND t.priority = 'urgent') AS urgent_count
FROM kanban_columns c
LEFT JOIN kanban_tasks t ON t.column_id = c.id
GROUP BY c.id, c.label, c.position
ORDER BY c.position;
-- Daily completion velocity (last 30 days)
CREATE OR REPLACE VIEW v_kanban_velocity AS
SELECT DATE(completed_at) AS day, COUNT(*) AS completed
FROM kanban_tasks
WHERE completed_at IS NOT NULL
AND completed_at > NOW() - INTERVAL '30 days'
AND deleted_at IS NULL
GROUP BY DATE(completed_at)
ORDER BY day DESC;
-- Eisenhower matrix view
CREATE OR REPLACE VIEW v_kanban_eisenhower AS
SELECT t.id, t.title, t.priority, t.project, t.tags, t.column_id,
CASE
WHEN t.column_id = 'today' AND t.priority = 'urgent' THEN 'q1'
WHEN t.column_id = 'today' THEN 'q2'
WHEN t.column_id = 'soon' AND t.priority = 'urgent' THEN 'q1'
WHEN t.column_id = 'soon' AND t.priority = 'medium' THEN 'q2'
WHEN t.column_id = 'soon' THEN 'q3'
ELSE 'q4'
END AS quadrant
FROM kanban_tasks t
WHERE t.deleted_at IS NULL AND t.completed_at IS NULL
AND t.column_id NOT IN ('done', 'waiting', 'inbox')
ORDER BY t.position;
Anon key: Read-only access (dashboard reads).
Service role: Full access (API server uses service key for all writes). Same pattern as ralph_queue.
v_kanban_board view via Supabase anon key (direct, no VPS proxy needed for reads)/api/vps/kanban/* which forwards to :5016/v1/*kanban_tasks for live updates across tabs/sessionstaskboard-parser.ts and taskboard-actions.ts become deprecated. New lib/kanban-client.ts replaces them./task command — POST /v1/tasks with text parsing (e.g., /task [!] [FORGE] Fix the dashboard)/done command — quick-complete from Telegram/today command — GET /v1/tasks?column=today formatted for TelegramDashboard "Send to Ralph" button calls POST /v1/dispatch which: (1) Creates a ralph_queue row with task text, project, priority mapping. (2) Updates kanban_tasks.ralph_queue_id to link them. (3) Moves the kanban task to waiting column with tag (ralph-queued). When Ralph completes the task, a callback marks it done with actor: 'ralph'.
Priority mapping: Kanban urgent → ralph_queue priority 20, medium → 50, normal → 70
/start reads from API: GET /v1/tasks?column=today/sync uses GET /v1/stats for progress summary/wrap-up calls POST /v1/export/write to ensure Task Board.md is currentcontext/SESSION-QUEUE.md — remains separate (session-scoped, not task-scoped)context/JASON-DEPS.md — stays as-is for Phase 1; could become a filtered view laterScratchpad.md — remains separate (ephemeral capture, different semantics)GET /health — returns { status, service, version, tasks, last_export }GET /docs — returns services/kanban-api/CLAUDE.md as plain textconfig/services.json with port 5016services/kanban-api/forge-kanban-api.serviceSUPABASE_URL, SUPABASE_SERVICE_KEY, SERVICE_PORTservices/kanban-api/
CLAUDE.md # Service docs (served at GET /docs)
package.json # Dependencies: express, @supabase/supabase-js
tsconfig.json
src/
server.ts # Express app, routes, health, docs
routes/
tasks.ts # CRUD for /v1/tasks
columns.ts # /v1/columns
dispatch.ts # /v1/dispatch (Ralph integration)
export.ts # /v1/export, /v1/import
stats.ts # /v1/stats
lib/
supabase.ts # Supabase client (service-role)
exporter.ts # Generate Task Board.md from DB
importer.ts # Parse Task Board.md into DB rows
history.ts # Write kanban_history entries
cron/
export-sync.ts # setInterval that writes Task Board.md
scripts/
migrate.ts # One-time migration CLI
setup.sql # SQL from Data Model section
forge-kanban-api.service # systemd unit file
services/kanban-api/ directory structure/health, /docsGET /v1/tasks and POST /v1/tasksconfig/services.jsonPATCH /v1/tasks/:id, DELETE, /done, /reopenPOST /v1/tasks/reorderGET /v1/export (markdown generator)POST /v1/import (parse Task Board.md)POST /v1/dispatch (kanban → ralph_queue)lib/kanban-client.ts in dashboard app/task, /done, /today commands to Commander| Item | Cost | Notes |
|---|---|---|
| Supabase tables | $0 | Free tier: 500MB, 50K rows. Current usage ~5% |
| Supabase realtime | $0 | Free tier: 200 concurrent connections |
| VPS compute | $0 | Already provisioned. Express server ~20MB RAM |
| LLM calls | $0 | No LLM needed for CRUD operations |
| Development | ~2.5 hrs | 6 phases at 15-30 min each (Ralph time) |
| Total: $0 incremental — All within existing infrastructure | ||
| Risk | Impact | Mitigation |
|---|---|---|
| Supabase free tier row limits | Tasks accumulate in done column |
Archive done tasks older than 90 days to kanban_archive. Cron job. |
| Export cron drift | Task Board.md briefly out of sync | 60s cadence is fine. Worst case = 90s lag. Rituals can call /v1/export/write explicitly. |
| Dashboard reads during migration | Stale data if API is down | Keep VPS file API fallback. If API returns 5xx, fall back to parsing markdown. |
| Two sources of truth during migration | Conflicting edits | Phase 3 (import) is the cutoff. After import, disable direct markdown editing. |
| Ralph dispatch creates duplicate tasks | Same task in kanban + ralph_queue | ralph_queue_id FK prevents double-dispatch. UI disables button if already dispatched. |
| Position gaps after deletes/moves | Messy ordering | Reorder endpoint normalizes positions (0, 1, 2...) on write. No gaps accumulate. |
agenda_person/agenda_topic fields. Simpler than a separate table.## Reference section should be hardcoded in the exporter (static content, not tasks).effort_minutes INT to kanban_tasks now. Cheap to add, expensive to migrate later.