A calm, self-hosted kanban planning tool built to be used alongside coding agents. Plan work on a mobile-friendly board, and let any agent plug in over MCP to read tickets, pick up work, comment, and move things along — in real time.
- Kanban boards with user-defined columns/statuses, multiple projects, drag & drop.
- Tickets with name, description (full markdown), assignee, priority, comments (markdown), and attachments. Drag an uncategorized card onto a group band to file it without leaving the board.
- Customizable ticket types per project — add, rename, recolor, and pick an icon for each type from Board settings (or via MCP). Defaults to feature/bug/idea.
- MCP server built in — agents connect with a token and get full access to tickets, comments, and attachments. When an agent claims a ticket it auto-assigns itself and moves the ticket into the project's Agent Working column.
- Realtime + reactive — every change syncs live across devices over WebSockets, with optional browser notifications when tickets move or comments arrive.
- Mobile-first, animated, warm, and minimal. Done tickets hide behind a per-board toggle.
# 1. (optional) choose a password and a stable session secret
echo "PLANJ_PASSWORD=your-password" > .env
echo "PLANJ_SECRET=$(openssl rand -hex 32)" >> .env
# 2. build & run
docker compose up -d --build
# 3. open the app
open http://localhost:8420If you don't set PLANJ_PASSWORD, Planj generates one and prints it in the logs on first boot
(docker compose logs planj). Data (SQLite + attachments) lives in the planj-data volume.
npm run install:all # install server + client deps
npm run dev # server on :8420, web app on :5173 (proxied)Open http://localhost:5173. The Vite dev server proxies the API, WebSocket, and MCP endpoints to the server, so it behaves like one origin.
-
In the app, open Agents & MCP and create a token (this auto-registers an agent member).
-
Point your MCP client at the Streamable HTTP endpoint with the token as a bearer:
{ "mcpServers": { "planj": { "url": "http://localhost:8420/mcp", "headers": { "Authorization": "Bearer planj_..." } } } }
Tools the agent gets:
- Read —
whoami,list_projects,get_project,get_board(project + columns + groups + tickets in one call),list_columns,list_groups,list_ticket_types,list_tickets,search_tickets,get_ticket,list_comments,list_members,list_activity,get_attachment. - Work the board —
create_ticket,update_ticket,move_ticket(target a column bytoColumnName),claim_ticketandnext_ticket(grab the top unclaimed ticket),assign_ticket,release_ticket,delete_ticket,add_comment,update_comment,delete_comment,add_attachment. - Shape the board —
create_project,update_project,create_group,update_group,create_ticket_type,update_ticket_type,delete_ticket_type,create_column,update_column,delete_column.
Columns can be addressed by name (columnName / toColumnName), so agents work in terms of
statuses like “In Review” rather than opaque ids. Reaching a Done column clears the working flag.
Each project's ticket types are customizable (add/rename/recolor/icon) from Board settings or MCP.
| Env var | Default | Purpose |
|---|---|---|
PORT |
8420 |
Port for the web app, API, WebSocket, and MCP. |
PLANJ_PASSWORD |
(generated) | Shared password gating human web access. |
PLANJ_SECRET |
(persisted) | Signs session cookies. If unset, one is generated and saved in the DB, so logins survive restarts. Set it to manage the value yourself. |
PLANJ_PUBLIC_URL |
http://localhost:8420 |
Public URL shown in MCP connection hints (set to your https:// domain in production). |
PLANJ_TRUST_PROXY |
1 |
Reverse-proxy hops to trust for X-Forwarded-* (real client IP, HTTPS detection). 0 to disable. |
PLANJ_HSTS |
(off) | Set 1 to send HSTS — only when always served over HTTPS via the proxy. |
PLANJ_CORS_ORIGINS |
(none) | Comma-separated origin allowlist; only needed if the API is fronted from a different origin. |
DATA_DIR |
/data (docker) |
Where SQLite + uploaded attachments live. |
Planj ships hardened for an untrusted network: helmet security headers + a strict
same-origin Content-Security-Policy, no cross-origin CORS by default, rate-limited login
(brute-force protection on the shared password), httpOnly + SameSite=Lax + Secure
(in production) session cookies with server-enforced expiry, and per-agent MCP bearer tokens
that can be revoked instantly. Uploaded attachments are served nosniff + sandboxed and
non-image types are forced to download, so an uploaded SVG/HTML file can't run script.
The shared password is a low-assurance gate — keep production instances behind your reverse proxy with TLS (and, ideally, on a private network / VPN / IP allowlist).
Planj is designed to run behind a TLS-terminating reverse proxy. A single Node port serves the
web app, API, WebSocket, and MCP, so you only proxy one upstream (host:8420).
- Enable WebSocket support. In Nginx Proxy Manager, turn on Websockets Support for the
proxy host (raw nginx: forward
Upgrade/Connection: upgradeand proxy/socket.io/). Without it the realtime/live board won't connect. - Forward the standard headers. NPM forwards
X-Forwarded-Proto/X-Forwarded-Forby default; Planj trusts one hop (PLANJ_TRUST_PROXY=1). This makesSecurecookies and the login rate limiter work correctly. - Set
PLANJ_PUBLIC_URLto yourhttps://domain so MCP connection hints are right. - Session cookies are
Securein production, so the app must be reached over HTTPS (which the proxy provides). Optionally setPLANJ_HSTS=1once you're sure it's always HTTPS.
Data is a single SQLite database (/data/planj.sqlite, WAL mode) plus the uploads/ folder.
Back it up with SQLite's online backup (safe while running) rather than a plain cp:
docker compose exec planj sh -c "sqlite3 /data/planj.sqlite \".backup '/data/backup.sqlite'\""
# then copy /data/backup.sqlite and /data/uploads off the host(If you copy files directly instead, stop the container first and copy planj.sqlite together
with its -wal and -shm sidecars.) Planj checkpoints and closes the database cleanly on
SIGTERM, so a normal docker compose stop leaves a consistent main file.
Bind-mounting
PLANJ_DATA_DIR? The container runs as UID 1000, so the host directory must be writable by it:mkdir -p /your/path && sudo chown 1000:1000 /your/pathbefore first boot. (The managed named volume needs no setup.)
A single Node process serves everything: the React web app (built with Vite), a REST API, a Socket.IO realtime layer, and an in-process MCP server (Streamable HTTP, bearer-auth) — all backed by SQLite via better-sqlite3.
client/ Vite + React + TS + Tailwind + framer-motion (the web app)
server/ Express + better-sqlite3 + Socket.IO + MCP SDK (API + realtime + MCP)
DESIGN.md the design system (palette, type, motion)
See DESIGN.md for the design system.