Contributor conventions
Note: this page is generated from
AGENTS.mdin the repo root byscripts/gen-conventions-page.ts. Edit that file, not this page — it is overwritten on every generate.
Guidance for AI agents (and humans) working on the projektor codebase. Read this before making changes — it captures conventions that aren’t obvious from the code alone.
Portable source of truth across agent tools (Claude Code, Codex, Cursor, …).
CLAUDE.mdpoints here.
What projektor is
Section titled “What projektor is”A project management tool deployed on Cloudflare, combining AI-native design with tried-and-tested principles.
Design principles
- Fast and lightweight.
- Serverless, built on Cloudflare resources.
Implementation details:
- When implementing a feature or fixing a bug, always add a test that confirms the behaviour.
- Runtime: Hono on Cloudflare Workers
- Data: D1 (SQLite) for relational data, KV for caching (Access certs, user-by-email), R2 for file attachments
- Schema: Drizzle is the schema and primary query layer; raw
DB.prepareremains in the auth/workspace middleware hot path, the dev bootstrap, and a handful of service queries (FTS, counters) where hand-written SQL is clearer. - Monorepo: pnpm workspaces + turbo.
apps/api(the Worker),apps/web(Astro + Preact static site, served in production via CF Workers Static Assets — see below),apps/docs(the Astro docs site linked throughout this file),packages/*(db, types, plugin-sdk),plugins/* - Deploy: projektor publishes a self-contained release artifact on each
v*tag; a config-only deploy repo (e.g.projektor-deploy-example) downloads it and ships it withwrangler— no submodule, no source checkout downstream. The Worker (apps/api) and the built frontend (apps/web/dist) ship together:wrangler.tomldeclares an[assets]binding withrun_worker_first = ["/api/*", "/mcp/*", "/wiki", "/.well-known/*"], so those paths always hit the Hono Worker while every other path serves the static Astro output (per-route HTML, asset-first). The release build compilesapps/weband bundles the Worker into a singleworker.js.
Coordination model (read this first)
Section titled “Coordination model (read this first)”Projektor expects multiple agents to work the same workspace concurrently. Before editing anything:
- Claim before editing. File claims (
claim_files) are path-level — they stop two agents editing the same files. Matching is exact string equality, not globs: claimingsrc/reserves nothing undersrc/, so name concrete paths and name them the same way the rest of the fleet does. Issue leases (claim_issue) are work-item-level — they stop two agents picking up the same ticket. The two are independent; you need both. - Your session goes stale if it goes quiet. Liveness is heartbeat-based:
ACTIVE_TTLinapps/api/src/services/agents.ts(mirrored asSESSION_TTL_SECONDSinapps/api/src/services/issue-leases.ts) is 120 seconds. Any call made with your session id (claim_issue,claim_files,update_issue,post_message) refreshes it, so use the two-call path below and you will rarely need an explicitheartbeat_agent. Go quiet for two minutes with no such call and your session goes stale: you must heartbeat again before you can claim. - Both tiers self-heal. An issue lease or file claim held by a stale session is
reclaimed by the next claimer in the same call (
release_reason: "expired"). Still callrelease_filesandend_agentwhen you finish: reclaim only happens when someone else wants the path, so until then your claims sit there looking held, and a clean exit is what distinguishes you from a crash in the health data. A claim with noagent_idhas no heartbeat to judge and is never auto-reclaimed —forceis the only way past it. - There’s a per-project cap on concurrently leased issues —
DEFAULT_AGENT_WIP_LIMIT = 3inapps/api/src/services/issue-leases.ts, overridable per project viaprojects.agent_wip_limit. It’s admission control on the backlog, not a rate limit: it bounds how much work can be in flight at once, not how fast you can ask. - A refused claim tells you who to talk to. Rejection is all-or-nothing: nothing is
claimed, and the error names the issue and agent holding the path — message them with
post_messageif you need it. Nothing is pushed to the holder on a plain rejection — its claim didn’t change.forceis different: it posts to both your issue scope (audit) and theirs (PROJ-635), since you just took something they thought they still held. Every contended path is recorded regardless.
This is the mechanism; the mechanical call sequence for this repo is under “Fleet
coordination protocol” below, and the design rationale (why leases, claims, and the
WIP cap are shaped this way) is the coordination model
doc. The workflow rules themselves (definition of ready, state machine, human review
gates) live in exactly one place, the workflow spec
— call get_workflow before claiming work; they aren’t restated here.
Planning and design docs live in the wiki, not the repo
Section titled “Planning and design docs live in the wiki, not the repo”Design records, implementation plans, and specs belong in the projektor wiki (create_wiki_page/update_wiki_page), not in a repo docs/ folder. Keeping them in the wiki makes them discoverable and searchable (search_wiki) instead of buried in git history. Root-level user-facing docs (README.md, AGENTS.md, CONTRIBUTING.md, SECURITY.md) are the only docs that belong in the repo itself.
Human-authored files
Section titled “Human-authored files”Most docs may be generated or written by agents, but these paths are human-authored (Tom’s decision, PROJ-915):
README.mdapps/docs/src/content/docs/philosophy/**
Agents must not edit these files, not even to fix a typo or a stale fact. When your work makes one of them inaccurate, add a comment to the current editorial issue (PROJ-914 or its successor) that quotes the affected line and states the fact that changed. Docs checks may still scan these files and report drift, but the fix goes to the editorial issue, never into an agent’s diff. .github/CODEOWNERS requires @TAJD’s review on both paths.
Architecture: the service-layer contract (most important)
Section titled “Architecture: the service-layer contract (most important)”There are two surfaces over the same data — a REST API and an MCP (JSON-RPC) server. They MUST behave identically. The mechanism that guarantees this:
routes/<domain>.ts (REST wrapper) ─┐ ├─► services/<domain>.ts ─► D1mcp/<domain>.ts (MCP wrapper) ─┘ (ALL business logic + SQL live here)Rules:
- All business logic and SQL live in
services/<domain>.ts. Routes and MCP tools are thin wrappers — resolve context, call the service, adapt the result/error. No SQL inroutes/ormcp/. - REST and MCP must stay at parity. If you add or change behavior, do it in the service so both surfaces get it. Adding a feature to only one surface is a bug.
- Validation happens inside the service via a shared Zod schema in
schemas/<domain>.ts— so REST and MCP are validated identically. Never trust rawunknowninput in a wrapper. - Services throw typed errors from
services/errors.ts(ValidationError,NotFoundError,ForbiddenError,ConflictError). The wrappers translate them:- REST:
http/error-adapter.ts→ HTTP status (400/404/403/409) - MCP:
mcp/error-adapter.ts→ a tool result withisError: trueand{error: {code, message, fields?, hint?, details?}}(PROJ-893;codeis the service error kind). JSON-RPCerroris only for protocol faults and unexpected internal errors. Never return rawString(err)to clients.
- REST:
- Context is a
ServiceCtx(services/types.ts):{ db, kv, r2, workspaceId, userId, role? }. Build it withctxFromHono(c)in REST; the MCP dispatch (routes/mcp.ts) builds the equivalent and passesrolethroughPluginContext.
Deliberate REST↔MCP parity exceptions
Section titled “Deliberate REST↔MCP parity exceptions”These surface-only features are intentional, not drift — don’t re-flag them in future audits:
- File attachment upload/download (
POST /api/files,GET /api/files/:id) — REST-only. Binary/multipart upload and streamed download can’t cross JSON-RPC. Metadata operations (list, get metadata, link-create, delete) have full MCP parity viamcp/files.ts. - Auth (
routes/auth.ts): login redirect, API token minting/revocation — REST-only. CF Access login is a browser redirect flow; token minting/revocation is a sensitive credential operation kept off the MCP surface. - Workspace-scoped API tokens (
POST/GET/DELETE /api/workspaces/:slug/tokens) — REST-only, same rationale as auth tokens above. GET /api/workspaces/:slug/mcp-info— REST-only. Bootstraps how to connect an MCP client in the first place; inherently can’t be an MCP tool.- Cross-workspace project list (
GET /api/projects→listAllProjects) — REST-only. MCP connections are bound to a single workspace (/mcp/<workspaceId>), so a cross-workspace listing doesn’t fit the MCP connection model. MCP’slist_projectsis the single-workspace equivalent (different, plainer shape — noopen_issue_count/workspace_namerollups). - Public issue sharing (
POST /api/issues/:id/share,GET /api/share/:token) — REST-only. Share-link creation/redemption is a browser-facing feature (the redemption endpoint is intentionally unauthenticated by token). get_prioritized_issues— MCP-only. An agent-productivity tool (“what should I work on next”) with no natural REST/browser analog.- Wiki export (
GET /api/wiki/export) — REST-only. Returns a binary zip (markdown + attachments) which can’t cross JSON-RPC the same way file download can’t (see the file-attachment exception above); import is explicitly out of scope (PROJ-497) so there’s no round-trip MCP surface to keep parity with either. - Public feedback submission (
POST /api/feedback/submit) — REST-only. Anonymous end-user feedback from a third-party product, authenticated by a per-source bearer token, not a session — there’s no ServiceCtx user/role for an MCP tool to act as. Feedback source management (create/list/update/rotate/revoke) and authenticated read/triage (list_feedback,update_feedback_status,convert_feedback_to_issue) both have full REST+MCP parity, same as every other domain; only the anonymous submit endpoint itself is the exception (PROJ-668). - OAuth consent (
GET/POST /oauth/authorize,services/oauth.ts) — REST-only, and browser-only. The whole point of the consent screen is that a human decides which client may act as them; an agent is the subject of a grant, never the party that approves one.middleware/auth.tsfails the route closed for API tokens and for the shared PUBLIC_READ_ONLY viewer for the same reason./oauth/tokenis not a projektor route at all — the OAuth library serves it before Hono sees the request. - Connector grants (
GET/DELETE /api/workspaces/:slug/connectors) — REST-only, for the same reason token minting is: withdrawing a credential is a sensitive operation, and a connector should not be able to enumerate or revoke credentials — least of all its own siblings. The list is scoped to the requesting user, not the workspace: a grant is a personal credential, so unlikepk_tokens no admin can see or revoke someone else’s.
The security invariant: always scope by workspace
Section titled “The security invariant: always scope by workspace”Every query MUST be scoped by workspace_id (directly, or via a parent entity that was itself workspace-checked — e.g. comments verify their issue belongs to the workspace first). A missing scope is a cross-tenant data leak. This is the single most important correctness rule in the codebase.
The D1 limit: never bind a row-scaled array into one query
Section titled “The D1 limit: never bind a row-scaled array into one query”Cloudflare D1 rejects any query with more than 100 bound parameters. A query whose parameter count grows with an input array — drizzle inArray, a raw IN (...), or a batched mutation keyed by ids — will throw at runtime (a 500) once the array is large enough. This is invisible in tests: the vitest runner backs D1 with SQLite (cap 32766), so an un-chunked query passes CI and only fails on real D1.
Route every variable-length IN/inArray load through inChunks (services/sql.ts), which splits the array so each query stays under the cap:
const rows = await inChunks(issueIds, (chunk) => orm.select(...).from(...).where(and(inArray(table.id, chunk), eq(table.workspaceId, ctx.workspaceId))));// for a mutation that returns nothing, have the callback return []Bounded arrays (enums like priority) are fine to bind directly. When in doubt, chunk.
Versioning
Section titled “Versioning”apps/web/package.json is the single version source for the whole monorepo -
bumped by release-prepare.yml, tagged by release-tag.yml, and read by
release.yml/scripts/build-release.sh to produce the release artifact (embedded
as VERSION in the tarball and injected into the MCP serverInfo.version via
esbuild --define). Every other apps/*/packages/* package’s package.json
version field is a fixed 0.0.0-workspace placeholder — those packages are
workspace-internal and not independently released, so their version field is unused
and intentionally never bumped. plugins/* packages carry their own unused
placeholder versions (e.g. 0.0.0, 0.0.1), not 0.0.0-workspace.
File layout per domain
Section titled “File layout per domain”When adding/changing a domain (issues, projects, wiki, comments, …):
| File | Role |
|---|---|
apps/api/src/services/<domain>.ts |
business logic, SQL, validation, typed errors |
apps/api/src/schemas/<domain>.ts |
Zod schemas (single source of truth; shared primitives in schemas/common.ts) |
apps/api/src/routes/<domain>.ts |
REST wrapper (mounted in index.ts) |
apps/api/src/mcp/<domain>.ts |
MCP tool array (composed in routes/mcp.ts) |
apps/api/src/test/<domain>.test.ts |
domain tests go here |
Test convention (don’t skip this): put a domain’s tests in its own <domain>.test.ts. Do not pile MCP tests into the shared test/mcp.test.ts — parallel work on multiple domains will collide there on merge. (mcp.test.ts is for cross-cutting dispatch behavior only.)
Conventions & gotchas
Section titled “Conventions & gotchas”- Adding a migration? After adding a new
.sqlfile topackages/db/migrations/, you must also add a corresponding?rawimport toapps/api/src/test/migrations.tsand append it to theMIGRATIONSarray. Without this the test DB won’t have the new table and integration tests will silently fail or error. Migrations are hand-written SQL — drizzle-kit’s generator is deliberately not wired up (PROJ-643): its journal was abandoned after0001, sodrizzle-kit generatediffed against a snapshot ~52 migrations stale and emitted a fullCREATE TABLEfor every table, which would fail against any non-empty database. Don’t re-add it without re-baselining the snapshot first. A migration that adds an index ends withPRAGMA optimize;so the planner has fresh statistics (PROJ-857). - Deletes never rely on FK cascades. Deleting a row that other tables reference must remove (or null) those rows explicitly in the service, in the same
db.batch()-ON DELETE CASCADE/SET NULLin the schema is not the cleanup (PROJ-407/918), and FTS mirrors, R2 objects and non-FK references have no cascade at all. Adding an FK withON DELETEmeans adding the cleanup and an entry inapps/api/src/test/architecture/fk-cleanup-allowlist.ts;fk-cleanup.node.test.tsfails otherwise. (PRAGMA foreign_keys = OFFis a no-op on D1/Miniflare, so tests can’t switch cascades off to prove cleanup.) - camelCase at the boundary, snake_case in the DB. Input schemas use
assigneeId,parentId, etc.; the service maps to theassignee_idcolumn. Keep both surfaces on the same naming. - JSON columns (
labels,scopes) are stored viaJSON.stringifyand returned as raw JSON strings — callersJSON.parseon read. There is no automatic (de)serialization. - Timestamps are unix seconds:
Math.floor(Date.now() / 1000). - IDs are
crypto.randomUUID(). - Issue numbers use
COALESCE(MAX(number),0)+1per project — known race under concurrency (tracked as a follow-up). - Auth (
middleware/auth.ts): Cloudflare Access JWT (browser) ORAuthorization: Bearer <token>(agents) OR a dev bypass (DEV_USER_EMAIL, non-prod only, and never on/mcp/— a remote MCP client learns it must authenticate from the 401 challenge, so answering 200 makes the connector flow unreachable). API tokens are workspace-scoped — don’t widen that. - Login provisioning (
services/provisioning.ts): runs on every CF Access / dev-bypass login (not the token path). Cloudflare Access is the gate; config decides what a user gets inside —ADMIN_EMAILS→owner(first admin login also creates theDEFAULT_WORKSPACE_SLUGworkspace), everyone else →AUTO_JOIN_ROLE(defaultnone= invite-only; set it, e.g.viewer, to auto-join). Idempotent; safe to run per request. - Roles (
owner/admin/member/viewer) are enforced in services viactx.role. Mutations generally blockviewer; destructive ops may requireowner. - Group-based project access is the authorization model for project-scoped data. Access is default-deny: owner/admin see everything, but everyone else sees a project only if one of their groups holds a
(project, role)grant. The effective in-project role is the strongest grant across the user’s groups and replaces their workspace role inside that project (so a workspaceviewerwith amembergrant can write there). Enforce it throughservices/access.ts:visibleProjectPredicate(an indexedEXISTSsubquery — filter every project-scoped list query with it),effectiveProjectRole/requireProjectAccess(resolve a single resource;null→ 404 to hide existence), andcanWriteProject. Membership is read per-request, so grant/revoke takes effect on the next request with no session state. Thegroupsdomain (service/routes/mcp) is owner/admin-only CRUD over groups, members, and grants. - The plugin system is not wired at runtime yet (
pluginRegistryis empty;enabled_pluginsis unread). Treatplugins/*as not-yet-functional until that lands.
localStorage policy (frontend)
Section titled “localStorage policy (frontend)”localStorage may only store cosmetic preferences (theme, view mode, layout choices).
Never store server-side entity references (workspace slug, project ID, user ID) — a deleted
or renamed entity leaves a stale value that will silently cause API 4xx errors.
Before adding a new localStorage.setItem call, ask:
- Does a stale value ever reach an API request? If yes → don’t store it; derive it from props or build-time env instead.
- Does a missing value crash the UI or produce a non-graceful error? If yes → add a safe fallback, not localStorage.
Mark safe usages with a // safe-ls: comment explaining why (cosmetic, no API dep,
degrades gracefully). This is the convention established in PR #99.
Frontend: islands and the API layer
Section titled “Frontend: islands and the API layer”All island↔API calls go through apps/web/src/utils/api-client.ts:
buildHeaders(workspaceSlug, extra?)— adds the X-Workspace-Slug headerapiFetch<T>(path, opts)— wraps fetch with headers, credentials, JSON parse, and error throwing
No raw fetch( calls in island components. No local buildHeaders copies.
This mirrors the backend service-layer contract: routes are thin wrappers; islands are thin callers.
Frontend: project identity (apps/web/src/lib/project-context.ts)
Section titled “Frontend: project identity (apps/web/src/lib/project-context.ts)”Islands are separate client:load roots (each its own Preact tree), so Preact context can’t
cross between them. Project identity instead lives in a module-level @preact/signals store:
currentProject, projectsList, projectError, projectReady. Module state survives Astro’s
ClientRouter navigations, so whichever island resolves first writes it and every sibling
island, plus the next in-app navigation, reads it without a refetch.
Islands call ensureProjectResolved(workspaceSlug, urlHint?, matches?) (or the useCurrentProject
hook) instead of parsing ?projectId=/?id= or fetching /api/projects themselves. Resolution
persists the resolved id back to the address bar via history.replaceState (see
resolve-project-id.ts’s persistProjectId), so copied URLs stay shareable. This is the only
@preact/signals usage in the codebase and the only module-level store pattern for cross-island
state — reach for it, don’t invent a second one, before adding a new island that needs project
identity.
The identity param is a boundary concern, not a transport mechanism: links between pages that
stay within the resolved project (ProjectNav’s tabs, in-app links to a project’s own issues,
wiki pages, etc.) carry no project param at all — the store survives the ClientRouter
navigation those <a> tags trigger, so the destination resolves instantly with no refetch.
?projectId= (the project UUID) is written only where identity actually crosses a boundary: the
cold-start entry point (ProjectList’s project cards), a full non-SPA reload
(window.location.href assignments, which drop all in-memory state), or an explicit project
switch. ?id= is reserved for a page’s own entity (an issue on /issues/view, for example) and
is never used for project identity in newly-written links — the resolver still accepts it (along
with ?project=) on read, for backward compatibility with existing shared URLs.
Dev workflow
Section titled “Dev workflow”pnpm installpnpm turbo type-check # tsc --noEmit across the monorepopnpm --filter @projektor/api test # vitest against an in-process Worker + D1
# One-time local secrets so the browser frontend can auth without Cloudflare Access:cp apps/api/.dev.vars.example apps/api/.dev.vars # DEV_USER_EMAIL + BOOTSTRAP_SECRETcp apps/web/.env.example apps/web/.env # PUBLIC_WORKSPACE_SLUG=projektor
pnpm dev # local dev - API on :8787, web on :4321# `dev` auto-applies D1 migrations to the local Miniflare DB first (db:migrate:local),# so /api/* won't 500 with "no such table" on a fresh checkout.GET /bootstrap (non-prod only, needs BOOTSTRAP_SECRET) seeds a workspace + user + token
- membership in one shot and prints the
claude mcp add ...command to connect an agent. Seed it once:
curl -H "X-Bootstrap-Secret: localdev" http://127.0.0.1:8787/bootstrapThen open http://localhost:4321 — with DEV_USER_EMAIL set, the dev auth bypass logs you in
as that user (a member of the seeded projektor workspace), and the islands load real data.
Before opening a PR: pnpm lint, pnpm turbo type-check, pnpm --filter @projektor/db test, pnpm --filter @projektor/api test:coverage, pnpm --filter @projektor/web test:coverage, pnpm --filter @projektor/web build, and pnpm --filter @projektor/docs build must all be green, and pnpm gen:docs must produce no diff. CI runs these plus the island API and design system convention checks (.github/workflows/ci.yml).
E2E testing (apps/web/e2e, Playwright)
Section titled “E2E testing (apps/web/e2e, Playwright)”Targets a deployed dev instance (E2E_BASE_URL), not local dev — see apps/web/e2e/README.md for the full setup, fixtures, and per-spec breakdown. Not run in CI (no live deployment there); run manually or on a schedule.
Three projects, pick the narrowest one that answers your question:
desktop— default viewport, Chromium.mobile— 375×812 viewport via Chromium’s mobile emulation. Fast, good for layout/CSS regressions.mobile-webkit— real WebKit engine (devices["iPhone 13"]). Reach for this specifically when investigating iOS Safari engine-level behavior that Chromium can’t reproduce (visual-viewport/on-screen-keyboard resize events,position: fixedunder scroll, etc.) — it caught the PROJ-397/PROJ-566 class of mobile-modal bugs. Still not a substitute for a real device: no Safari chrome, no PWA install/Add-to-Home-Screen coverage.
pnpm --filter @projektor/web exec playwright test --project=mobile-webkitGit hooks (lefthook)
Section titled “Git hooks (lefthook)”pnpm install runs prepare, which calls lefthook install and wires one hook:
- pre-commit —
pnpm turbo type-check(fast; leverages turbo’s cache, near-instant on unchanged packages) andpnpm biome check --changed --no-errors-on-unmatched(lint, changed files only).
There is deliberately no pre-push hook — CI (.github/workflows/ci.yml) is the authoritative gate before merge (main is PR-protected; direct pushes are rejected), so a local pre-push copy of the same checks was pure redundant overhead. It was also a source of real bugs: under concurrent local load its test step could fail while a backgrounded git push still reported exit code 0, masking a rejected push. It was removed for these reasons; don’t re-add one without addressing both.
CI runs a superset of the pre-commit checks: the generated-docs freshness check, pnpm lint, pnpm turbo type-check, pnpm --filter @projektor/db test, coverage-enforced test runs for @projektor/api and @projektor/web, and both the web and docs builds. New contributors get the pre-commit hook automatically after pnpm install. See Before opening a PR above for the full local command set to run before pushing.
Bypass for WIP commits: pass --no-verify (or -n) to git:
git commit --no-verify -m "wip: …"Agent workers should also use --no-verify for intermediate commits; run the full checks (listed under “Before opening a PR” above) before opening a PR.
Fleet coordination protocol
Section titled “Fleet coordination protocol”The workflow rules (definition of ready, state machine, human review gates, WIP
limits) have exactly one home: the workflow spec,
served live via the get_workflow MCP tool / GET /api/workflow. Call it before
claiming work — don’t rely on a copy of the rules here, they aren’t restated in this
file.
What is repo-specific and stays here: the mechanical call sequence agents use to avoid colliding in this particular repo’s git worktree/file layout.
Session identity (PROJ-894)
Section titled “Session identity (PROJ-894)”register_agent (and start_work) records the credential the call authenticated with on
the session (agent_sessions.credential_id + auth_method). A lone agent on its own
credential may then omit the agent id on claim_issue, heartbeat_agent and end_agent.
Fleets that share one credential (one pk_ token for every worker) should still pass
agentId explicitly: with several live sessions on the credential an omitted id is
ambiguous and is rejected. No per-connection state exists; the session is looked up from the
credential on every call (PROJ-452 statelessness holds).
The two-call path (PROJ-929)
Section titled “The two-call path (PROJ-929)”start_work and finish_work collapse the sequence below into two calls:
start_work({ issue, paths, name })at session start — registers the session, claims the issue and files (if given), and posts the start message. All-or-nothing with compensating cleanup, not a single atomic write (D1 has no cross-call interactive transaction): on any conflict (the sameclaim_issue/claim_fileserrors as before) the session is ended and nothing is left claimed. If the process crashes mid-call (so that cleanup never runs), the claims it made become reclaimable once the session’s heartbeat goes stale after the 120s TTL, same as any other stale holder. Save the returnedsessionId.finish_work({ sessionId, issue, completionReport?, status? })when done — optionally transitions the issue via the same pathupdate_issueuses (completion-report rules apply unchanged), then releases every claim/lease the session holds and ends it.
claim_issue/claim_files/update_issue/post_message calls made with a live agent
session id refresh that session’s heartbeat as a side effect, and finish_work ends the
session outright, so an explicit heartbeat_agent is optional on this path — call it
anyway if a lot of work happens between start_work and finish_work with no other
agent-scoped call in between.
The five-call path (still supported)
Section titled “The five-call path (still supported)”The primitives above compose from these, which remain available for finer-grained
control (e.g. claiming files separately from the issue, or checking list_file_claims
before deciding whether to force):
register_agentat session start, linking the issue you’re implementing — save the returnedid.claim_filesbefore touching any file (checklist_file_claimsfirst; back off, don’tforce).post_messagetoscope: "issue:<uuid>"when you start/blocker/finish;scope: "workspace"for fleet-wide notices.heartbeat_agentonly if more than ~2 minutes pass between calls that carry your session id (those refresh it already; sessions time out after 120 s of silence).release_filesthenend_agentwhen done.
See the MCP tool catalog for each tool’s exact input schema.
Working in parallel (multi-agent)
Section titled “Working in parallel (multi-agent)”This repo is built out via parallel workers in separate git worktrees. To avoid conflicts:
- Give each worker a disjoint file set (one domain = its 4-5 files above). Domains don’t share files except read-only shared scaffolding (
services/types.ts,services/errors.ts,schemas/common.ts, the adapters) androutes/mcp.ts/index.ts. - Never let two parallel workers edit
routes/mcp.ts,index.ts, ortest/mcp.test.ts— serialize those, or assign to exactly one worker. - Large refactors that touch shared files go in a foundation phase first (behavior-preserving), then fan out per-domain.
Spawn prompt requirement
Section titled “Spawn prompt requirement”Workers will not use the coordination primitives unless explicitly told to. Every spawn prompt for a parallel worker must include a ## Coordination (required) section stating the call sequence from “Fleet coordination protocol” above. Default to the two-call start_work/finish_work path and tell workers not to call heartbeat_agent, register_agent or release_* separately; use the five-call path only when a task needs finer-grained control.
A full spawn prompt also needs a Finish section (what “done” means for the task, and what to report back) alongside the Coordination section above.
Fleet planning rules
Section titled “Fleet planning rules”These are the constraints the fleet skill reads to plan batches. Keep them current when the codebase changes.
Serialized files — only one worker at a time, ever:
| File | Reason |
|---|---|
apps/api/src/routes/mcp.ts |
MCP tool registry — all domains compose here |
apps/api/src/index.ts |
Hono app root — route mounting |
apps/api/src/test/mcp.test.ts |
Cross-cutting dispatch tests — domain tests go in test/<domain>.test.ts |
Domain → file ownership — one agent per row, no overlap:
| Domain | Service | Schema | Routes | MCP | Tests |
|---|---|---|---|---|---|
| issues | services/issues.ts |
schemas/issues.ts |
routes/issues.ts |
mcp/issues.ts |
test/issues.test.ts |
| projects | services/projects.ts |
schemas/projects.ts |
routes/projects.ts |
mcp/projects.ts |
test/projects.test.ts |
| wiki | services/wiki.ts |
schemas/wiki.ts |
routes/wiki.ts |
mcp/wiki.ts |
test/wiki.test.ts |
| files | services/files.ts |
schemas/files.ts |
routes/files.ts |
mcp/files.ts |
test/files.test.ts |
| sprints | services/sprints.ts |
schemas/sprints.ts |
routes/sprints.ts |
mcp/sprints.ts |
test/sprints.test.ts |
| comments | services/comments.ts |
schemas/comments.ts |
routes/comments.ts |
mcp/comments.ts |
test/comments.test.ts |
| task-types | services/task-types.ts |
schemas/task-types.ts |
routes/task-types.ts |
mcp/task-types.ts |
test/task-types.test.ts |
| custom-fields | services/custom-fields.ts |
schemas/custom-fields.ts |
routes/custom-fields.ts |
mcp/custom-fields.ts |
test/custom-fields.test.ts |
| workflow | services/workflow.ts |
— (no input) | routes/workflow.ts |
mcp/workflow.ts |
test/workflow.test.ts |
| flow-metrics | services/flow-metrics.ts |
schemas/flow-metrics.ts |
routes/flow-metrics.ts |
mcp/flow-metrics.ts |
test/flow-metrics.test.ts |
| groups | services/groups.ts |
schemas/groups.ts |
routes/groups.ts |
mcp/groups.ts |
test/groups.test.ts |
Frontend islands are not domain-locked in the same way, but two agents must never own the same island file. Assign each island to exactly one agent per batch.
Deploy: tag a release (git tag vX.Y.Z && git push --tags) — release.yml
builds the artifact and the config-only deploy repo (projektor-deploy-example) picks
it up. See the deploy guide.
CI commands (must all pass before opening a PR):
pnpm gen:docs # must produce no diffpnpm lintpnpm turbo type-checkpnpm --filter @projektor/db testpnpm --filter @projektor/api test:coveragepnpm --filter @projektor/web test:coveragepnpm --filter @projektor/web buildpnpm --filter @projektor/docs buildMerge ordering rule: if two agents both touch the same frontend file (e.g.
IssueList.tsx), assign one as “primary” and one as “secondary”. Primary merges
first; secondary rebases onto main before merging. Document this in the spawn prompts
and in the fleet manifest.
MCP tool catalog
Section titled “MCP tool catalog”All tools are available via POST /mcp/<workspaceId> (JSON-RPC 2.0). Connect with:
claude mcp add --transport http --header "Authorization: Bearer <token>" \ projektor https://<host>/mcp/<workspaceId>The full tool list is generated from source — do not hand-maintain a copy here.
See the MCP tool catalog
(generated into apps/docs/src/content/docs/agents/tool-catalog.md by
apps/api/scripts/gen-mcp-catalog.ts from apps/api/src/mcp/*.ts; CI fails if it is
stale). The grouping there separates Coordination tools (the agent-native primitives
used by the fleet protocol above) from Project data tools.
Tip: get_issue accepts ref: "PROJ-42" (project key + number) — you don’t need the UUID when you have the display key.
tools/list caching: the response carries ttlMs/cacheScope hints (SEP-2549); cacheScope is "private" because the list varies per query string (?domains=, PROJ-716). These are advisory only — projektor has no server-side cache backing them — so a client that caches tools/list must key on the full request URL (path + query), not the path alone, or it will serve one caller’s filtered catalog to another.
Built by Verdient.