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. Bringing together AI native design and tried and tested principles.
Design principles
- Fast and lightweight.
- Serverless using cloudflare resources.
Implementation details:
- When implementing features or fixing bugs you must always implement a test or tests to confirm the functionality is implemented.
- 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),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-workspace) 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/*"], so/api/*and/mcp/*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.
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→ JSON-RPC code (-32602for validation,-32000otherwise). 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”The parity audit (PROJ-236) confirmed these surface-only features are intentional, not drift — don’t re-flag them in future audits:
- File attachments (
routes/files.ts) — REST-only. Binary/multipart upload and streamed download can’t cross JSON-RPC. Tracked separately as PROJ-234 (no MCP surface at all for this domain). - 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.
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 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.
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. - 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). 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(defaultviewer;none= invite-only). Idempotent; safe to run per request. - Roles (
owner/admin/member/viewer) are enforced in services viactx.role. Mutations generally blockviewer; destructive ops may requireowner. - 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.
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/api test, pnpm --filter @projektor/web test, and pnpm --filter @projektor/web build must all be green. CI runs exactly these.
Git hooks (lefthook)
Section titled “Git hooks (lefthook)”pnpm install runs prepare, which calls lefthook install and wires two hooks:
- pre-commit -
pnpm turbo type-check(fast; leverages turbo’s cache, near-instant on unchanged packages),pnpm biome check --changed --no-errors-on-unmatched(lint, changed files only), and the island API convention check. - pre-push -
pnpm --filter @projektor/api testandpnpm --filter @projektor/web test(too slow for every commit but catches the failures that most often break CI).
These mirror CI (.github/workflows/ci.yml), which additionally runs pnpm lint (full repo) and pnpm --filter @projektor/web build as PR gates. New contributors get the hooks automatically after pnpm install.
Bypass for WIP commits/pushes: pass --no-verify (or -n) to git:
git commit --no-verify -m "wip: …"git push --no-verifyAgent workers should also use --no-verify for intermediate commits; run the full checks before opening a PR. To manually re-run a hook without committing: pnpm lefthook run pre-push.
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.
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_agentevery ~60 s (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 section:
## Coordination (required)You are working in a parallel fleet. Use the projektor MCP to coordinate:
1. `register_agent` at session start - link to your issue ID, save the returned `id`.2. `claim_files` before touching any file - check `list_file_claims` first; back off if another agent holds a file.3. `post_message` to `scope: "issue:<uuid>"` when you start, hit a blocker, and finish. Use `scope: "workspace"` for fleet-wide announcements (e.g. "rebasing mcp.ts, hold off").4. `heartbeat_agent` every ~60 s while working (sessions time out after 120 s of silence).5. `release_files` then `end_agent` when done.See ~/.claude/docs/spawning-sessions.md for the full prompt template (Coordination + Finish line are both required sections).
Fleet planning rules (for the /fleet skill and human planners)
Section titled “Fleet planning rules (for the /fleet skill and human planners)”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 |
| 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 |
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-workspace) picks
it up. See the deploy guide.
CI commands (must all pass before opening a PR):
pnpm lintpnpm turbo type-checkpnpm --filter @projektor/api testpnpm --filter @projektor/web testpnpm --filter @projektor/web 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 projektor --transport http https://<host>/mcp/<workspaceId> \ --header "Authorization: Bearer <token>"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.