Git-native task tracking: every card is a Markdown file
No database, no export button, no API to reverse-engineer. A card is .portbay/tasks/<id>.md — YAML frontmatter and a Markdown body — and the file is the source of truth, not a cache of one.
Every card is a file
Drop one in, or write it in your editor. Same thing.
Can I keep my task board in my git repo as Markdown files?
Yes, and a few tools now work this way: one task per Markdown file with YAML frontmatter, kept beside the code instead of stored in a hosted database. PortBay's board is built on that. Every card is one file at .portbay/tasks/<id>.md — frontmatter for title, status, priority, labels, estimate, due date and the verify gates, and a Markdown body that is the description. Change status: Todo to status: InProgress in any editor, save, and the board moves the card about 150 milliseconds after the writes stop. Agents get two lanes: an audited one over MCP or the CLI, which takes a per-card lock and writes an attributed audit entry, and a raw lane that edits the file directly. In a git repository the canonical copy is a git ref, refs/portbay/board, so the board no longer changes with the branch you have checked out.
Verified against the product, 2026-09-06.
The same card, twice: a board column and a text file
One line changes in .portbay/tasks/t_01HF9K.md — status: Todo becomes status: InProgress — and the card crosses the board about 150 ms after the write lands.
From a text editor to the board and back
- Read the contract that ships beside the cards
- Write a card as a file
- Move it by editing one line
- Or take the audited lane
- Write safely when two things write at once
Read the contract that ships beside the cards
PortBay writes .portbay/tasks/AGENTS.md into the same directory: the card anatomy, every frontmatter field and who is allowed to edit it, the two edit lanes, and the locking discipline. It is generated, safe to read, and the one file in that folder that is not a card.
cat .portbay/tasks/AGENTS.mdWrite a card as a file
Frontmatter plus a body. The id is a stable ULID you never change; status is the column; title, priority, labels, estimate, acceptance, verifyChecks and doneWhen are yours to edit. Any key PortBay does not recognise is round-tripped under the card's custom map instead of being dropped.
.portbay/tasks/t_01HF9K7Q2M3B4N5P6R7S8T9V.mdMove it by editing one line
Set status to another column and save. The watcher coalesces a burst of writes, waits for about 150 milliseconds of quiet, reconciles the external edit and re-renders once — so a script that rewrites forty cards produces one board update, not forty.
sed -i '' 's/^status: Todo$/status: InProgress/' t_01HF9K.mdOr take the audited lane
The raw lane is attributed to “edited on disk (System)”, because a file write carries no identity. Going through MCP or the CLI instead takes the per-card lock, honours your run id, and puts your name on the entry in the activity feed. Comments only exist on that lane — a file cannot carry an audit thread.
portbay tasks edit <project> <card> --title 'Fix the login bug'Write safely when two things write at once
For a read-modify-write, take the advisory per-card lock; for a whole-file replacement, write a temp file and rename over the card. Either way a concurrent PortBay write cannot clobber yours, and yours cannot clobber PortBay's.
.portbay/.runtime/cards/<id>.lockWhat the file format guarantees
.portbay/tasks/<id>.md — YAML frontmatter and a Markdown body. The file is the durable source of truth, not a cache.
The watcher drains a burst of writes and re-renders once the writes stop, so a bulk rewrite costs one update.
title, status, priority, labels, estimate, acceptance, verifyChecks, doneWhen, spec, touchpoints, due — each one documented with its owner.
signedOff and blockedExitApproval. An agent must never write either; a sign-off dated before the card's current claim is refused.
portbay_task_edit over MCP, or portbay tasks edit. Takes the per-card lock and writes an attributed audit entry.
Edit the .md with any tool. PortBay records an “edited on disk” System entry but cannot attribute the change to you.
.portbay/.runtime/cards/<id>.lock, or write atomically with a temp file and a rename.
refs/portbay/board — a ref git never checks out, shared by every worktree of the repo. Updates are compare-and-swap; conflicting edits are three-way merged, never clobbered.
Round-tripped under the card's custom map rather than dropped or rejected, so your own fields survive a PortBay write.
Verified against the app source and the generated .portbay/tasks/AGENTS.md. The board ships in every tier; there is no board entitlement gate.
The two alternatives, and what each one costs
Task tracking usually lands in one of two places: a single TODO.md that every change collides in, or a hosted board whose only exit is an export endpoint.
One file per card gives every task its own history, its own diff, and a path an agent can read with cat.
# Option 1: one file for the whole board$ cat TODO.md- [ ] Fix the login bug # no id, no owner, no gates- [x] Ship the OG image # no per-task history$ git log -p -- TODO.md # every task's history, interleaved # Option 2: a database you cannot diff$ curl -sH "Authorization: Bearer $TOKEN" \ https://api.example.com/v1/issues | jq '.[] | .title'# No git blame on a task. No grep across the board. No offline read.# And the migration out is a script you write yourself. # Either way, an agent cannot open a task with the tools it already has:$ cat .portbay/tasks/t_01HF9K.md # ...this is the thing that is missing
One app, not a stack of tools
Agent task board
A Kanban board both you and your agents work. Cards are Markdown in the repo; moving one to To Do dispatches the agent.
Explore →Agent memory
A rolling hand-off brief and a learned rule-book as Markdown in your repo, read back on every run — and a failed run can write the next rule.
Explore →CLI
brew install --cask portbay ships a full CLI over the same Rust core: lifecycle, logs, doctor, config-as-code and the board, with --json.
Explore →Asked before downloading
Not to your branch, by default — and the reason is a bug that fix closed. Cards used to be ordinary tracked files, so the board showed different counts depending on which branch was checked out, and an uncommitted card closure was invisible to everyone including the app. PortBay now keeps the canonical board in refs/portbay/board, a git ref that is never checked out and that every worktree of the repository shares, and writes a .gitignore in .portbay/tasks/ so the working copies stop being branch-scoped. The cards are still plain Markdown on disk and still versioned by git — in a ref rather than in your branch. Set PORTBAY_BOARD_REF=0, or delete the ref with git update-ref -d refs/portbay/board, to go back to ordinary tracked files; nothing was moved, so nothing is lost either way.
Yes. Write .portbay/tasks/<id>.md with an id, a title and a status and the board picks it up on the next watcher pass. The one discipline that matters is atomicity: write a temp file and rename it over the card, or take the advisory lock at .portbay/.runtime/cards/<id>.lock for a read-modify-write, so a concurrent PortBay write cannot land halfway through yours.
Because .portbay/tasks/.gitignore ignores *.md, and ripgrep — plus any grep wrapper that respects gitignore — skips ignored files by default. A recursive search returns zero matches for strings that are definitely there, silently. Use an explicit glob, rg --no-ignore, or find .portbay/tasks -name '*.md' -print0 | xargs -0 grep -l. This is a real footgun and it is written down in PortBay's own rule-book for exactly that reason.
The file format is the same idea, and that is deliberate. What is different is what sits on top: a board a dispatched agent can move a card on over MCP with an audit trail, verify gates and doneWhen signals that decide when a card is allowed to reach Done, a per-card lock so concurrent writers do not clobber each other, and a canonical store that is a git ref rather than the checked-out branch. If you only need a text file with checkboxes, a TODO.md is genuinely fine.
Every board-ref update is a compare-and-swap, so a write cannot silently overwrite another process's. A card that moved on both sides is merged with git merge-file over ours, the last-synced base and theirs, so two edits to different parts of one card both land. If the same lines moved on both sides, nothing is dropped: your local file is left alone, the other version stays at the ref tip, and a full copy of it is parked at .portbay/tasks/.conflicts/<id>.<tag>.md until a person picks one.

Give your projects and your agents a real local home.
Download for macOSFree & open source · macOS 11+ on Apple Silicon · Pro from $10/mo