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

@legdev/tkxr

v3.1.0

Published

In-repo ticket management system with CLI and web interface

Readme

tkxr — In-Repo Ticket Manager

tkxr is a lightweight, file-based ticket manager that lives inside your repo. It ships with:

  • a keyboard-driven sidebar + panel web UI (Svelte + Vite) for humans,
  • a full CLI for scripting and shell workflows,
  • an MCP server (stdio bin + HTTP /mcp endpoint) so AI agents can drive it,
  • optional per-ticket and per-epic git worktrees so multiple agents can work concurrently without stepping on each other.

Tickets and comments are stored as chunked NDJSON, sprints, epics and users as JSON, all under ./tkxr/ in the working directory. Everything is text you can git diff.

A sprint is the workspace — the board is always scoped to exactly one, and you pick or create one before you get a board at all. Epics are the grouping inside that workspace (features, initiatives, themes). See Sprints vs epics.


Installation

You can run tkxr without installing it globally:

pnpm dlx @legdev/tkxr serve    # web + REST + MCP-over-HTTP server
pnpm dlx @legdev/tkxr mcp      # MCP stdio server (for MCP client configs)
pnpm dlx @legdev/tkxr list     # any CLI subcommand

# Or via npx
npx @legdev/tkxr serve

Global install (optional, gives you the tkxr and tkxr-mcp bins on PATH):

pnpm install -g @legdev/tkxr

Requires Node ≥ 18.


Quick start

# 1. Start the server (web UI + REST + MCP over HTTP)
pnpm dlx @legdev/tkxr serve
# → open http://localhost:8080

# 2. Or use the CLI directly
tkxr user create alice "Alice"
tkxr sprint create "Sprint 1" --goal "Ship auth"     # the workspace
tkxr epic create "Auth" --sprint spr-abc123          # a group inside it
tkxr create task "Wire up login form" --sprint spr-abc123 --epic epi-abc123 --priority high
tkxr status tas-abc12345 progress
tkxr comments tas-abc12345 --add --author alice --content "PR up for review"

Data lands in ./tkxr/. Commit it like any other source file.


Data model

| Entity | ID prefix | Storage | Notes | |---------------|-----------|--------------------------------------------|-------| | Ticket (task) | tas- | tkxr/tickets/tickets-XXXX.ndjson | one JSON object per line | | Ticket (bug) | bug- | tkxr/tickets/tickets-XXXX.ndjson | same shape, different type | | Comment | com- | tkxr/comments/comments-XXXX.ndjson | linked by ticketId | | Sprint | spr- | tkxr/sprints.json | one file; the workspace frame | | Epic | epi- | tkxr/epics.json | one file; groups tickets inside a sprint | | User | use- | tkxr/users.json | one file |

Sprints vs epics

A sprint wraps the whole workspace. The board, list, sidebar counts and triage are all scoped to the one active sprint; switching sprints is its own view, not a filter chip. Tickets and epics carry a sprint field, and anything without one lands in the built-in Unsorted workspace (sprint=none) so it stays reachable rather than disappearing behind the gate.

An epic groups tickets within that workspace — the role sprints used to play. Epics have a name, optional description/goal/color, a planning | active | completed status, and a sprint. Tickets carry an epic field; the sidebar lists the current workspace's epics and clicking one filters the board. epic=none matches ungrouped tickets.

Deleting either entity is non-destructive to tickets: deleting a sprint clears sprint on its tickets (they fall back to Unsorted) and detaches its epics; deleting an epic clears epic on its tickets (they stay in their sprint, ungrouped).

Ticket statuses (5-column board)

backlog → progress → review → done
                ↘ blocked ↙

backlog, progress, review, blocked, done — all valid targets for tkxr status <id> <status> and the MCP update_ticket_status tool.

Sprint statuses

planning → active → completed. A sprint owns no branch or worktree, so completing one has no effect on git state.

Epic statuses

planning → active → completed — same triple as sprints, set via tkxr epic status <id> <status>, the Epic panel, or the MCP edit_epic tool. Status is presentational grouping only; it does not gate ticket edits.

Users & per-user color

Every user has an optional color field. The web UI uses it for their avatar and their sidebar row; if unset, a color is picked from a small palette based on the user's index. Set it through the User panel in the web UI or the MCP edit_user / create_user tools.

Dependencies

Tickets can declare inter-ticket blockers via dependsOn: string[]. Read tools (list_tickets, get_ticket) surface both dependsOn and a computed blockedBy (unmet or missing deps), so an orchestrator can topological-sort a sprint from a single call. Set them via the MCP edit_ticket tool (dependsOn, addDependencies, removeDependencies, clearDependencies) or create_ticket (dependsOn).


Web UI

Open http://localhost:8080 after tkxr serve.

A sprint is required to reach the board. With no active workspace you land on the sprint switcher instead: a full view listing every sprint with its ticket counts, plus a create form and — when it holds anything — an Unsorted entry for tickets that have no sprint. Pick one and the board opens scoped to it; the choice persists in localStorage. The sidebar's Switch button returns to that view.

Layout (once a workspace is active):

  • Left sidebar — workspace header (active sprint + Switch), epics, users, view switcher, theme toggle, command palette, AI Triage. Drag a ticket onto an epic or user row to reassign.
  • Toolbar — context title, search box, type filter, sort selector, "New ticket" button.
  • Main view — either the 5-column Board or the List view, scoped to the active sprint. Board columns match the 5 statuses; drag between columns to move a ticket. Each column has an inline quick-add. Cards and list rows show an epic chip.
  • Sprint strip — sits above the view for the active workspace, showing done / total story points.
  • Epic panel — slide-in CRUD for an epic (name, description, goal, color, status, sprint), reached from the sidebar epic row.

Search + infinite scroll

The tickets view is server-paged so a large repo doesn't ship its entire ticket store to the browser on every load.

  • List view — fetches the first page (default 50 rows) on mount, then an IntersectionObserver on a sentinel row inside .list triggers pagedTickets.fetchNextPage() when it comes within one viewport (rootMargin: 400px) of visibility. Fetching continues page-by-page until the server returns nextCursor: null. In-flight page loads are guarded so rapid scroll doesn't queue parallel requests, and the item list de-dupes by id so a WS-created row that also appears in a later page won't render twice.
  • Board view — each of the five status columns owns its own createPagedTicketStore() with a fixed limit: 25. A Load more button under each column extends only that column ("Load more (N left)" uses total - loaded). The column badge shows the full server-side total, so the count stays honest even when only a slice is loaded.
  • Toolbar search debounces at ~200ms and calls resetAndFetch({ q, ... }) on the active store(s). Every keystroke aborts the previous fetch via AbortController, so a slow first page can't overwrite the results of a newer query. Changing sprint, assignee, type, status or sort chips also triggers resetAndFetch and scrolls back to page 1.
  • Live updates — the shared ticketEvents.ts bus fans one WebSocket connection out to every open panel. ticket_created / ticket_updated / ticket_deleted events call pagedTickets.applyEvent(...) on the active store — new rows are inserted in the correct sort position on page 1 (or ignored past the cursor to avoid double-counting on the next fetch); updates mutate in place; deletes drop the row from every loaded page. The Sidebar's /api/tickets/summary fetch coalesces bursts of events into a single request 500ms after the last one.

The CLI reads storage directly, so tkxr list is untouched by the paging change. External HTTP consumers that hit GET /api/tickets without any paging query parameters continue to receive the full Ticket[] array (see the REST API section for the paged contract).

  • Workspace panel — a slide-in panel on the right for the currently selected ticket, sprint, epic, user, or the AI Triage report. Never modal; you can keep the board visible behind it.
  • Command palette — Cmd/Ctrl-K, full-text ticket search, quick actions, natural-language ticket draft ("critical bug: login crash for @alice").

Keyboard shortcuts

| Key | Action | |----------------|-------------------------------------------| | Cmd/Ctrl + K | Toggle command palette | | / | Focus the toolbar search box | | C | New ticket (opens the ticket panel) | | B | Switch to Board view | | L | Switch to List view | | Esc | Close the workspace panel / palette | | Enter | Commit a quick-add (in board columns) or send a comment (in ticket panel) |

Shortcuts are ignored while you are typing in an input.

The UI persists filters (view, active sprint, active user, type filter, sort, search) to localStorage under tkxr-ui.

Live updates

Every mutation — from the UI, the CLI, or the MCP server — broadcasts a WebSocket event that the UI listens to and refetches on. You do not need to refresh.


CLI

All commands accept --help. The most common are listed below.

Tickets

tkxr create task "Wire up login" \
  --description "OAuth first, password fallback later" \
  --priority high --estimate 3 \
  --sprint spr-abc12345 --epic epi-abc12345 --assignee alice

tkxr new bug "Dashboard crashes on empty state"       # alias for `create bug`

tkxr list                                              # all tickets
tkxr list tasks                                        # only tasks
tkxr list --status progress --sort-by priority
tkxr list --search "login" --verbose                   # -v shows assignee + sprint + epic names
tkxr list --sprint spr-abc12345                        # or --sprint none
tkxr list --epic epi-abc12345                          # or --epic none (ungrouped)

tkxr show tas-abc12345                                 # polymorphic: also accepts spr-, epi- and use- ids
tkxr status tas-abc12345 review                        # backlog|progress|review|blocked|done
tkxr edit tas-abc12345 --priority critical --add-label backend
tkxr edit tas-abc12345 --epic epi-abc12345             # or --clear-epic
tkxr delete tas-abc12345 --force

Comments

tkxr comments tas-abc12345                                            # list
tkxr comments tas-abc12345 --add --author alice --content "LGTM"      # add
tkxr comments tas-abc12345 --delete com-abc12345                      # delete

Users

tkxr users
tkxr user create alice "Alice Smith" --email [email protected]
tkxr user edit alice --display-name "Alice S." --email [email protected]
tkxr user assign tas-abc12345 alice
tkxr user assign tas-abc12345 --unassign

Sprints (workspaces)

tkxr sprints                                          # grouped: in flight, planning, completed
tkxr sprint create "Sprint 1" --goal "Ship auth"
tkxr sprint status spr-abc12345 active                # planning|active|completed
tkxr sprint complete spr-abc12345                     # completed + roll up its epics
tkxr sprint edit spr-abc12345 --name "Auth Sprint" --end-date 2026-08-01
tkxr sprint set tas-abc12345 spr-abc12345             # move ticket into workspace
tkxr sprint set tas-abc12345 --unset                  # back to Unsorted

tkxr sprint migrate spr-abc12345 spr-def45678         # move every ticket across
tkxr sprint migrate spr-abc12345 spr-def45678 --status backlog,progress,review,blocked
                                                      # roll unfinished work over
tkxr sprint migrate spr-abc12345 none --dry-run       # preview; "none" = Unsorted

tkxr delete spr-abc12345 --force                      # tickets + epics → Unsorted
tkxr delete spr-abc12345 --cascade --force            # delete them with the sprint

tkxr sprints groups by lifecycle so finished workspaces sink to the bottom, and prints each sprint's done/total ticket count. A sprint whose tickets are all done but is still open is flagged as ready to complete — the same condition that raises the web UI's "Complete sprint?" prompt.

Completing a sprint also marks every epic under it completed, on every path (sprint complete, sprint status … completed, the REST routes, and the MCP update_sprint_status tool). Epic status is a manual label that lags its tickets, so without the rollup a closed workspace keeps reporting active epics. Tickets are left alone: carrying open tickets out of a completed sprint is normal, and the CLI just reports which ones they are.

Bulk moves and cascade delete

sprint migrate is the bulk form of sprint set — the end-of-sprint move where everything unfinished rolls into the next workspace. --status narrows the selection (repeatable, comma-separated), --tickets takes an explicit id list, and --dry-run prints the selection without writing. Either sprint id may be none for the Unsorted bucket.

An epic belongs to exactly one workspace, so a ticket that moves while its epic stays behind is dropped from that epic rather than carrying a chip the target board's epic filter won't list. The command reports those ids; re-file them with epic set, or move the epic itself:

tkxr epic migrate epi-abc12345 spr-def45678           # epic + its tickets
tkxr epic migrate epi-abc12345 spr-def45678 --status backlog --keep-epic
                                                      # split some out, epic stays

Deleting a sprint is non-destructive by default: its tickets and epics survive in Unsorted. --cascade instead deletes every epic under the sprint, every ticket in it, and those tickets' comments. It is the only irreversible operation in tkxr — nothing is archived — so both the CLI and the web dialog print the exact counts first and require an explicit confirmation. Worktrees are never touched, and once the records are gone nothing remembers their paths, so remove those first if you want tkxr to clean them up.

Epics (grouping within a workspace)

tkxr epics                                            # name, status, sprint, done/total
tkxr epics --sprint spr-abc12345                      # or --sprint none
tkxr epics --status active

tkxr epic create "Auth" --sprint spr-abc12345 --goal "Ship SSO" --color "#7c3aed"
tkxr epic status epi-abc12345 completed               # planning|active|completed
tkxr epic edit epi-abc12345 --name "Auth & SSO" --sprint spr-def45678
tkxr epic edit epi-abc12345 --clear-sprint
tkxr epic set tas-abc12345 epi-abc12345               # attach ticket to epic
tkxr epic set tas-abc12345 --unset                    # ungroup
tkxr delete epi-abc12345 --force                      # ungroups its tickets, keeps them

tkxr create epic "Auth" --sprint spr-abc12345 --goal "Ship SSO" --color "#7c3aed"
                                                      # alias for `epic create`
tkxr list epics --sprint none --status active         # same filters as `tkxr epics`

--sprint and --epic are validated on every path that accepts them (create, edit, epic create, epic edit). A dangling reference would silently hide the entity — the board is scoped to one sprint, and a ticket pointing at an unknown epic shows under neither that epic nor No epic — so unknown ids exit 1.

epic set warns when the ticket's sprint differs from the epic's — the board is sprint-scoped, so such a ticket would not appear under the epic you filed it in.

Worktrees

Per-ticket and per-epic git worktrees let multiple agents work in parallel on isolated branches. Sprints own no branch.

tkxr worktree create tas-abc12345
# → creates ../<repo>-worktrees/tas-abc12345 on branch tkxr/tas-abc12345,
#   based on the epic branch if the ticket's epic has a worktree, else HEAD.

tkxr worktree create epi-abc12345
# → creates ../<repo>-worktrees/epics/epi-abc12345 on branch tkxr/epic/epi-abc12345,
#   based on HEAD. This is the feature branch its tickets merge into.


tkxr worktree list
tkxr worktree remove tas-abc12345                     # deletes the dir + merged branch
tkxr worktree remove tas-abc12345 --keep-branch       # keep the branch around
tkxr worktree remove epi-abc12345

Options for create: --path <dir>, --branch <name>, --base <ref>. Options for remove: --force, --keep-branch. Override the worktree parent directory with the TKXR_WORKTREE_ROOT env var.

Branch hierarchy. A sprint is a frame for concurrent work and owns no branch. An epic is a feature and owns one. A ticket is a unit of work inside a feature.

ticket branch   tkxr/<ticket-id>       →  epic branch
epic branch     tkxr/epic/<epic-id>    →  main
sprint          (no branch)

So a ticket branch bases on its epic branch when the epic has a worktree, and on main otherwise — a ticket with no epic is a standalone change. PRs follow the same shape: ticket into its epic branch, epic into the repo default.

Anything that affects a feature gets grouped into an epic, and the work lands on that feature branch. If work doesn't seem to fit an epic, the epic is usually missing rather than the sprint needing a branch. See docs/branching-model.md for the reasoning and the tradeoff this accepts.

Sprints have no worktree and no branch. tkxr worktree create <spr-*>, the /api/sprints/:id/worktree routes, /api/sprints/:id/git, /api/sprints/:id/pr and create_sprint_worktree are all gone, along with the sprint-level orchestrate / commit / plan actions. The only sprint-level activity is triage: sorting a sprint's tickets into epics. Worktrees created under the old model still exist on disk — tkxr show <spr-*> prints the path so you can find one, and git worktree remove cleans it up.

Deleting an epic never removes its worktree — remove it explicitly first if you want tkxr to clean it up, since the record is the only thing that remembers the path.

Servers

tkxr serve                                # web + REST + WebSocket + MCP-over-HTTP
tkxr serve --port 3000 --host 0.0.0.0
tkxr mcp                                  # MCP stdio server for MCP client configs

serve respects TKXR_PORT / PORT and TKXR_HOST env vars as fallbacks after the flags.

Version

tkxr version                              # print current version
tkxr version --bump patch                 # patch | minor | major (updates root + web package.json)

MCP (AI integration)

tkxr exposes the same functionality over the Model Context Protocol. There are two ways to connect:

  1. stdio — the tkxr-mcp bin. Use this in editor MCP client configs (Claude Desktop, Cursor, Zed, etc.).
  2. HTTP — every tkxr serve instance also serves MCP JSON-RPC at /mcp. Useful for agents that already talk HTTP or for remote setups.

Both transports expose the same tools and broadcast the same WebSocket events, so a running web UI reflects agent mutations live.

Client config (stdio)

Global install:

{
  "mcpServers": {
    "tkxr": {
      "command": "tkxr-mcp",
      "args": []
    }
  }
}

No global install (via pnpm dlx):

{
  "mcpServers": {
    "tkxr": {
      "command": "pnpm",
      "args": ["dlx", "@legdev/tkxr", "mcp"]
    }
  }
}

Or via npx:

{
  "mcpServers": {
    "tkxr": {
      "command": "npx",
      "args": ["-y", "@legdev/tkxr", "mcp"]
    }
  }
}

Client config (HTTP)

Run tkxr serve (default http://localhost:8080), then point your client at http://localhost:8080/mcp. The endpoint speaks the MCP Streamable HTTP transport (JSON-RPC over POST/GET/DELETE). Session state is keyed by the mcp-session-id header.

You can also grab the tool list and the agent guide as plain REST:

curl http://localhost:8080/api/mcp/tools     # JSON tool list
curl http://localhost:8080/api/mcp/guide     # markdown agent guide

Available MCP tools

Read: agent_guide, list_tickets, get_ticket, search_tickets, list_users, get_user, list_sprints, get_sprint, list_epics, get_epic, list_comments, list_worktrees.

Ticket mutations: create_ticket, edit_ticket, update_ticket_status, assign_ticket, set_ticket_sprint, set_ticket_epic, delete_ticket, migrate_tickets.

Comment mutations: add_comment, delete_comment.

Sprint mutations: create_sprint, edit_sprint, update_sprint_status, delete_sprint.

migrate_tickets is the bulk move — a whole sprint's tickets, a whole epic's, a status subset, or an explicit id list — and takes dryRun so an agent can show the selection before committing to it. delete_sprint takes cascade: true to delete the sprint's epics, tickets and comments instead of dropping them into Unsorted; it is the only irreversible tool in the server, so confirm before calling it.

Epic mutations: create_epic, edit_epic, delete_epic.

User mutations: create_user, edit_user, delete_user.

Worktrees: create_worktree, create_epic_worktree, remove_worktree, remove_epic_worktree.

Call agent_guide first if you're not sure — it returns a short markdown briefing on the data model, typical flow, dependency rules, and worktree conventions.


Suggested worktree flow

For a single ticket:

tkxr worktree create tas-abc12345
cd ../<repo>-worktrees/tas-abc12345
tkxr status tas-abc12345 progress
# ... work, commit on the tkxr/tas-abc12345 branch ...
tkxr status tas-abc12345 review
tkxr comments tas-abc12345 --add --author alice --content "Ready for review"

When the ticket is merged (via your normal PR flow):

tkxr worktree remove tas-abc12345

Status changes never touch worktrees. Moving a ticket to done (or a sprint to completed) only changes the status — it will not delete a directory or a branch. done fires from the CLI, MCP, the REST API and a plain board drag, so anything destructive hanging off it would be triggered by dragging a card. Removing a worktree is always something you ask for. tkxr status <id> done does print a reminder that the worktree is still open, and whether its branch has been merged.

Branch deletion is never destructive either: worktree remove deletes the branch only when it is fully merged, and otherwise keeps it and tells you. Use git branch -D <branch> if you really want to discard unmerged work.

For an epic (the usual fan-out — an epic is one feature branch):

tkxr worktree create epi-abc12345
# Each ticket in the epic gets its own worktree, branched off the epic branch:
tkxr worktree create tas-111
tkxr worktree create tas-222
# ... agents work concurrently, each PRing into tkxr/epic/epi-abc12345 ...
tkxr epic status epi-abc12345 completed     # status only — worktrees stay put
tkxr worktree remove epi-abc12345           # clean up once the epic branch lands

For a sprint (workspace-wide integration, e.g. the orchestrator flow):

tkxr worktree create spr-abc12345
# Tickets with no epic worktree branch off the sprint branch instead:
tkxr worktree create tas-333
# ... agents work concurrently ...
tkxr sprint complete spr-abc12345           # status + epic rollup — worktrees stay put
tkxr worktree remove spr-abc12345           # clean up when you're ready

File layout

Inside your repo, tkxr writes:

tkxr/
├── tickets/
│   ├── tickets-0001.ndjson
│   └── tickets-0002.ndjson
├── comments/
│   ├── comments-0001.ndjson
│   └── comments-0002.ndjson
├── sprints.json
├── epics.json
└── users.json

Example NDJSON ticket line:

{"id":"tas-abc12345","type":"task","title":"Wire up login","status":"progress","assignee":"use-alice001","sprint":"spr-abc12345","epic":"epi-abc12345","estimate":3,"priority":"high","dependsOn":[],"worktree":{"path":"...","branch":"tkxr/tas-abc12345","createdAt":"..."},"createdAt":"...","updatedAt":"..."}

Example epic (tkxr/epics.json holds an array of these):

{
  "id": "epi-abc12345",
  "name": "Auth",
  "goal": "Ship SSO",
  "status": "active",
  "color": "#7c3aed",
  "sprint": "spr-abc12345",
  "createdAt": "...",
  "updatedAt": "..."
}

Example user:

{
  "id": "use-alice001",
  "username": "alice",
  "displayName": "Alice Smith",
  "email": "[email protected]",
  "color": "#e0864a",
  "createdAt": "...",
  "updatedAt": "..."
}

REST API

tkxr serve exposes REST alongside the web UI, MCP, and WebSocket. Endpoints:

# Tickets
GET    /api/tickets                      (see "Paged tickets" below)
GET    /api/tickets/summary              (aggregate counts for sidebar/board badges)
GET    /api/tickets/:type                (task|bug)
POST   /api/tickets
PUT    /api/tickets/:id
PUT    /api/tickets/:id/status
DELETE /api/tickets/:id
POST   /api/tickets/bulk/move            (bulk-move a selection into another sprint)

# Comments
GET    /api/tickets/:ticketId/comments
POST   /api/tickets/:ticketId/comments
DELETE /api/comments/:id

# Users
GET    /api/users
POST   /api/users
PUT    /api/users/:id
DELETE /api/users/:id

# Sprints
GET    /api/sprints
POST   /api/sprints
PUT    /api/sprints/:id
PUT    /api/sprints/:id/status
POST   /api/sprints/:id/complete         (status → completed + roll up its epics)
GET    /api/sprints/:id/contents         (ticket/epic/comment counts a cascade would delete)
DELETE /api/sprints/:id                  (?cascade=true also deletes its epics, tickets, comments)

# Epics
GET    /api/epics                        (?sprint=<id>, or none, to scope to one workspace)
POST   /api/epics
PUT    /api/epics/:id
DELETE /api/epics/:id

# Worktrees
GET    /api/worktrees
POST   /api/tickets/:id/worktree
DELETE /api/tickets/:id/worktree
POST   /api/epics/:id/worktree
DELETE /api/epics/:id/worktree

# Branch insights + PR flow (per ticket / epic)
GET    /api/tickets/:id/git
POST   /api/tickets/:id/pr
GET    /api/epics/:id/git
POST   /api/epics/:id/pr

# MCP over HTTP
POST   /mcp
GET    /mcp
DELETE /mcp
GET    /api/mcp/tools                    (plain tool list)
GET    /api/mcp/guide                    (markdown agent guide)

# AI stubs (return scaffolded responses until wired to a model)
# Only /api/ai/create has an in-app caller. `triage` and `plan` are external /
# agent surfaces: the web app computes triage findings client-side and hands
# planning to the Claude runner, so changing them moves nothing in the UI.
POST   /api/ai/ask
POST   /api/ai/create
POST   /api/ai/triage
POST   /api/ai/plan                      (drafts an epic from a workspace's ungrouped backlog;
                                          body { sprint?, capacity?, commit? })

# Claude CLI runner (streams over WebSocket, see Configuration section)
POST   /api/claude/run                   ({ prompt, cwd?, runId?, label? })
POST   /api/claude/cancel                ({ runId })

# Server metadata
GET    /api/config                       ({ host, port, url, version, claude })

Paged tickets

GET /api/tickets has two response shapes, selected by whether any paging query parameter is present:

  • No paging params — returns the legacy Ticket[] array (used by the CLI's list command via storage.getAllTickets() and any external script that hard-codes the pre-2.1 shape).
  • Any of limit | cursor | q | sprint | epic | assignee | type | status | sortBy present — returns { items: Ticket[], nextCursor: string | null, total: number }.

| Param | Type | Notes | |------------|------------------------------------------------------|-----------------------------------------------------------------------| | limit | positive number (default 50, hard cap 200) | Rejects 0 / negative / non-numeric with 400 bad_input. | | cursor | opaque base64url string (from a previous nextCursor)| Passing an unknown/expired cursor is treated as "start from the top". | | q | string | Case-insensitive substring match over title + description. | | sprint | sprint id or the literal none | none matches tickets with no sprint (the Unsorted workspace). | | epic | epic id or the literal none | none matches tickets with no epic (ungrouped). | | assignee | user id or the literal none | none matches unassigned tickets. | | type | task | bug | Anything else → 400 bad_input. | | status | backlog | progress | review | blocked | done | Anything else → 400 bad_input. | | sortBy | updated (default) | created | priority | title | Priority sort uses critical > high > medium > low; ties broken by id. |

nextCursor is null on the last page. The cursor is fully opaque (base64url of sortValue|id) — do not parse it in a client. Bumping the sort field or the filter chips invalidates any held cursor; call resetAndFetch(...) on the store (or issue a fresh request without a cursor) whenever the query changes.

curl "http://localhost:8080/api/tickets?limit=25&status=backlog&sortBy=priority"
# → { "items": [...25 tickets...], "nextCursor": "MjAyNi0wNy0xNlQwNTo1NjoxNS4wNzdafHRhcy1hYmMxMjM0NQ", "total": 137 }

Bulk ticket moves

POST /api/tickets/bulk/move moves a selection of tickets into another sprint in one pass over the ticket store, instead of PUT /api/tickets/:id per row.

{
  "toSprint": "spr-def45678",        // required; null or "none" = the Unsorted bucket
  "fromSprint": "spr-abc12345",      // source workspace, or "none"
  "fromEpic": "epi-abc12345",        // source epic, or "none"
  "statuses": ["backlog", "progress"], // omit for every status
  "ticketIds": ["tas-abc12345"],     // explicit allowlist, intersected with the above
  "moveEpic": true,                  // epic scope only; default true
  "dryRun": false                    // report the selection without writing
}

At least one of fromSprint / fromEpic / ticketIds is required — a selection-less call is a 400, not a project-wide move. An unknown toSprint is also a 400.

The response is { movedTicketIds, moved, matched, alreadyThere, ungroupedTicketIds, toSprint, epic? }. matched counts everything the selection hit including no-ops, alreadyThere those that were already in the target, and ungroupedTicketIds those dropped from an epic that stayed behind. Each moved ticket is broadcast as ticket_updated (and the epic as epic_updated when it was re-parented); a dryRun broadcasts nothing.

When the selection is a whole epic and nothing narrowed it further, the epic record moves too — a partial move is a split, so the epic stays with the tickets left behind.

Ticket summary

GET /api/tickets/summary returns a cheap single-pass aggregate over getAllTickets(). The sidebar polls it on mount and on every ticket_* WS event (coalesced 500ms), so badges + the triage pill stay honest even when the paged list only holds a slice of tickets.

?sprint=<id> (or the literal none) scopes counts, triage, byStatus, byEpic and byAssignee to that workspace, matching the sprint-scoped board. bySprint is deliberately always project-wide — the sprint switcher needs cross-workspace totals.

{
  "counts": {
    "backlog": 42,
    "progress": 7,
    "review": 3,
    "blocked": 1,
    "done": 89,
    "total": 142
  },
  "triage": {
    "unassignedOpen": 6,
    "criticalOpen": 2,
    "backlogCount": 42
  },
  "byStatus": {
    "backlog": 42,
    "progress": 7,
    "review": 3,
    "blocked": 1,
    "done": 89
  },
  "byEpic":     { "none": 12, "epi-abc12345": 130 },
  "byAssignee": { "none": 6,  "use-alice001": 136 },
  "bySprint":   { "none": 4,  "spr-abc12345": 142 },
  "sprintProgress": {
    "none":         { "total": 4,   "done": 0,  "open": 4 },
    "spr-abc12345": { "total": 142, "done": 89, "open": 53 }
  }
}

sprintProgress is the done/open split behind bySprint, also always project-wide. The sprint switcher draws each card's progress bar from it, and open === 0 && total > 0 is what marks a sprint ready to complete.

The endpoint reloads from disk each call, so it always agrees with whatever /api/tickets last read. It broadcasts nothing on the WS bus — mutations that need summary refreshes are announced via their own ticket_created / ticket_updated / ticket_deleted events and the sidebar refetches from there.

WebSocket

const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (ev) => {
  const { type, data } = JSON.parse(ev.data);
  // type ∈ ticket_created | ticket_updated | ticket_deleted
  //      | comment_created | comment_deleted
  //      | sprint_created | sprint_updated | sprint_deleted
  //      | epic_created | epic_updated | epic_deleted
  //      | user_created | user_updated | user_deleted
  //      | claude_run_started | claude_run_chunk | claude_run_exit
};

claude_run_* events stream stdout/stderr from POST /api/claude/run and are keyed by runId. See the Claude CLI integration section under Configuration for full payload shapes.


Configuration

tkxr serve dynamically writes .tkxr-server in its cwd with the host, port, and URL for the running web UI (default: http://localhost:8080) as JSON, so the notifier client, the Vite dev proxy, the CLI, MCP tools, and any other tooling can discover where the running server lives:

{
  "host": "localhost",
  "port": 8080,
  "url": "http://localhost:8080"
}

The file is cleaned up on SIGINT shutdown. CLI/MCP commands also honor TKXR_HOST / TKXR_PORT (or TKXR_SERVER_URL) as env fallbacks when no .tkxr-server file is present.

Override any of these with flags or env vars:

  • --port <n> / TKXR_PORT / PORT
  • --host <h> / TKXR_HOST
  • TKXR_SERVER_URL — full override for CLI/MCP when discovering a running server.
  • TKXR_WORKTREE_ROOT — override the parent directory used for created worktrees.
pnpm dlx @legdev/tkxr serve --port 3000

Claude CLI integration

tkxr serve probes for a working claude CLI once at boot (via where on Windows, which on macOS/Linux) and reports the result at GET /api/config under claude: { available, bin, version, disabled }. The web UI reads that store to decide between "Run in Claude" and the existing "Copy prompt" fallback — no config needed for the copy-paste path to keep working.

Env vars honored by the discovery + spawn layer (see docs/claude-cli-integration.md for the full design):

  • TKXR_CLAUDE_BIN — absolute path or bare command name for the claude executable. Default claude.
  • TKXR_CLAUDE_ARGS — extra flags forwarded to claude -p after the built-in ones. Whitespace-split; no shell metacharacters are interpreted.
  • TKXR_CLAUDE_DISABLED — set to 1 / true / yes to force the clipboard fallback even when the binary is present.
  • TKXR_CLAUDE_FALLBACK_MODEL — forwarded as --fallback-model <value> when set.
  • TKXR_CLAUDE_MAX_BUDGET_USD — forwarded as --max-budget-usd <value> when set.
  • TKXR_CLAUDE_PERMISSION_MODE — forwarded as --permission-mode <value>. One of default | acceptEdits | bypassPermissions. Defaults to bypassPermissions because the runner is headless — there is no human to click "Approve" on tool-use prompts, so any other mode risks stalling the run indefinitely. plan is refused (the CLI can't exit plan mode non-interactively); if you set TKXR_CLAUDE_ARGS="--permission-mode plan" the server strips it, logs a warning, and uses the configured mode.

REST endpoints

POST /api/claude/run     body: { prompt, cwd?, runId?, label? }
POST /api/claude/cancel  body: { runId }
  • run validates cwd against the repo root + registered worktrees (so a browser client can't escape the workspace), spawns claude -p --output-format stream-json --verbose with the prompt on stdin, and streams stdout frames over the shared WebSocket. Returns 503 { error: { code: 'claude_unavailable' } } when the binary is missing (clients should fall back to copyPrompt). Assigns a runId if the caller didn't supply one.
  • cancel sends SIGTERM (then SIGKILL after a 2 s grace) to the child identified by runId.

GET /api/config now includes the Claude block:

{
  "host": "localhost",
  "port": 8080,
  "url": "http://localhost:8080",
  "version": "2.0.2",
  "claude": { "available": true, "bin": "claude", "version": "1.2.3", "permissionMode": "bypassPermissions" }
}

disabled: true is added when TKXR_CLAUDE_DISABLED is set. permissionMode reflects TKXR_CLAUDE_PERMISSION_MODE (default bypassPermissions).

WebSocket events

In addition to the existing ticket_* / sprint_* / epic_* / user_* / comment_* broadcasts, a live claude run emits three event types, all keyed by runId:

claude_run_started  { runId, cwd, label, startedAt }
claude_run_chunk    { runId, stream: 'stdout' | 'stderr', frame }
claude_run_exit     { runId, ok, exitCode, signal, durationMs, costUsd?, isError? }

Late-joining subscribers can identify a run by its runId and replay any buffered frames the server still holds.

Web UI behavior

The action buttons in TicketPanel, EpicPanel, and TriagePanel swap their label based on $claudeConfig.available:

  • Available — button reads "Run in Claude" (or "Plan with Claude" for planning actions) and streams live output into the workspace panel via ClaudeRunPanel.svelte.
  • Unavailable / disabled — button reads "Copy prompt" (or "Copy plan prompt" / "Copy triage prompt") and drops the prompt on the clipboard, preserving the pre-integration flow.

Sprints get no agent actions beyond triage. Planning, orchestration and committing are epic-level — a sprint has no goal worth decomposing and no bounded ticket set to fan out over. See docs/branching-model.md.

Epics get two actions on EpicPanel:

  • "Plan epic with Claude" (epicBreakdownPrompt) — the epic-level twin of the sprint planner. Enabled once the epic has a goal.
  • "Review epic code with Claude" (epicReviewPrompt) — reviews the epic branch and every ticket branch under it in one pass, so problems that only exist between tickets surface: the same helper written twice, one ticket invalidating another's assumption, a convention applied in three places out of four. It runs in the epic worktree, diffs against whatever the epic branch forked from (sprint branch, else the repo default), and posts findings as ticket comments plus one ranked summary. Read-only by construction — the prompt forbids edits, commits and status changes. Enabled once the epic or one of its tickets has a branch.

Agent isolation

Research ticket tas-Ap8VMPuL evaluated alternatives to git worktrees (filesystem snapshots, containers, in-process sandboxes) for concurrent agent isolation. Outcome: kept git worktrees — they remain the default and only production isolation strategy because they are the only option that is cross-platform (Windows-first), zero-setup, and gives full git-state isolation per agent. See docs/agent-isolation-alternatives.md for the full comparison; the design spec for the CLI integration itself is in docs/claude-cli-integration.md.


Development

git clone https://github.com/<your-fork>/tkxr
cd tkxr
pnpm install

# CLI + MCP (TypeScript)
pnpm run build:cli       # compiles src/ → dist/
pnpm run dev             # tsc --watch

# Web UI (Svelte + Vite, workspace package `tkxr-web`)
pnpm run dev:web         # Vite dev server
pnpm run build:web       # production build

# Everything
pnpm run build           # CLI + web + copy package.json into dist/
pnpm run typecheck

# Run locally against your built dist
pnpm run serve           # tkxr serve
pnpm run mcp             # tkxr mcp

Contributing

  1. Fork.
  2. git checkout -b feature/thing (or let tkxr do it: tkxr worktree create tas-…).
  3. Commit on your branch.
  4. Open a PR.

License

MIT — see LICENSE.

Changelog

See CHANGELOG.md.