@legdev/tkxr
v3.1.0
Published
In-repo ticket management system with CLI and web interface
Maintainers
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
/mcpendpoint) 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 serveGlobal install (optional, gives you the tkxr and tkxr-mcp bins on PATH):
pnpm install -g @legdev/tkxrRequires 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 / totalstory 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
IntersectionObserveron a sentinel row inside.listtriggerspagedTickets.fetchNextPage()when it comes within one viewport (rootMargin: 400px) of visibility. Fetching continues page-by-page until the server returnsnextCursor: 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 fixedlimit: 25. A Load more button under each column extends only that column ("Load more (N left)" usestotal - 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 viaAbortController, so a slow first page can't overwrite the results of a newer query. Changing sprint, assignee, type, status or sort chips also triggersresetAndFetchand scrolls back to page 1. - Live updates — the shared
ticketEvents.tsbus fans one WebSocket connection out to every open panel.ticket_created/ticket_updated/ticket_deletedevents callpagedTickets.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/summaryfetch 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 --forceComments
tkxr comments tas-abc12345 # list
tkxr comments tas-abc12345 --add --author alice --content "LGTM" # add
tkxr comments tas-abc12345 --delete com-abc12345 # deleteUsers
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 --unassignSprints (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 sprinttkxr 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 staysDeleting 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-abc12345Options 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/worktreeroutes,/api/sprints/:id/git,/api/sprints/:id/prandcreate_sprint_worktreeare 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, andgit worktree removecleans 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 configsserve 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:
- stdio — the
tkxr-mcpbin. Use this in editor MCP client configs (Claude Desktop, Cursor, Zed, etc.). - HTTP — every
tkxr serveinstance 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 guideAvailable 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-abc12345Status 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 landsFor 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 readyFile 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.jsonExample 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'slistcommand viastorage.getAllTickets()and any external script that hard-codes the pre-2.1 shape). - Any of
limit | cursor | q | sprint | epic | assignee | type | status | sortBypresent — 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_HOSTTKXR_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 3000Claude 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 theclaudeexecutable. Defaultclaude.TKXR_CLAUDE_ARGS— extra flags forwarded toclaude -pafter the built-in ones. Whitespace-split; no shell metacharacters are interpreted.TKXR_CLAUDE_DISABLED— set to1/true/yesto 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 ofdefault | acceptEdits | bypassPermissions. Defaults tobypassPermissionsbecause the runner is headless — there is no human to click "Approve" on tool-use prompts, so any other mode risks stalling the run indefinitely.planis refused (the CLI can't exit plan mode non-interactively); if you setTKXR_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 }runvalidatescwdagainst the repo root + registered worktrees (so a browser client can't escape the workspace), spawnsclaude -p --output-format stream-json --verbosewith the prompt on stdin, and streams stdout frames over the shared WebSocket. Returns503 { error: { code: 'claude_unavailable' } }when the binary is missing (clients should fall back tocopyPrompt). Assigns arunIdif the caller didn't supply one.cancelsendsSIGTERM(thenSIGKILLafter a 2 s grace) to the child identified byrunId.
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 mcpContributing
- Fork.
git checkout -b feature/thing(or let tkxr do it:tkxr worktree create tas-…).- Commit on your branch.
- Open a PR.
License
MIT — see LICENSE.
Changelog
See CHANGELOG.md.
