@nickmeriano/task
v0.13.0
Published
A task manager that lives in your repo — plain-text tickets in .task/, a zero-dependency CLI for humans and agents, and a live kanban/table UI via `task serve`.
Maintainers
Readme
@nickmeriano/task
A task manager that lives in your repo.
Tasks and their status are coupled to the code — so keep them next to it.
State is plain text in .task/ at your project root — one markdown file per
ticket and per comment: no accounts, no connectors, no drift between the board
and the branch. Ticket changes show up as readable diffs in PRs, and two
branches editing different tickets merge cleanly. Built for the way projects
work now — you and your AI agents share one backlog, and the board updates
live while agents move tickets from another terminal.
npx @nickmeriano/task initinit creates .task/ — commit it, that's the point.
Teach your agent
The package ships an agent skill at skill/SKILL.md with the conventions that
make a shared board work: check it before starting, move tasks as work actually
progresses, comment at milestones, file what you discover instead of letting it
evaporate.
npm i -D @nickmeriano/task # pnpm add -D / yarn add -D all work
npx skills add ./node_modules/@nickmeriano/task/skillA local path rather than a URL, on purpose: you get the skill that your
installed version ships, it works offline, and nothing is fetched from a third
party. skills is one manager among several — any other
works the same way, pointed at that directory.
It deliberately isn't installed for you. Where skills live differs by agent and by repo layout, and a task manager guessing at that puts a copy somewhere nothing reads — which is exactly what happened the first time this tried.
Use
task add "Wire up webhook retries" --tags api --goal launch
task list # open tasks, board order
task ask 1 "Mint the API key" # file what a person owes the ticket
task resolve 1 1 # …and check it off once it's done
task inbox # every open ask on in-progress work, repo-wide
task search "webhook" # find tickets by what they say — titles,
# descriptions, comments and asks, with snippets
task move 1 in_progress # status changes all go through move; done is
# refused while the ticket has open asks
task promote 1 # backlog → todo, the human "build this" call;
# blocked tickets promote with the hold reported
task link 2 --blocked-by 1 # dependency, visible from both tasks
task update 1 --pr https://github.com/you/repo/pull/42
task comment 1 "shipped in #42" --author claude
task boards # every board in the repo, with prefixes
task archive --all # move finished tickets to .task/archive/
task check # lint the board files; --fix repairs the mechanical part
task serve # opens the kanban + table UI, live
task export # the same board as static files, for any host
task publish # same board, at a URL you can open on a phoneClaiming — so two workers never pick up the same ticket
When agents (or agents and you) work one backlog, picking up a ticket needs
a lock. task claim is that lock, built out of things git already guarantees:
task claim TAS-21 # claim it: lock branch on origin, checked out
task claim TAS-21 TAS-22 # two tickets, one atomic push, one work branch
task claim --goal launch # every claimable ticket in a goal
task list --claimable # the queue: what a worker may pick up next
task list --claims # who is on what, and where the work lands
task claim --release TAS-21 # abandon a claim cleanly
task sweep # janitor: delete claims whose tickets are finished- The claim is a lock branch with an empty marker.
task claim TAS-21createstask/claim/tas-x7k4mon origin pointing at one empty commit on the default branch's tip. Its message is trailers only — who claimed, when,Delivered-By:(the branch the work lands on), and a nonce — so the lock carries no file diff and can never conflict with an edit to the ticket. The ticket file does not change: a live claim is what says "in progress"; the file keeps sayingtodountil you move it. The branch existing on origin is the claim — nothing side-band to clean up, because it dies at merge or release. - Lock and work, one branch when that's convenient. Claimed from the
default branch, the lock branch is checked out and the work delivers
there — the merge closes the claim. Claimed from any other branch (a
provisioned session branch, a feature branch), the lock is pushed and the
checkout stays exactly where it is, dirty or not;
Delivered-Byrecords your branch, and that is where the work — and any asks filed on the ticket — live.--delivered-by <branch>names it outright. - Several tickets are one push.
task claim A B Cbuilds one marker per ticket and pushes them with--atomic: every lock or none. A bundle with one taken ticket locks nothing and exits1naming it — no rollback to get wrong.--goal <slug>claims a goal's claimable tickets and reports any that raced away. One delivery branch, one PR ([A +2] …). - The namespace is yours.
task/claim/is only the default —claims.branchPrefixin.task/config.jsonmoves the whole namespace, and it lives in the committed config on purpose: every worker and every clone must agree on what "claimed" looks like, or there is no lock. Pick the value before the first claim; renaming later strands in-flight claims. - Atomic by construction. The push only succeeds if the branch doesn't
exist yet (a compare-and-swap on the ref), so two concurrent claimers of the
same ticket get exactly one winner — the nonce keeps their commits distinct
objects, since git treats pushing an id a ref already holds as a no-op.
Exit codes are the contract:
0claimed,1already claimed (pick the next ticket),2not claimable — nottodoon origin's default branch, blocked, a dirty tree when the claim would switch branches, or at the cap. Open asks don't gate a claim: an agent can work the code while a person owes the ticket something. - The lifecycle rides the branch. Before the PR is marked ready,
task move <id> done+task update --pron the delivery branch; the merge lands code anddonetogether, and the branch auto-deletes. Between claim and merge the default branch still saystodo— the claim is the truth about in-flight work:task list --claimsreads every live claim off origin with itsDelivered-By, andgit ls-remote origin 'task/claim/*'(or your configured namespace) is the same list raw. - The WIP cap counts sessions.
claims.maxOpenCounton the root board caps distinct delivery branches in flight: a bundle is one session, adding a ticket to your own bundle never trips it, and a new delivery is refused (2) once the cap is reached;--forceis the escape. - Claim before you work — humans too. The same verb covers manual work:
run
task claim <id>before starting a ticket yourself and any scheduled agent will skip it.task list --claimableshowstodotickets in position order minus blocked and already-claimed — the top entry is what an autonomous worker takes next, which makes column order your priority queue. - Releases are verified, and
task sweepis the janitor.--releasedeletes the lock branch (a pure lock's work branch is left alone). Some environments silently drop branch-deletion pushes — git reports success while the ref survives — so--releasere-checks withls-remoteand fails loudly instead of reporting a release that didn't happen. The systemic healer istask sweep: delete every claim branch on origin whose ticket is done, canceled, or missing on the current checkout, across all the repo's boards, verifying each deletion; it exits non-zero if a deletion was attempted and failed. Its jurisdiction is exactly the namesclaimmints —<namespace><prefix>-<valid key>; a branch under the namespace that doesn't fit (a pre-migration numeric claim, a hand-made branch) is reported and left alone, because it may be an open PR's head and deleting what claiming didn't create is a judgment call, not janitorial work. Pure git and zero judgment by design — the trigger belongs to a runner, not an agent (see below).
Environment requirements for agent runners
The claim protocol is plain git, so any harness — Claude, Codex, CI, a laptop cron — can drive it if its environment meets this contract:
- Git identity:
user.name/user.emailset (the marker commit needs an author, andClaimed-Byrecords it). - Push rights to the claim namespace (
claims.branchPrefix): creating a branch there is claiming. - Branch deletion rights for a full-service worker — or accept degraded
release: environments that can't delete remote branches get a loud error
from
task claim --releaseinstead of a silent lie, and atask sweeprunning somewhere capable heals the stale locks once tickets finish. - A branch-pinned harness (the session must deliver its work on an
assigned branch) just claims from that branch: the lock records it as
Delivered-Byand the checkout is never touched. A free harness claims from the default branch and works on the lock branch. No flag, no vendor detection anywhere — where you stand when you claim is the whole rule.
The reference sweep trigger is ~10 lines of GitHub Actions — push-to-main plus a daily cron, deliberately deterministic (no agent, no judgment):
# .github/workflows/task-sweep.yml
name: Task sweep
on:
push:
branches: [main]
schedule:
- cron: "17 4 * * *"
permissions:
contents: write # branch deletion
jobs:
sweep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npx @nickmeriano/task sweepAny runner that can git push --delete works identically — a laptop cron
running npx @nickmeriano/task sweep is the same janitor.
Automation hooks: commit trailers and --dry-run
Automation that reacts to board events shouldn't have to parse human prose. Two stable interfaces exist for it:
Commit trailers. Every commit the CLI authors for a board operation carries git trailers naming the operation and the ticket:
chore(board): resolve ask [1] on TAS-x7k4m
Task-Op: resolve-ask
Task-Id: TAS-x7k4mThe subject line is presentation and may be reworded in any release; the
Task-Op / Task-Id trailers are the contract — documented here, pinned by
tests, existing op names never change meaning. Ops emitted today: claim
(the empty marker commit, which also carries Claimed-By, Delivered-By,
Claimed-At and Claim-Nonce), ask, resolve-ask,
reopen-ask, delete-ask — each ask op is its own single-commit push to
the claim branch, so a forge webhook's head commit is the operation.
Reserved for later: begin-implement (a worker marking the start of an
implement pass). Treat unknown ops as "not the one I'm looking for", never
an error. Reading them is plain git:
git log -1 --format='%(trailers:key=Task-Op,valueonly)' <sha>task claim --dry-run (plain claim and --next) answers "would a claim
succeed right now?" by running the real command's decision path — every
gate, in order, against live origin state — and stopping before the first
write. Same exit codes (0 would claim, 1 queue empty / already claimed,
2 preconditions failed), nothing created, checkout untouched. It's the
predicate for schedulers: check whether waking a worker is worth it without
disturbing the board. A 0 is a reading, not a reservation — no lock is
taken, so a concurrent claimer can still win a moment later.
Together they make event-driven dispatch a few lines of runner config: on a
board push to the default branch, fire your worker if
task claim --next --dry-run exits 0; on a claim-branch push, fire when
the head commit's Task-Op is resolve-ask (an answered question is what
makes a stalled claim workable again).
Git, from the board
Board edits — a drag, a checkbox, a new ticket — are plain writes into your
working tree; nothing commits behind your back. task serve shows you that
truth and gives it verbs: the header grows a branch chip with the current
branch, a dirty-count badge, and ahead/behind, cards carry an "uncommitted
edits" dot, and the chip's panel commits, switches branches, and opens PRs.
- Commits are board-scoped. "Commit board changes" runs a pathspec-scoped
commit of each board's
.task/directory — your half-finished code stays dirty and stays out. Messages default tochore(board): <ticket ids>. - Context decides where the commit goes. On a feature or claim branch it lands right there — ticket edits usually belong to that branch's PR. On the default branch the flow is a fresh board branch plus a PR, so board edits never land on main from a click.
- PRs: files remember, git informs. Opening a PR uses
ghwhen it's on your PATH and falls back to GitHub's compare page when it isn't. A created PR's URL is written into theprs:frontmatter of every ticket the branch touches — the file stays canonical — and, withgh, ticket chips gain display-only draft/open/merged state resolved live from the branch. - Branch switching is a real checkout — distinct from the hosted board's
?ref=preview — and refuses while tracked files outside.task/are dirty, so a click in a board can't carry your code somewhere else. - Dragging a claimed ticket out of In Progress releases the claim. That's
task claim --releasewearing a drop: a confirm explains that the claim branch and its commits go away, and the move happens only if the branch deletion succeeds. - It disappears when it can't work. No git, no
origin, the hosted board, the static snapshot: no git surface at all, no errors. Writes are guarded by a same-origin check, so a webpage in the same browser can't aim POSTs at your board.
Chat, from the board
task serve also hosts a chat — press ⌘J, or the speech-bubble button in
the bottom-right corner. It is Claude Code, running on your own machine on
your own login, with this board's verbs as its only tools: list, show,
search, add, update, move, promote, link, ask, resolve, comment, goals,
inbox. Ask what's in progress, have it file the three tickets you just
described, promote a backlog item and comment why — and watch the board
update beside the window as each tool writes, the same file watch that
shows an agent working from another terminal.
- Board mode, on purpose. No shell, no files, no web: the built-in tool
set is emptied and the one MCP server the session gets is the board's own,
so nothing it can do needs a permission prompt, and nothing it does
escapes
.task/. Your terminal's settings,CLAUDE.mdfiles and skills stay in your terminal. Its writes are attributed toclaude, like an agent's CLI comments. - It is your Claude Code. The package stays dependency-free: the chat
spawns whatever
claudeis on your PATH (or the native installer's~/.claude/local/claude) and speaks its streaming protocol directly. Not installed? The composer says so, with the install command, and checks again without a restart. - Threads persist and resume. Each thread remembers the board it was
opened on and its Claude session, so the next message continues the same
conversation. Threads live per checkout under your user data directory
(
$TASK_CHAT_DIRto move them) — never in.task/, so a chat is not a board change, not a commit, and not in an export. - The same tools, for any agent. While the server runs, the board's
tools are an MCP endpoint at
/mcp:claude mcp add --transport http task http://127.0.0.1:4400/mcpgives a terminal Claude Code the same verbs against the same open board. - It disappears when it can't work. The hosted board and a static export
have no machine to run Claude on, so they draw no chat at all. Writes to
/mcpsit behind the same same-origin gate as the rest of the API.
From anywhere
task serve is localhost, which is the right answer right up until you're not
at that machine. task publish gives the same board a URL:
task publish # private — only people who can see the repo on GitHub
task publish --public # anyone with the link
task unpublish # take the URL down againIt opens a browser once to install a GitHub App on that repository, and prints
task.nickmeriano.com/<owner>/<repo>. After that it's just a URL — commit and
push, and the board updates, including when an agent in a cloud session is the
one committing.
- Read-only, by construction. The App asks GitHub for
Contents: Read-only, so the hosted board cannot write to your repo even if it wanted to. Editing still happens where it always did: the CLI, an agent, ortask serve. - Private by default — including for public repos. Your code being open
source doesn't mean you meant to publish a live feed of what you're working on
next.
--publicis opt-in, and re-running with the other flag flips it. - Nothing is copied. Tasks are read from
.task/on your default branch, per request. The URL is a window onto your repo, not a second database — andtask publishwrites nothing to.task/, so there is no state to commit and no token in your repo. - No new account. You sign in with GitHub, and who can see the board is whoever can see the repository — there are no seats, invites or member settings, because there's no permission model separate from GitHub's.
- Reversible.
task unpublishremoves the board and the URL 404s immediately; your tasks are untouched, because they were only ever read from.task/. Changing your mind about visibility doesn't need that at all —task publish --privateis enough. - Any branch. The board renders your default branch, but
?ref=<branch>renders any other — and the header grows a branch switcher when there's more than one. That's PR review for board changes: see the board as the PR would leave it, before merging. Locally none of this is needed —task serveshows your working tree, so previewing a branch isgit checkout.
Since the preview URL is predictable, a small workflow in your repo can comment it on every PR that touches board state — the App itself stays read-only and never writes to your repository:
# .github/workflows/board-preview.yml
name: Board preview
on:
pull_request:
paths: [".task/**", "**/.task/**"]
permissions:
pull-requests: write
jobs:
comment:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const branch = context.payload.pull_request.head.ref
const url = `https://task.nickmeriano.com/${context.repo.owner}/${context.repo.repo}?ref=${encodeURIComponent(branch)}`
const marker = "<!-- board-preview -->"
const body = `${marker}\n📋 [Preview the board at \`${branch}\`](${url})`
const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: context.issue.number })
const existing = comments.find((c) => c.body?.startsWith(marker))
if (existing) await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
else await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })- Ids are
PREFIX-x7k4mor justx7k4m— a random 5-character key minted attask add, so two branches that have never seen each other can never mint the same id; any unique prefix of a key resolves git-style (task show x7). The board prefix is the first three letters of the project name ("phone" →PHO-1), or whatevertask init --prefixsays. - Ids route by prefix: a bare
x7k4mmeans the nearest board, whileTAS-x7k4mreaches the TAS board from anywhere in the monorepo. An unknown or ambiguous prefix fails loudly instead of guessing.task boardslists every board — prefix, name, path, open count — with a*on the one commands target from the current directory. task archive <id>moves a done/canceled ticket's directory to.task/archive/— off the board and out of every hot path, so boards stay fast as history accumulates. Archived tickets stay readable (task show,task list --archived), keep their comments, and their numbers stay reserved forever.task archive --allsweeps everything finished;task unarchive <id>puts one back.- Statuses:
backlogtodoin_progressdonecanceled. - A task carries tags, at most one goal, and asks — checklist items
a person owes it (
task ask), each one file undertickets/<key>/asks/. Open asks derive the amber needs-human badge, feedtask inbox(the repo-wide "what do I owe NOW" view — in-progress work only, and a ticket claimed on a branch counts, its asks read from the branch), and gatedone: a ticket finishes when nobody owes it anything.--tag a,bmatches either tag. task link A --blocked-by Bmarks a dependency. It's one relation seen from both ends — B's page says it blocks A — and the board badges A as blocked until B is done or canceled.--pr <url>attaches a pull request to a task (--prsreplaces the list); attached PRs show in the task's rail and as a count on its card. The elegant loop is an agent convention, not a webhook: put the id in the PR title ([TAS-x7k4m] …) and runtask update TAS-x7k4m --pr <url>when opening the PR — the shipped skill tells agents to do exactly that.- Every task has Copy link — the icon next to its title, or right-click / long-press any card or row — so handing a ticket to an agent is one paste.
- Comments are attributed from
git config user.namewith no setup — override with--authoror$TASK_AUTHOR, and check withtask whoami. - Every command takes
--json— that's the agent interface. - Commands work from any subdirectory; the CLI walks up to find
.task/the way git finds.git. task serveopens your browser on the board (--no-opendoesn't) and takes the next free port when 4400 is busy, so a server you left running elsewhere can't stop you —--strict-portfails on a taken port instead.task publishworks out the repo from your git remotes; pass--repo owner/nameif the guess is wrong (a fork with two remotes, say).task exportwrites the board out as static files — the same UItask serveships, plus asnapshot.jsonit reads instead of an API — so a CI job can put a read-only board on any static host with nothing running behind it. It shows a banner naming the commit and the build time, because a board that looks live but was built three days ago is worse than no board. It defaults to./task-export; re-exporting refreshes one in place and leaves anything else in the directory alone (a directory that isn't a previous export needs--force). Hosted anywhere but a domain root, pass the mount point —task export --base /board/forexample.com/board/— so the page knows where its assets and snapshot live. Point the host's SPA fallback atindex.htmlso deep links resolve. It also writes a live upgrade for Cloudflare Pages that stays dormant until you add a secret — see "Live data for an exported board" below;--no-functionsleaves it out.task serveis the one command that also looks down: run it at a monorepo root and it serves every nested board from one server, with a board switcher in the header. Run it inside a package and you get just that board. Every other command stays scoped to the nearest.task/.- Boards from before 0.6 stored their state as a committed SQLite database;
run
task migrateonce on@nickmeriano/[email protected]to move one onto text files (the database stays on disk as an ignored backup) — newer versions read only the text format.
Deploy the board
task export writes the board out as plain files, so putting it on the web is
a CI job and nothing else — no server, no database, no runtime. Cloudflare
Pages is the path this section walks (per-branch preview URLs come free, and
the later live upgrade has somewhere to land), but the output is an ordinary
directory: see "Any static host" below.
# .github/workflows/board.yml
name: Board
on: push
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npx @nickmeriano/task export --out board
# The board's routes are real paths (/task/x7k4m), so the host has to
# answer them with index.html instead of a 404.
- run: echo '/* /index.html 200' > board/_redirects
- uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy board --project-name=<your-project> --branch=${{ github.ref_name }}Three things the workflow can't do for you, once:
- Create the Pages project (Direct Upload) and use its name for
--project-name. - Add
CLOUDFLARE_API_TOKENas a repository secret — a token with Pages: Edit on that account, nothing more. - Add
CLOUDFLARE_ACCOUNT_IDas a repository secret.
Notes on the shape:
on: push, every branch, is the point. A claim is a push, so the deployed board tracks claims at CI latency with no extra machinery — and--branch=${{ github.ref_name }}hands each branch its own Pages preview URL, so "the board as this PR would leave it" is a link rather than a checkout.- The
_redirectsline is belt and braces here, and required elsewhere.task exportwritesindex.html,snapshot.jsonandassets/— it does not write an SPA fallback, and the board's routes are real paths (/task/<key>,/goal/<slug>). Pages already servesindex.htmlfor unmatched paths when the output has no404.html, so the line changes nothing there; on a host without that default, leaving it out means every deep link 404s while the home page looks fine. setup-nodeis not decoration. The package needs Node >= 22.13; without the step you get the runner's default and a confusing failure.- No
--base. Pages serves at a domain root, where the default base/is right. - The live upgrade is already in the output.
task exportalso writes afunctions/directory holding a Pages Function that stays dormant until the project has a token — see "Live data for an exported board" below. Turning it on is a secret in the Pages project, never a change to this file.
Any static host
The output is a directory of static files, so anything that serves those works. Two host-side requirements, and they're the whole list:
- An SPA fallback to
index.html, however that host spells it (_redirectson Pages and Netlify, a rewrite rule elsewhere). The routes are real paths. task export --base /board/if you're not serving from a domain root, so the page knows where its assets and snapshot live.
No platform matrix beyond that — there isn't one to write.
An exported board is as public as wherever you put it
Worth saying plainly, because the export itself has no opinion about it: a Pages URL is public even when the repository is private. Anyone with the link reads your board.
If that's not what you want, you have two answers. Put the deploy behind your
own perimeter (Cloudflare Access and the like) — the export doesn't care what's
in front of it. Or use task publish, whose entire point is the other trade:
GitHub-synced private access, where who can see the board is whoever can see
the repo.
Which is the shape of the whole picture — same board, three deployments, and you pick by how much setup you want:
| | Where it runs | Who can see it | Setup |
| --- | --- | --- | --- |
| task serve | your machine | you | none |
| task export | any static host | whoever you let | a CI job and a host |
| task export + a token | Cloudflare Pages | whoever you let, live | the same, plus one secret |
| task publish | hosted | whoever can see the repo | one command |
Live data for an exported board
An export is a snapshot: as fresh as the last CI run, and only of the branch it ran on. When that's not enough — you want the board to read the repository now, or to browse a branch that nobody exported — the same deploy can upgrade itself, and the export has already packed what that takes.
Every task export writes functions/api/[[path]].js beside the static
files: a Cloudflare Pages Function with the repository baked in (worked out
from your git remotes; --repo owner/name sets it when there isn't one, and
--no-functions leaves the file out). Pages deploys anything under
functions/ with the rest of the directory, and other hosts ignore it. It
does nothing until the host has a token — so the workflow above has already
deployed it, dormant. To wake it, once:
- On GitHub, create a fine-grained personal access token: Only select repositories → this one; Repository permissions → Contents: Read-only, nothing else. That is the whole permission set the function needs, and the only one you should give it.
- In the Pages project's settings, add it as a secret named
TASK_GITHUB_TOKEN.
That's the upgrade. The board's boot probe finds an API answering next to it
and reads from there instead of snapshot.json: the staleness banner is gone,
every view is the repository's current .task/, and the header grows the
branch switcher — ?ref=<branch> reads any ref, so a PR's board is a link
again, the way it is on task publish. Until the secret is set (or if it
expires), the function answers 503, the probe reads that as "no API", and the
same files serve the snapshot as before. Nobody chooses an architecture up
front; the deploy is whichever tier it's configured for.
Still read-only, still by construction: the token can only read, and the function refuses anything but a GET before it even looks for one.
Two things to know before you turn it on:
- The token is a person's. Fine-grained PATs expire (a year at most) and belong to whoever minted them — when they leave, the board goes back to being a snapshot until someone mints a new one. That's the trade for needing no GitHub App of your own.
- There is no viewer auth in this tier, and a token makes that matter
more. An exported snapshot is as public as wherever you put it; a live
function is the repository's current
.task/, served to anyone who can reach the URL, for as long as the secret is set — including after you stop deploying.task exportsays so in one line every time it writes the file, because the reminder belongs next to the setup. Put the deploy behind your own perimeter (Cloudflare Access and the like), or usetask publish, whose whole point is GitHub-synced access without a token of your own.
Any other host
The function is a three-line adapter around an exported handler. Anything
that can run a (Request) => Promise<Response> — a Netlify function, a Deno
or Bun server, a Worker of your own — gets the same live board:
import { createBoardHandler } from "@nickmeriano/task"
const board = createBoardHandler({
token: process.env.TASK_GITHUB_TOKEN!, // or an async getter that mints one
repo: "owner/name",
basePath: "/api", // where you mount it: `<base>api`
})
export default { fetch: (request: Request) => board(request) }Mount it at <base>api — /api at a domain root, /board/api under
task export --base /board/ — and the SPA finds it the same way. token may
be a function returning a promise, so an adapter that would rather mint an
installation token from its own GitHub App can, without this package knowing
anything about App auth.
How it's built
- Zero dependencies. Storage is markdown files with a tiny frontmatter
block — reviewable in a PR, mergeable by git, editable by hand. Comments are
one file each (append-only merges cleanly), and the
blocks/blocked-byrelation is stored on one side only, so its two views can't disagree. Nothing to compile, sonpxstarts in about a second. - Realtime is a file watch.
task serveis onenode:httpprocess: the prebuilt UI, a JSON API, a server-sent-events stream that pings whenever anything writes to.task/— this UI, another terminal, an agent mid-run — and the chat's threads and MCP endpoint, whose tools write through the same functions the API does. - The UI is deliberately simple. Five fixed statuses, a kanban board with drag and drop, a table, a detail panel. Built on the mojo design system.
Docs: task.nickmeriano.com
