npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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`.

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 init

init 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/skill

A 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 phone

Claiming — 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-21 creates task/claim/tas-x7k4m on 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 saying todo until 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-By records 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 C builds one marker per ticket and pushes them with --atomic: every lock or none. A bundle with one taken ticket locks nothing and exits 1 naming 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.branchPrefix in .task/config.json moves 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: 0 claimed, 1 already claimed (pick the next ticket), 2 not claimable — not todo on 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 --pr on the delivery branch; the merge lands code and done together, and the branch auto-deletes. Between claim and merge the default branch still says todo — the claim is the truth about in-flight work: task list --claims reads every live claim off origin with its Delivered-By, and git ls-remote origin 'task/claim/*' (or your configured namespace) is the same list raw.
  • The WIP cap counts sessions. claims.maxOpenCount on 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; --force is 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 --claimable shows todo tickets 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 sweep is the janitor. --release deletes 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 --release re-checks with ls-remote and fails loudly instead of reporting a release that didn't happen. The systemic healer is task 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 names claim mints — <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.email set (the marker commit needs an author, and Claimed-By records 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 --release instead of a silent lie, and a task sweep running 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-By and 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 sweep

Any 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-x7k4m

The 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 to chore(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 gh when 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 the prs: frontmatter of every ticket the branch touches — the file stays canonical — and, with gh, 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 --release wearing 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.md files and skills stay in your terminal. Its writes are attributed to claude, like an agent's CLI comments.
  • It is your Claude Code. The package stays dependency-free: the chat spawns whatever claude is 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_DIR to 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/mcp gives 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 /mcp sit 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 again

It 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, or task 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. --public is 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 — and task publish writes 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 unpublish removes 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 --private is 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 serve shows your working tree, so previewing a branch is git 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-x7k4m or just x7k4m — a random 5-character key minted at task 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 whatever task init --prefix says.
  • Ids route by prefix: a bare x7k4m means the nearest board, while TAS-x7k4m reaches the TAS board from anywhere in the monorepo. An unknown or ambiguous prefix fails loudly instead of guessing. task boards lists 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 --all sweeps everything finished; task unarchive <id> puts one back.
  • Statuses: backlog todo in_progress done canceled.
  • A task carries tags, at most one goal, and asks — checklist items a person owes it (task ask), each one file under tickets/<key>/asks/. Open asks derive the amber needs-human badge, feed task 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 gate done: a ticket finishes when nobody owes it anything. --tag a,b matches either tag.
  • task link A --blocked-by B marks 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 (--prs replaces 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 run task 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.name with no setup — override with --author or $TASK_AUTHOR, and check with task 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 serve opens your browser on the board (--no-open doesn't) and takes the next free port when 4400 is busy, so a server you left running elsewhere can't stop you — --strict-port fails on a taken port instead.
  • task publish works out the repo from your git remotes; pass --repo owner/name if the guess is wrong (a fork with two remotes, say).
  • task export writes the board out as static files — the same UI task serve ships, plus a snapshot.json it 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/ for example.com/board/ — so the page knows where its assets and snapshot live. Point the host's SPA fallback at index.html so 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-functions leaves it out.
  • task serve is 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 migrate once 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:

  1. Create the Pages project (Direct Upload) and use its name for --project-name.
  2. Add CLOUDFLARE_API_TOKEN as a repository secret — a token with Pages: Edit on that account, nothing more.
  3. Add CLOUDFLARE_ACCOUNT_ID as 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 _redirects line is belt and braces here, and required elsewhere. task export writes index.html, snapshot.json and assets/ — it does not write an SPA fallback, and the board's routes are real paths (/task/<key>, /goal/<slug>). Pages already serves index.html for unmatched paths when the output has no 404.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-node is 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 export also writes a functions/ 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 (_redirects on 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:

  1. 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.
  2. 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 export says 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 use task 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-by relation is stored on one side only, so its two views can't disagree. Nothing to compile, so npx starts in about a second.
  • Realtime is a file watch. task serve is one node:http process: 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