@tb.p/kanban
v2.3.0
Published
Minimal npx-runnable kanban board backed by a local kanban.json file.
Readme
@tb.p/kanban
A minimal, zero-dependency kanban board you run with npx. State lives in a
kanban.json file in the directory you run it from — no database, no server to host.
Usage
npx @tb.p/kanban- If
kanban.jsondoesn't exist in the current directory, it's created with three default columns: Backlog, In Progress, Done. - If it already exists, it's loaded as-is.
- The board opens automatically in your default browser.
- Uploaded files (attachments, avatars, markdown file references) are saved under
.kanban/uploads/next tokanban.json.
Options
--user, -u <name> Create/select a user and pre-select them as "current user" in the
browser tab this command auto-launches (does not affect other
clients connecting to the same server)
--port, -p <port> Port to listen on (default: last used port, or 4321 the first time)
--no-open Don't automatically open the browser
--ntfy-topic <t> ntfy topic to notify on card create/move (saved in kanban.json;
omit to reuse the saved one)
--ntfy-server <url> ntfy server to use (default: https://ntfy.sh, or the last saved value)
--project <name> Set the board's display name — replaces "🗂️ Kanban" in the header
and the page/tab title (saved in kanban.json; omit to keep the
last saved value)
--help, -h Show helpIf --port isn't given, the server starts at whatever port it last successfully bound
to (saved in kanban.json), auto-incrementing from there if that port is busy. Passing
--port explicitly requires that exact port — if it's taken, the process exits with an
error instead of silently picking another.
Identity and permissions
Users are cosmetic identity, not accounts — no passwords, just "who's currently at the
keyboard." A browser with no user selected is view-only: no add/edit/delete/drag
actions are available anywhere in the UI, and a banner says so. Picking or creating a
user (top right) unlocks editing for that browser, remembered in localStorage.
--user <name> only pre-selects identity for the one browser tab the CLI auto-launches
(via a one-time URL parameter) — it does not change what other clients see. Anyone
else opening the URL still lands in view-only mode and picks their own identity.
Many users, one board
This is built for two overlapping setups:
- Async, one instance each — everyone clones/pulls the repo, runs their own
npx @tb.p/kanbanlocally, andkanban.json(plus.kanban/uploads/) is synced via git (commit/push/pull like any other file — being a dotdir doesn't exclude it from git, only from a plainls). Commit the uploads folder too, or attachments/avatars will 404 for anyone who didn't generate them locally. - Synced, one shared instance — one person runs the server and others connect to its URL directly; all changes go through the same process.
Live updates
Every connected tab stays in sync automatically — no manual refresh needed. The
server pushes a change notification over Server-Sent Events (GET /api/events)
whenever the board is mutated, by anyone: another tab, a teammate's browser, or an
API client like Claude Code creating tickets. The board view (columns, cards, users)
updates itself within ~200ms of any change, and so does an open card or notes modal —
e.g. a comment someone else adds shows up while you're looking at that same card. The
one exception: if you're actively focused in a text field (title, description,
comment box, notes), a live update won't overwrite your unsaved keystrokes; it catches
up automatically as soon as you click away. If the card you have open gets deleted
(directly, or via its whole column being deleted), the modal closes on its own.
Events are typed and granular — one per change, carrying just enough to act on it (ids and small fields, not full markdown bodies), so a listener never has to poll or re-fetch the whole board just to find out what happened:
| Event | Payload |
|---|---|
| column.created | { column } |
| column.renamed | { columnId, name } |
| column.deleted | { columnId, removedCardIds } |
| columns.reordered | { order } |
| card.created | { card } |
| card.updated | { cardId, fields, title?, assigneeId?, color? } — fields lists what changed; body changes are flagged but the text itself isn't sent |
| card.deleted | { cardId } |
| card.moved | { cardId, fromColumnId, toColumnId, toIndex } |
| comment.added | { cardId, comment } |
| comment.deleted | { cardId, commentId } |
| attachment.added | { cardId, attachment } |
| attachment.deleted | { cardId, attachmentId } |
| user.created | { user } |
| user.avatar_updated | { userId, avatar } |
| notes.updated | {} — fetch GET /api/notes for content |
| project.updated | { project } |
Lines starting with : (e.g. : ping) are keep-alive comments per the SSE spec —
ignore them; EventSource already does.
This also means an AI agent can watch the board cheaply: run
curl -N http://localhost:<port>/api/events in the background and react to specific
event types instead of re-reading the whole board on every change.
Browser notifications
The 🔕/🔔 toggle in the header enables native OS notifications (via the browser's
Notification API) for card.created, card.moved, and comment.added. First click
prompts for permission; after that it just mutes/unmutes (stored per-browser).
Notifications are skipped while the tab is actually focused — you're already looking
at the board update, no need to also pop a toast — so in practice you'll only see them
when the board changes while you're elsewhere. Works over localhost without HTTPS
(browsers treat it as a secure context); if you ever expose this over a real network
address instead, most browsers will require HTTPS for the Notification API to work
at all.
Dark mode
Follows your OS light/dark setting automatically. Use the 🌙/☀️ toggle in the header to override it — the choice is remembered per-browser.
Instructions and notes
Two related but distinct fields:
instructions(GET /api/instructions, read-only) — fixed default guidance baked into every board: use the API instead of hand-editingkanban.json, fetchGET /apifirst to discover routes, and checknotesfor board-specific context. Not user-editable; it exists so a fresh AI agent or unfamiliar human has somewhere to start.notes(📋 Notes button in the UI,GET/PATCH /api/notes) — a board-level markdown scratchpad for context that isn't tied to any one card: introductions, conventions, sprint goals, whatever the next person (human or AI) should know. Empty by default; whoever's working the board fills it in.
Notifications (ntfy)
ntfy is a free push-notification service: you pick a topic name,
subscribe to it on your phone/desktop, and anything posted to that topic shows up as
a push notification — no account required. This app posts to your topic on
card.created, card.moved, and comment.added. Notifications are fire-and-forget —
a down/unreachable ntfy server never affects the app.
To set it up:
- Pick a topic name. On the public
ntfy.shserver this is effectively a shared secret (anyone who knows the name can subscribe), so use something unguessable — e.g.kanban-a1b2c3d4e5, notmy-board. - Subscribe to it before or after starting the server:
- Phone/desktop: install the ntfy app, add your topic.
- Browser: open
https://ntfy.sh/<your-topic>and click "Subscribe" (or just leave that tab open — it shows pushes live via the same kind of stream this project uses internally). - Quick test without any app:
curl -s ntfy.sh/<your-topic>/jsonand watch it while you trigger an event elsewhere.
- Set the topic, either way works, both save into
kanban.json(meta.ntfy):- In the UI: ⚙️ Settings → enter the topic → Save. (Needs a user selected — it's board config, not exempt from the view-only gate.)
- Or via CLI flag when starting the server:
npx @tb.p/kanban --ntfy-topic kanban-a1b2c3d4e5
- Click Test connection in ⚙️ Settings (or
POST /api/ntfy/test) — it sends a real push using whatever's currently in the form (even before you hit Save), so you can catch a typo'd topic or bad token before committing it. Reports success or the actual failure reason (unreachable server, 401 from a bad token, etc).
Running your own server instead of the public one: set it in ⚙️ Settings, or pass
--ntfy-server https://your-ntfy-instance on the CLI — saved the same way either path.
Token (optional). Only needed if your ntfy server requires authentication — a
self-hosted instance with auth enabled, or a reserved topic on ntfy.sh. Set it in
⚙️ Settings (the field is password-masked and never echoed back in plaintext after
saving — reopening Settings shows a "leave blank to keep" placeholder, with a
separate "Clear saved token" action to remove it) or via PATCH /api/ntfy {"token":...}.
Sent as Authorization: Bearer <token>. The public ntfy.sh server doesn't need one
for ordinary topics — the topic name itself is the only "secret."
API
The API is self-describing — GET /api returns a JSON list of every endpoint, method,
and expected body. This is meant to be discoverable by an agent (e.g. Claude Code)
creating/updating tickets without needing to read the source first.
Example:
npx @tb.p/kanban --user "Batman" --ntfy-topic "kanban-a1b2c3d4"Features
- Columns: add/remove from the board UI, drag to reorder, drag cards between and within columns.
- Cards: emoji-friendly titles, markdown description (Write/Preview toggle, rendered as a preview snippet directly on the card face), assignee (any board user), a pastel background color picker. The card face shows the creator's avatar before the title and the assignee's avatar bottom-right; hovering any avatar shows the username as a tooltip.
- Attachments: drag-and-drop or file-picker upload (copied into
.kanban/uploads/, referenced by URL — not embedded as base64). - Avatars: upload and crop an image (pan/zoom) per user; stored as a file under
.kanban/uploads/and referenced by URL. Falls back to colored initials if unset. - Markdown file references: drop a file directly onto a card body or comment box
(or use the 📎 Insert file button) to upload it and insert a
[name](url)— orfor images — reference at the cursor. - Comments: markdown, attributed to whichever user is "current" in the browser.
- Everything persists to
kanban.jsonafter every change.
Data model
kanban.json shape:
{
"version": 1,
"columns": [{ "id": "...", "name": "Backlog" }],
"cards": [
{
"id": "...",
"columnId": "...",
"title": "🔥 Fix the thing",
"body": "markdown text",
"color": "#FADDE1",
"assigneeId": "...",
"createdBy": "...",
"attachments": [{ "id": "...", "url": "/kanban-uploads/...", "name": "..." }],
"comments": [{ "id": "...", "userId": "...", "body": "...", "createdAt": "..." }],
"createdAt": "...",
"updatedAt": "..."
}
],
"users": [
{ "id": "...", "name": "Batman", "initials": "BA", "color": "hsl(...)", "avatar": "/kanban-uploads/..." }
],
"notes": "markdown text shared across the whole board",
"instructions": "default guidance for humans/AI on how to use this board (see below)",
"project": "Acme Sprint Board",
"meta": {
"lastPort": 4321,
"ntfy": { "server": "https://ntfy.sh", "topic": null }
}
}It's plain JSON, but don't hand-edit it — use the HTTP API (or the web UI) so
uploads, notifications, and file references stay consistent. Check it into version
control alongside your project if you want (see the "many users, one board" note
above about also committing .kanban/uploads/); just make changes through the API,
not a text editor.
