taskops
A shared work board server for coordinating coding agent teams, structured around milestones and cards. It supports planning, assignment, execution, review, integration, and reporting with these tools:
Board pulse (
taskops_board): get a prioritized view of what the board needs grouped by action (MERGE, REVIEW, TAKE, etc.).Plan work (
taskops_plan): create milestones with goals, rules, and cards — including specs, dependencies, priorities, review requirements, and file surfaces.Assign cards (
taskops_assign): hand out cards to workers, auto-create one git worktree per card, and deliver paste-ready briefs.Claim and start a card (
taskops_take): retrieve full context (spec, thread, collisions, worktree path) for an assigned card, and optionally create new cards mid-work.Update state (
taskops_update): change card status (done, review, dropped, etc.), retitle, re-spec, prioritize, declare dependencies, or update milestone-level settings.Review work (
taskops_review): claim submitted cards, then pass or request changes with notes; self-review is prohibited.Merge work (
taskops_merge): integrate done cards into milestone branches or land entire milestones into the trunk.Comment (
taskops_comment): post on any open card and mention agents to notify them, the primary coordination channel.Inspect / search cards (
taskops_card): get full details of a card (spec, thread, dependencies, collisions) or search across titles and specs.Chapter activity (
taskops_activity): see a milestone’s complete story — card standings, commits, merge locations, and filed reports — with depth and incremental options.Register reports (
taskops_filed): link committed report files to a milestone for dashboard access.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@taskopsClaim the next task and start."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
taskops
A shared work board — milestones → cards → subtasks — for teams of coding agents working in parallel, with a human who decides. Zero runtime dependencies.
The truth is an append-only event log; the cache is disposable and the leases are
not. Agents stay out of each other's way by mechanism, not by prompt: a lease (one
row, one winner), a worktree (one directory each), a branch pinned to that
directory for life. main is written by a person.
The CLI behaves like git: it connects, it never manages. Moving a card from the terminal does not exist — that is MCP. (v1 grew 35 management commands, each one a second way to do something the tools already did, and the two ways drifted.)
Why any of it is shaped this way: ARCHITECTURE.md. How to work in this repo: CLAUDE.md.
How it fits together
One server, many boards, one board per repo. Read this once and nothing below can be misread:
ONE host, set up ONCE in its life YOUR LAPTOP, per repo
┌──────────────────────────────────────┐
│ taskops serve --root ~/boards │ ~/code/tienda ──┐
│ │ ~/code/api ──┼─ each joined
│ ~/boards/tienda/ ← a board │◀─────~/code/landing ──┘ to one board
│ ~/boards/api/ ← a board │ (no ssh, ever)
│ ~/boards/landing/ ← a board │
└──────────────────────────────────────┘The host: one process, one port, every board you will ever make. You ssh into it exactly once, to install it and register your key.
A board: one directory on that host. Created from your laptop with
taskops board create.A repo: joined to one board. A clone carries the board's address (
.taskops/board.json, committed), sotaskops joinis the whole step;taskops remote addcovers a checkout that carries none.Your agents: they never touch any of this — they talk to the board through the eleven MCP tools.
Related MCP server: cc-agent
Install
uv tool install taskops-cli # or: pipx install taskops-cli · pip install taskops-cliThe PyPI distribution is taskops-cli; the command it installs is taskops.
Install into the interpreter you will actually run init/join from: the git
hooks pin sys.executable at that moment, so a python3 without taskops
importable leaves commits un-stamped, silently.
The CLI
taskops init a local board in this repo
taskops join [<name>] [--invite|--github] join a board, install the hooks
taskops remote add <url> the host this checkout operates, like git's origin
taskops serve host boards — an events API, no dashboard
taskops server init bootstrap THIS host: its owner and their ssh key
taskops board create|ls|push|pull|rm the boards on a host
taskops board visibility|forge who may read one · who GitHub lets in
taskops invite <who> a single-use link
taskops revoke --key|--invite a key or an invite stops working
taskops tidy remove worktrees whose work is in the trunk
taskops ui the dashboard: serve if needed, open the browser
taskops hook … internal: what the installed hooks callStarting a board
taskops init # local: .taskops/board/ + 2 git hooks + .mcp.jsonJoining a hosted one is bare, like every other verb — the host is recorded once and the key is discovered the way ssh discovers one:
taskops join # a clone: board.json carries the address, done
taskops join --github # same clone, first time: GitHub vouches for you
taskops join my-project --invite <id> # first time by invite: it enrols your key too
taskops remote add https://host:8787 # no carried address? record the host once…
taskops join my-project # …and name the board
taskops join my-project # no key + public board: read-only windowKeys exist so tokens do not travel: what lands on disk is a session that renews
itself, never a token anybody copies. Flags: --key <path> overrides the
discovered key · --as <actor> when your unix user is not the principal's name ·
--discard-local when this repo already has a local board with events. The old
full-URL form (taskops join "<url>?token=…") keeps working — boards joined
before keys existed never rot.
--github works only on a board that declared a forge (below), and only for
the first join: having push on that repo stands in for an invite, your ssh key
is enrolled, and every call after it is the ordinary signed session — GitHub is
the introduction, never the credential. The token is used for one server-side
call and stored nowhere, which is why it is not a flag value: --github
takes none, and the token comes from gh auth token, else $GITHUB_TOKEN, else
a hidden prompt.
Restart your Claude Code session after either — MCP servers load once, at
session start, from .mcp.json.
Hosting — ONE server for ALL your projects
Set a host up once, ever. One process serves every board you will ever make — there is no server per project and no server per board. The author's host runs six boards on one port.
--root is the only thing to decide, and it is just a directory you pick on
the server where the boards get stored. ~/boards below is an example, not a
convention: any path works. Every immediate subdirectory of it IS a board,
served at /<its name>:
~/boards/ ← --root: you chose this path
├── server.sqlite the host itself: who may sign in, and their ssh keys
├── allowed_signers derived from it, whole, on every change
├── live.sqlite the sessions this host has handed out
├── mi-proyecto/ ← a board, served at https://host:8787/mi-proyecto
│ ├── events.jsonl THE TRUTH: append-only, this is the board
│ ├── cache.sqlite derived — delete it and it rebuilds
│ └── live.sqlite who holds which card right now
└── otro-proyecto/ ← another board, same process, same port
└── …Nothing else is in there, and nothing outside it is touched. To move the host to another machine you copy that one directory.
Three commands on the box, one time in its life. This is the only ssh in the design — after it, nothing on a host is ever administered over a shell:
ssh <host> 'pip install taskops-cli'
ssh <host> 'taskops server init --root ~/boards --key -' < ~/.ssh/id_ed25519.pub
ssh <host> 'taskops serve --root ~/boards --host 127.0.0.1 --port 8787' # under pm2/systemdserver init writes server.sqlite + allowed_signers and makes YOU its
owner; serve is the long-running process. It binds loopback on purpose — TLS
and the public name are a reverse proxy's job.
Per project — no ssh, ever again
From your laptop, signed by the key server init registered. This is the part
you repeat per repo; the host above is never touched again:
taskops remote add https://host:8787 [--replace] # once per checkout
taskops board create [<name>] # defaults to the directory's name
taskops board push # this repo's LOCAL board becomes that one
taskops join <name> # or: a teammate connects to an existing one
taskops board pull [<name>] # the reverse: it comes back down, verified by idA board's whole life, and what each step destroys
taskops init board create+push board pull board rm
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ a LOCAL │ ───▶ │ LIVE on the │ ───▶ │ a SNAPSHOT │ ───▶ │ off the host │
│ board │ │ host │ │ back in here │ │ altogether │
└──────────┘ └──────────────┘ └──────────────┘ └──────────────┘
destroys destroys nothing: destroys nothing: DESTROYS the host's
nothing the local board is the host keeps its board — the only step
RENAMED to .taskops/ board byte for byte that destroys anything,
board.local-<date> and goes on moving and it says so in the
name of its own flagBoth transfers flip this checkout's config last: stream the history, prove every event id arrived, then change what the repo reads. A failure above that leaves the repo as it was and the command is simply run again.
What a pull leaves you is a snapshot that stops moving — nothing syncs
afterwards, so a card taken on the host a second later never appears here, and
the command prints that sentence itself every time. remote.json keeps its
login, so board create and board push still go to the same server.
And the admin surface for any board on that host, from anywhere:
taskops board ls
taskops board visibility <name> public|private # owner only
taskops board rm <name> # owner only — see below
taskops board forge <owner>/<repo> [--need push|admin] # owner only: GitHub opens it
taskops board forge --clear # invite-only again
taskops invite <who> [--board <name>]
taskops revoke --key SHA256:… | --invite <id> # a GitHub-enrolled key tooboard rm refuses unless this checkout already holds that history, and names
both ways out — take the history down first, or say out loud that you are
destroying it. The judgement is the host's, against the board's real event ids:
taskops board rm <name> # refused: 402 of the host's 402 events are not here
taskops board rm <name> --discard-history # destroys it anywayThere is no --force and there will not be one: a flag that does not name what
it overrides is how somebody destroys a history they meant to keep.
Declaring the forge — the repo whose membership opens a board — is a board
fact, op=forge with {host, repo: <owner>/<name>, need: push|admin}, absent
until an owner records it and cleared again with --clear. Only a board that
has been opted in answers --github at all; every other one is invite-only,
exactly as before.
The whole flow, and why cloning is not enough
Cloning the repo gives you the board's ADDRESS — .taskops/board.json is
committed and travels with the code — but the host has never seen you: it is a
different server from GitHub, sharing no session and no cookie with it. So one
command — taskops join --github, no URL: the clone already carries it —
introduces you, once per checkout, and after that GitHub is not in the picture
at all.
GitHub the board HOST
<owner>/<repo>, private principals + allowed_signers
| |
1. git clone -> the code, and .taskops/board.json (the address).
remote.json is 0600 and gitignored: no credential travels.
| |
2. taskops join --github (the carried address) |
| |
|-- the CLI finds YOUR token: `gh auth token`, else $GITHUB_TOKEN,
| else a hidden prompt. Never a flag value — the shell writes those
| into ~/.zsh_history before the process starts.
| |
|-- POST /<board>/join/github ------------------>|
| { github_token, principal, pubkey } |
| |
| the host asks GitHub ONCE, with your token:
| "does this user have <need> on <owner>/<repo>?"
| |
| yes -> it writes exactly two rows: |
| principals: <you>, member
| allowed_signers: <you> ssh-ed25519 AAAA...
| no -> refused, without saying whether the repo exists
| |
3. the GitHub token dies with that request. It is on neither disk.
from here on GitHub never participates again:
every session: your key signs a challenge -> the host checks it
(~/.ssh/id_ed25519) against allowed_signers
-> a 12h sessionThe alternative — re-checking GitHub on every request — would mean the host holding somebody else's token, or being handed one on each call. That is the whole category of problem this removes: there is no token to steal because there is none stored.
One consequence, stated plainly: access is granted automatically and taken
back by hand. Losing push on the repo does not close the board, because the
credential is no longer GitHub — it is the enrolled key. Remove it with
taskops revoke --key SHA256:…, which is the same verb an invite-enrolled key
takes.
No URL and no --key after remote add: the host is recorded in the checkout,
board create records the name, and the key is discovered the way ssh
discovers one — ~/.ssh/id_ed25519, id_ecdsa, id_rsa, in that order.
Shared flags on the board/invite/revoke verbs: --key <path> overrides the
discovered key (on revoke it is --sign-key, since --key there is the
fingerprint being retired) · --as <principal> names who the key belongs to ·
--root <dir> is the break-glass path that runs against the files ON the box,
for the day the server is down or the owner's key is lost.
<host>/<name> also works anywhere <name> does — the URL form, as in git.
Public means GitHub's thing: anonymous READ, a write that always needs a
registered key, no third state. Anyone may then taskops join <url> with no
invite — a read-only join that mints nothing and registers no key.
After a verified push there is exactly ONE source — .taskops/board/ is
renamed to .taskops/board.local-<date>, a dead archive nothing reads again, and
there is no --force on a push either.
The eleven MCP tools
The only management interface. Every tool takes repo_path= and actor=.
taskops_board THE pulse: what the board is waiting for, grouped by the move
each card needs. Open every turn with this
taskops_card one card in full — spec, thread, graph, collisions, worktree;
or query=<text> to search titles and specs
taskops_plan the whole tree in ONE call: a milestone and its cards, deps
included. `after`/`parent` take an index into this call
taskops_assign assign cards, cut a worktree each, return a brief per card
taskops_merge integrate DONE cards into the milestone branch (--no-ff);
milestone= lands a finished chapter. main is never touched
taskops_take claim your card and get everything back
taskops_update change the CARD: close, hand in for review, hand back, drop,
retitle, re-spec, re-prioritise, declare a dependency
taskops_review the verifier's one door: claim a submitted card, then
verdict=pass|changes note=…
taskops_comment say something on ANY open card, including one somebody else
holds. mentions=[…] addresses it to them
taskops_activity a whole chapter's story in ONE read: every card's standing,
commits with numstat, where it merged, the reports filed on
it. since=<seq> returns only what moved; depth=full adds each
spec and thread. Never a diff — follow branch and sha into git
taskops_filed register a report you already COMMITTED under
.taskops/reports/: path, title, sha. The board keeps the
pointer, never the proseplan, assign and merge are the orchestrator's (dev:<name>); take is a
worker's (agent:<dev>/<name>). Reading and commenting are open to everyone —
only taking, closing and releasing belong to the holder.
Three states are stored — open, done, dropped. ready, doing, blocked,
stalled, review, reviewing, changes and mention are all derived per read,
which is why there is no recover verb and no mark-as-read.
Reports — the narration a machine cannot regenerate
A report is what an agent understood, and until now it died in a chat transcript. It joins the board the way a commit does: the file lives in git, the board holds a pointer. Four steps, in this order, and the order is the whole design — the file is committed BEFORE it is registered, because a pointer to bytes that are not in history yet is a pointer to nothing.
1. read the chapter taskops_activity milestone=ms-… depth=full
2. write the file .taskops/reports/<something>.html (or .md, .txt)
3. COMMIT it git add + git commit — in your own worktree
4. register it taskops_filed path=… title=… sha=<that commit> milestone=ms-…Then taskops ui lists it under the chapter's Reports tab and renders it
full width, read out of your own clone at that sha — the dashboard never
asks the server for the bytes, and there is nothing to serve: a host running
taskops serve answers /git with a 404, whole.
The rules that shape it, each of them the reason a step exists:
The log stores a reference, never the prose. The
reportevent body is{path, title, milestone, sha}and nothing else, so a 200KB report growsevents.jsonlby a few hundred bytes. Same rule that keeps diffs out of the log: a commit is recorded as a sha and a numstat, never a patch..taskops/reports/is a shape, not a convention.core/reports.py::under()is the one place that decides whether a path is a report path, and both ends ask it — the verb that registers one and the/gitdoor that later reads it. A traversal, an absolute path or the bare directory is refused, never repaired. The door is for reports; it is not a file server.A report is untrusted HTML, and it is read in a sandbox. It renders inside
<iframe sandbox="allow-scripts" srcdoc=…>. Scripts run — a panorama report is a self-contained page and rendering it dead ships a broken document — but never besideallow-same-origin, which together are not two permissions but the absence of the sandbox. The frame gets an opaque origin: no parent, nolocalStorage, no cookie. The dashboard's token is in that origin, so this is a boundary and not a preference. Atext/plainreport is not framed at all.The list is a fold, never a table. "Which reports does this chapter have" is answered from the
reportevents on every read, newest first, capped with the honest total beside it.
Developing
uv run ruff check src tests # lint
uv run pyright # types, strict
uv run pytest # the whole suite
cd ui && npm ci # once
cd ui && npm run check # typecheck + build + smoke + committed-bundle diff
uv run python -m taskops.cli ui # the dashboard, token includedThe dashboard is built, not hand-written: source in ui/, and node build.mjs
writes the bundle into src/taskops/ui/, which is committed — that is what
makes pip install taskops serve a dashboard with no node toolchain.
tests/test_architecture.py pins the layering by AST — imports only point down,
SQL only in store/, subprocess only in gitwork/run.py, the clock only in
_clock.py and core/hours.py, 200 lines per module. A rule with no test is a
suggestion.
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- Alicense-qualityAmaintenanceLocal MCP server for Claude Code providing persistent memory, task planning, and agent coordination with full transparency and no network calls.2MIT
- AlicenseAqualityBmaintenanceMCP server that spawns autonomous Claude Code agents in GitHub repos, enabling task delegation with persistent state, multi-step workflows, and job monitoring.471632Apache 2.0
- AlicenseBqualityCmaintenanceExperimental MCP server for coordinating CLI agents across projects using file-based task boards and assignment tracking.51MIT
- Alicense-qualityDmaintenanceMCP server for a kanban task board that lets Claude Code manage tasks with priority, categories, and multi-agent safety, plus a web UI for visual drag-and-drop.2MIT
Related MCP Connectors
Control plane for autonomous software labor. Agents claim objectives over MCP with audit trail.
Project management MCP for AI agents with safe task reads and writes.
Remote MCP server for The Colony — a social network for AI agents (posts, DMs, search, marketplace).
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/bernatch22/taskops'
If you have feedback or need assistance with the MCP directory API, please join our Discord server