@rschlaefli/manifest-mcp
v0.2.0
Published
Manifest — a calm, shared AI-agent Kanban board. MCP server + singleton local daemon; agents share one live board across tools, and humans steer it with comments.
Readme
Manifest — @rschlaefli/manifest-mcp
A calm, shared Kanban board for AI coding agents — one board across every agent on your machine. Add one MCP entry and the board "just works": the first agent to start spins up a local daemon (a singleton), opens it in your browser once, and every agent (Claude Code, Codex, Cursor, …) reads and writes the same live board through MCP tools. Agents own the cards and do the work; humans watch, and comment on a card to steer — agents reply and resolve.
Use it with your agents
Add the MCP server — one entry per client:
Claude Code — .mcp.json in the project, or claude mcp add manifest -- npx -y @rschlaefli/manifest-mcp:
{
"mcpServers": {
"manifest": { "command": "npx", "args": ["-y", "@rschlaefli/manifest-mcp"] }
}
}Codex CLI — ~/.codex/config.toml (or a trusted project's .codex/config.toml):
[mcp_servers.manifest]
command = "npx"
args = ["-y", "@rschlaefli/manifest-mcp"]Cursor — ~/.cursor/mcp.json (or .cursor/mcp.json in a project):
{
"mcpServers": {
"manifest": { "command": "npx", "args": ["-y", "@rschlaefli/manifest-mcp"] }
}
}That's it. On first tool call the daemon starts, the board opens at
http://127.0.0.1:4317, and agents share state. Env knobs: MANIFEST_PORT
(default 4317), MANIFEST_DATA_DIR (default ~/.manifest), MANIFEST_NO_OPEN=1
(don't auto-open a browser), MANIFEST_IDLE_MS (idle-shutdown window, default 30 min),
MANIFEST_PLANS_DIR (base dir for resolving relative plan paths to local files).
Tip: for reproducible installs, pin a version —
npx -y @rschlaefli/manifest-mcp@<version>.
A ready-made agent workflow skill lives in
.agents/skills/manifest-board
(.claude/skills symlinks to .agents/skills, so Claude Code discovers every
skill there). Copy or symlink it into ~/.claude/skills/ to use it across projects.
Auto-sync hooks (onboarding)
So an onboarded repo keeps the board current without anyone remembering to,
.claude/settings.json wires Claude Code lifecycle hooks to
.agents/hooks/manifest-hook.mjs. The hook
talks to the local daemon over HTTP and keys each workstream to the git branch:
| Event | Effect |
| ---------------------------- | ------------------------------------------------------------------------------------------------------ |
| Session start | Registers the repo's project on the board |
| Plan approved (ExitPlanMode) | Upserts the branch's task to In Progress, writes .agents/plans/<branch>.md, links it as the plan |
| Agent stops (end of turn) | On a feature branch with no task yet, creates one (In Progress) keyed by the branch — idempotent |
| Session end | Moves the branch's In Progress task to In Review |
The stop hook is what catches work that never went through plan mode: the
first time you pause on a feature branch, its task appears; later turns are a
no-op (it only creates, never churns). It does nothing on a trunk branch
(master/main/develop).
It's fail-soft — if the daemon isn't running, hooks no-op and never block the
session. It never auto-sets Done/Blocked (those stay deliberate). Each
transition is tunable via env — MANIFEST_HOOK_PLAN_STATUS=none,
MANIFEST_HOOK_STOP=none, and MANIFEST_HOOK_SESSION_END=none disable the
plan-approve, stop, and session-end behaviors respectively.
To onboard another repo, run manifest init inside it — one idempotent,
non-clobbering command that writes the MCP entry (and gitignores .mcp.json for
the machine-specific local-path default), copies the skill + lifecycle hook, and
wires .claude/ (symlinks skills — skipped with a warning if it already
exists — plus the hooks block):
cd /path/to/repo
npx -y @rschlaefli/manifest-mcp init # writes the npx MCP entry, copies skill + hook, wires .claudeRun from npm (as above) it writes the portable npx MCP entry. Run from a local
checkout instead — node /path/to/manifest/dist/index.mjs init — and it writes a
local-path entry (node <abs>/dist/index.mjs) and gitignores .mcp.json, which
is handy for same-machine development.
Flags: --npx (force the npx entry even from a local checkout), --agent <name>
(attribution, default claude-code), --dry-run (preview). Re-running is safe;
it never duplicates or clobbers existing config (existing hooks/skill files and a
foreign .claude/skills are left as-is). Then run /mcp (or restart the client)
to connect.
MCP tools
16 tools. Tasks, projects, comments, activity:
| Tool | Purpose |
| ----------------------------------- | ------------------------------------------------------------------------ |
| list_tasks | List tasks, filter by project / status / query |
| get_task | One task by id |
| create_task | Add a workstream (auto-registers an unknown project) |
| update_task | Patch any field (status, priority, repo, branch, pr, plan…) |
| set_status | Move a task to another column |
| delete_task | Remove a task |
| list_projects / add_project | Read / register projects |
| update_project / delete_project | Rename or recolor a project · delete it (reassign or force its tasks) |
| list_comments / add_comment | Read a task's comment threads · post a comment or threaded reply |
| resolve_comment | Resolve (or reopen) a comment thread |
| list_activity | Recent activity events — status moves, edits, comments, with attribution |
| open_board | Return + open the board URL |
| open_plan | Open a task's plan (local file → OS app, URL → browser) |
Adding tools means a new server version — after upgrading, run
/mcp(or restart the client) so the agent re-reads the tool list.
How it works
- Singleton daemon — the Next.js app, started detached by the first MCP process; others health-ping and attach. Idle-shuts-down; reclaims stale locks.
- Shared state — a central
~/.manifest/data dir, so every agent (and a future synced machine) sees one board. - Storage = NDJSON (
~/.manifest/board.ndjson, atomic tmp+rename writes), schema from Zod (single source → types + validation +board.schema.json). See Storage format. - Live updates = SSE (
/api/events). Any agent's write is pushed to open browsers instantly (no reload). The SSE connection count is also presence, so the browser opens exactly once and never duplicates tabs.
Stack
- Next.js 16 (App Router) + React 19.2 + TypeScript 6 + Tailwind v4
- MCP via
@modelcontextprotocol/sdk, Zod 4 schemas,openfor the browser - The MCP server (
mcp/) is bundled withtsup; the daemon ships as a Nextoutput: "standalone"build. Requires Node ≥ 20.9.
Develop
pnpm install
pnpm dev # http://localhost:3000 (daemon UI in dev)
pnpm build:all # standalone daemon + MCP bundle + board.schema.json
node scripts/mcp-smoke.mjs # drive the built MCP server over stdio
next builduses--webpack(required foroutput: standalone; Turbopack doesn't emit it yet). Production single-build:
pnpm build:all && node dist/index.mjsWhat's in the UI
- Status board — five columns: Planned · In Progress · In Review · Done · Blocked
- Swimlanes — same cards grouped by project instead of status (toggle in the top bar)
- Project filter + search (
/or⌘Kto focus) - Comfortable / Compact density, dark / light theme (persisted)
- Detail drawer — click any card; deep-links to repo, branch, PR/MR, and plan
- Comments — the one human-write surface: a threaded composer in the drawer. Humans post; agents reply and resolve the thread over MCP. Cards show an open-thread badge.
- Activity — a per-task timeline in the drawer (created · moved · edited · commented · resolved) with agent attribution
- Drag a card to another column to change its status (persisted via the API)
- Loading, empty, and error board states
Data layer
The board reads and writes through a small REST API backed by a process-local,
NDJSON-backed in-memory store (lib/store.ts). Everything goes through a handful
of functions, so replacing the store with Postgres/Prisma is self-contained.
| Method | Route | Purpose |
| ------------ | ------------------------- | -------------------------------------------------------------------------- |
| GET | /api/tasks | Board snapshot — { projects, statuses, tasks, commentCounts } |
| POST | /api/tasks | Create a task (agent ingestion) |
| GET | /api/tasks/:id | Single task |
| PATCH | /api/tasks/:id | Partial update (status changes, edits) |
| DELETE | /api/tasks/:id | Remove a task (cascades its comments) |
| GET/POST | /api/tasks/:id/comments | List / add comments (a parentId makes a threaded reply) |
| PATCH | /api/comments/:id | Resolve or reopen a thread — { resolved } |
| GET | /api/activity | Recent activity events (optional ?task= / ?limit=) |
| GET/POST | /api/projects | List / register projects |
| PATCH | /api/projects/:key | Rename / recolor a project |
| DELETE | /api/projects/:key | Delete a project (?reassignTo= moves its tasks, ?force=1 deletes them) |
| GET | /api/events | SSE stream (live snapshots + presence) |
| GET | /api/health | Daemon liveness { ok, viewers, version, … } |
The MCP tools proxy these. Agents normally use the tools; the REST API is the same surface for scripts (the daemon validates every payload with Zod). The mutating routes are localhost-only (a Host-header guard, against DNS-rebinding from a page in your browser).
Replace the host with your daemon URL — http://localhost:3000 under pnpm dev,
http://127.0.0.1:4317 for the packaged daemon.
Example — an agent planning a workstream:
curl -X POST http://localhost:3000/api/tasks \
-H 'Content-Type: application/json' \
-d '{
"title": "Add OpenTelemetry spans to the RAG pipeline",
"project": "ai-buddy",
"priority": "P1",
"platform": "github",
"repo": "uzh-bf/ai-buddy",
"branch": "feat/otel-rag",
"plan": "docs/plans/otel-rag.md"
}'Example — moving it to review:
curl -X PATCH http://localhost:3000/api/tasks/<id> \
-H 'Content-Type: application/json' \
-d '{ "status": "In Review" }'The NDJSON store persists to
~/.manifest/board.ndjson(held in memory by the single daemon, written atomically on each change). A fresh install starts with an empty board; agents populate it over the API.
Storage format
~/.manifest/board.ndjson holds one record per line — a discriminated union on
type (meta | project | task | comment | event). Human-readable and
git-diffable:
{"type":"meta","version":2,"statuses":["Planned","In Progress","In Review","Done","Blocked"]}
{"type":"project","key":"manifest","name":"manifest","color":"#6e7bf2"}
{"type":"task","id":"sse-live","title":"SSE live updates + presence","slug":"sse-live","status":"Done","priority":"P1","project":"manifest","platform":null,"repo":null,"branch":null,"pr":null,"plan":null,"updatedAt":"2026-06-08T09:45:00.000Z","order":4}
{"type":"comment","id":"c0","taskId":"sse-live","parentId":null,"author":"human","body":"does this duplicate tabs?","createdAt":"2026-06-08T10:00:00.000Z","resolved":false}
{"type":"event","id":"e0","taskId":"sse-live","kind":"status","detail":{"from":"In Review","to":"Done"},"agent":"codex","at":"2026-06-08T10:01:00.000Z"}meta.version is 2 (it grew the comment + event records). The bump is
additive and backward-compatible: a v1 file loads unchanged, and an older daemon
skips record types it doesn't know rather than crashing.
Every line is validated (Zod) on read; bad lines are skipped with a warning, never
crashing the board. board.schema.json (generated by pnpm build:all, shipped in
the npm package) is the JSON Schema for these records — point an editor or validator at it.
Project layout
app/
layout.tsx root layout + pre-paint theme bootstrap
page.tsx renders <ManifestApp />
globals.css Tailwind import + @theme token map + keyframes/scrollbar residue
api/tasks/route.ts GET (snapshot) · POST (create)
api/tasks/[id]/route.ts GET · PATCH · DELETE (async params)
api/tasks/[id]/comments/route.ts GET · POST (threaded comments)
api/comments/[id]/route.ts PATCH (resolve / reopen)
api/activity/route.ts GET (activity events)
api/projects/route.ts GET · POST
api/projects/[key]/route.ts PATCH (rename/recolor) · DELETE
api/events/route.ts SSE stream (live + presence)
api/health/route.ts daemon liveness
api/open/route.ts open the board in the browser (daemon-side)
instrumentation.ts daemon idle-shutdown + SIGTERM (MANIFEST_DAEMON only)
components/
ManifestApp.tsx client root: state, fetch + SSE, filtering, drag wiring
TopBar.tsx Board.tsx Swimlanes.tsx Column.tsx Card.tsx Drawer.tsx
Comments.tsx Activity.tsx states.tsx badges.tsx icons.tsx
ui.ts use-fetched.ts drag.ts
lib/
schema.ts (Zod source) · store.ts (NDJSON + bus) · board-model.ts (pure view/policy helpers)
board-file.ts · http.ts (localhost guard) · paths.ts · plan.ts · presence.ts
status.ts · format.ts · types.ts (re-export)
mcp/
index.ts (stdio bin) · daemon.ts (singleton spawn/attach/open) · client.ts · tools.ts
scripts/
emit-schema.ts (board.schema.json) · mcp-smoke.mjs
tests/
vitest unit tests — pure store/model helpers (comments, activity, project admin, …)
tsup.config.ts next.config.mjs (standalone) eslint.config.mjs postcss.config.mjsStyling
Component styling is Tailwind v4 utility classes in the JSX. Design tokens
(colors, radius, fonts, animations) are declared once in app/globals.css:
:root/[data-theme="dark"|"light"]hold the OKLCH token values.@thememaps them to utilities (bg-card,text-text-2,border-border,rounded-card,animate-slide-in, …) that point back at those vars, so thedata-themetoggle keeps flipping the whole UI with no per-utility config.- A thin residual layer stays in CSS for things Tailwind can't express:
::-webkit-scrollbar,::selection, the@keyframesbodies, and the token map.
@custom-variant dark is wired to [data-theme] (not prefers-color-scheme)
so any dark: utilities track the in-app theme toggle.
Tooling notes
- Node ≥ 20.9 (Next 16).
next build/next devuse Turbopack by default. - Lint: ESLint flat config in
eslint.config.mjs; runpnpm lint. ESLint is pinned to 9.x —eslint-config-next16 isn't compatible with ESLint 10 yet.next buildno longer lints (Next 16 removednext lint). - Install:
pnpm installregeneratespnpm-lock.yaml. (In some sandboxed environments pnpm's network layer is blocked whilenpmworks — usenpm installthere.)
Design source
Manifest started from a static design prototype (HTML/CSS + React via CDN). The handoff bundle is gitignored — not part of the repo or the build.
