An agent-native ticket tracker that enables autonomous, asynchronous, inter-harness collaboration.
A local, no-auth issue tracker for driving agentic development. Issues with
status, priority, and labels plus a dependency graph and an HTTP agent
API. Multiple projects can be tracked side by side in one instance, each
with its own identifier prefix and per-project number sequence (LIN-42,
OEMR-7).
The highest-value capability is the advisor relationship: a cheap, fast implementing agent works a ticket, and when it hits a decision it can't resolve alone, it asks a question through the tracker. A smarter, more expensive advisor agent reads the question with full issue context and answers it. The implementer proceeds. You get expensive-model intelligence in your pipeline at the decision points — not on every line of code.
Because the collaboration happens over HTTP, the two agents don't need to share a harness. Pair them however you like:
- GLM 5.2 in ZCode implements via
/work-with-help; Claude Opus in Claude Code advises via/advise. Neither harness knows the other exists — they communicate only through the tracker. - An OpenRouter-routed model in one harness implements; a subscription-plan Claude Code session in another answers questions — programmatic collaboration across harnesses without an API key in the loop.
The real requirement isn't different harnesses — it's separate processes with no shared state. Two sessions of the same harness, two terminals, two machines: all structurally identical to the inter-harness case. The advisor pattern earns its round-trip whenever the two agents would otherwise have no channel between them. If they already share a session (a native sub-agent, a shared context window), use that instead — OrbitTrack is pure overhead there.
Underneath the advisor layer sits the frontier — the set of issues an agent
can pick up next because they're todo and unblocked — and the dependency
graph that sequences work. Together they make the advisor pattern scale: an
implementer works a chain of tickets in blocking order, asking only when it
needs to, while the advisor answers asynchronously across whatever harness it
runs in.
Built with Next.js (App Router), SQLite (via Drizzle ORM + better-sqlite3), and Tailwind. The whole tracker is one SQLite file.
For when you need Claude Code to communicate with an agent that lives in a different harness — and you must use your Anthropic subscription.
OrbitTrack started from a concrete constraint: ZCode offers higher usage allowances for GLM 5.2, but Claude Code is the only place to use an Anthropic subscription. Both harnesses had value — but the two agents couldn't talk to each other. Collaboration meant manually copying questions from one window and pasting answers back to the other, acting as a human relay between agents that should have been able to communicate directly.
OrbitTrack is that communication channel. Agents collaborate through a shared tracker API instead of through the human.
npm install
npm run devOpen http://localhost:3000. The database is created at data/tracker.db on
first run, seeded with four default labels (ready-for-agent, bug,
feature, chore), and bootstrapped with a default project whose key comes
from TRACKER_PREFIX. A pre-existing single-project database is migrated in
place on next start: its issues are backfilled into a default project keyed
off their existing identifier prefix (uppercased; TRACKER_PREFIX is only
used when the legacy DB holds no issues), and if it holds data, a timestamped
snapshot file is written next to the DB first.
- Backup: copy
data/tracker.db. - Reset: delete
data/tracker.db(it's recreated on next start).
| Var | Default | Purpose |
|---|---|---|
TRACKER_DB_PATH |
data/tracker.db |
Location of the SQLite file. |
TRACKER_PREFIX |
LIN |
Key of the default project bootstrapped on first run (stored uppercased; a project's key is its identifier prefix). Ignored when migrating a legacy DB that already has issues — their prefix wins (see above). Further projects are created via the API with their own keys. |
TRACKER_SEED |
true |
Set to false to skip seeding default labels. |
An issue is on the frontier when it is todo and every issue that
blocks it is done.
todo= ready to be picked up, not started.in_progress= being worked (set viaclaim).done= complete; satisfies a blocking edge.backlog= captured but not committed to; never on the frontier.canceled= abandoned; does not satisfy a blocking edge — the dependent must be re-pointed or the edge removed.
Agents fetch the frontier, pick one, then claim it:
# What can I work on right now?
curl localhost:3000/api/issues/frontier
# Grab one atomically (todo → in_progress).
curl -X POST localhost:3000/api/issues/LIN-42/claimThe frontier is ordered by priority desc, then created asc (a long-waiting ticket wins ties over a fresh one).
All routes are JSON in, JSON out. :id accepts either the numeric id or the
identifier string (e.g. LIN-42). Errors use a stable envelope:
{ "error": { "message": "string", "code": "string | null" } }The tracker is multi-project: every /api/issues/* route takes an optional
?project=KEY query param selecting the project scope (the default project
when omitted), and /api/projects lists and creates projects. Identifier and
numeric lookups are scoped to the active project — an identifier from another
project is a 404, never a leak.
The full route-by-route reference (paths, bodies, status codes, project scoping) lives in AGENTS.md, the agent-contract document loaded into every agent session. It is the single source of truth for the API surface; the tables are not duplicated here.
A Question is a clarification an implementing agent posts against an
in_progress issue, answered by a separate orchestrating model. It lets an
agent loop stay autonomous instead of stalling on a harness prompt.
- Ask:
POST /api/issues/:id/questions { question }. Only whilein_progress(claim runs first, so plan-time questions are covered). Numbers are a per-issue sequence (Q1,Q2, …). - Answer:
POST /api/issues/:id/questions/:number/respond { answer }. Answering is a single irreversible event — a second respond is409, not an overwrite. A correction is a new question (the next number). - State is derived:
answeredAt == null⇒open; otherwiseanswered. The full Q&A history is embedded inIssue.questions, so an implementing agent checks for an answer with oneGET /api/issues/:idand an answering model reads prior exchanges as context. - Polling:
GET /api/questions?status=open&label=authreturns every open question across the issues in a track (issues sharing a label), each with its fullIssueembedded. The orchestrator's wait strategy is polling; the tracker provides the data, the agent decides when to look.
Labels are global across projects — one triage vocabulary for the whole
tracker. CRUD lives at /api/labels (see AGENTS.md).
The only mutator that performs a conditional status transition. An agent calls
GET /api/issues/frontier, picks one, then POST /api/issues/:id/claim. If two
agents race, both get 200 (single-user local app), but 409 is reserved for
the case where the issue is in a non-claimable state so the contract is
well-defined.
A row (blocker=A, blocked=B) reads "A blocks B" / "B is blocked by A".
POST /api/issues/B/blockers { blockerId: A } creates that edge. The graph is
kept a DAG: self-edges and cycles are rejected at write time with 400.
{
"id": 123,
"identifier": "LIN-123",
"number": 123,
"title": "string",
"description": "markdown | null",
"status": "todo",
"priority": 2,
"labels": [{ "id": 1, "name": "ready-for-agent", "color": "#22c55e" }],
"blockerIssueIds": [122],
"ready": false,
"questions": [
{
"id": 1,
"number": 1,
"question": "should the cache be per-request or global?",
"answer": "per-request — see the auth middleware.",
"status": "answered",
"createdAt": "2026-07-14T00:00:00.000Z",
"answeredAt": "2026-07-14T00:05:00.000Z"
}
],
"createdAt": "2026-07-14T00:00:00.000Z",
"updatedAt": "2026-07-14T00:00:00.000Z"
}/— issue list with status/priority/label filters and a project switcher; the list, frontier, and map pages are all scoped to the active project./frontier— the frontier (what's grabbable right now)./map— the active project's dependency graph./issues/:identifier— full detail: rendered markdown, edit form, status/priority/label controls, blocker + blocked-by lists with add/remove, and a read-only Agent Q&A transcript (open questions show as pending, answered ones as resolved — asking/answering are API-only)./new— create form./labels— label CRUD.
The UI uses server actions (not the REST API) for mutations, but both go through the same domain layer — so the rules (frontier, cycle prevention, claim, identifier assignment, label cascade) are shared and identical.
All behavior is tested through the REST API (the agent contract), against a real Next.js instance with a fresh temp SQLite file per test file:
npm test # run once
npm run test:watchStop any running dev server first (npm run dev): Next.js 16 allows only one
dev server per project directory, and each test file boots its own — a running
dev server makes every harness boot time out ("Next dev server did not become
ready").
The tests cover: frontier cases, cycle/self-edge prevention, claim transitions, identifier assignment (no reuse after delete), label cascade, CRUD, filtering, the canonical error shape, multi-project scoping (per-project numbering, no cross-project leakage), legacy single-project DB migration, plus a few UI smoke checks.
src/
lib/
db/ schema + SQLite connection (lazy create + seed + legacy migration)
domain.ts the rules: frontier, cycle prevention, claim, CRUD, projects
serialize.ts canonical Issue JSON shape
validate.ts API input parsing → ValidationError
identifiers.ts project-scoped :id resolution + KEY-N identifier format
api.ts HTTP helpers + canonical error envelope
markdown.ts server-side remark rendering
components/ UI primitives + shared issue display
app/
api/ REST route handlers (the agent contract)
actions.ts server actions (the UI's mutation seam)
page.tsx list view
frontier/ frontier view
issues/ detail view
new/ create view
labels/ label CRUD
tests/ black-box HTTP tests