Architecture
Wiki + Jira-style issue tracker built MCP-native, running entirely on Cloudflare’s edge. Monorepo (pnpm + turbo).
This page maps how Projektor is actually built — its surfaces, service layer, and storage — including what is wired up and what isn’t yet. It describes the system as it stands today, not an idealised summary.
How it works
Section titled “How it works”Projektor runs as a single Cloudflare Worker (apps/api, built on Hono). That one
Worker exposes two surfaces over a shared request pipeline: a REST API for the
browser SPA, and an MCP endpoint (JSON-RPC 2.0) that is the primary
surface for AI agents. Every request flows through auth middleware — Cloudflare Access
JWTs for human browser sessions, hashed API tokens for agents — and then workspace
middleware that resolves the tenant from the slug and verifies membership before any
handler runs.
State lives entirely on Cloudflare’s edge. D1 (SQLite) holds the relational data - workspaces, users, projects, issues, comments, wiki pages, tokens, activity, and revisions. KV caches Access certs and email lookups. R2 holds file attachments. There are no servers and no containers: the whole system deploys as a Worker plus its bound data stores.
That storage choice is load-bearing, not incidental. Coordination state — issue leases, file claims, workspace membership — is shared, durable, and workspace-scoped in D1, reachable by any client that can make an HTTPS request. Two agents on two machines, or an agent and a CI job, read and write the same lease and claim rows and see each other’s state immediately. A tool that keeps this state in a local SQLite file or an in-repo JSONL log cannot do that: two machines don’t share a filesystem, and a CI job doesn’t have a persistent one to write to. That’s a ceiling on what a local-first design can coordinate — a whole class of cross-machine and agent/CI fleets is unreachable no matter how good its primitives are — not a convenience Projektor happens to also offer.
Deployment is also operationally simple, which is a separate, smaller claim: a release
artifact plus a config-only repo as the distribution unit, a pinned version, wrangler
auto-provisioning D1/KV/R2, migrations applied automatically at deploy, /bootstrap
gated to non-production environments, and an agent-executable runbook. See
Deploying & operating for the full mechanics.
The sections below give the picture in increasing detail — first a diagram, then a layer-by-layer breakdown, then the request flow through a single agent call.
System diagram
Section titled “System diagram”flowchart TB
subgraph Clients
AI["AI agents<br/>(Claude, etc.)"]
Browser["Browser / SPA user"]
CI["GitHub Actions"]
end
subgraph Edge["Cloudflare Edge"]
Access["Cloudflare Access<br/>(SSO / Zero Trust)"]
subgraph Worker["projektor-api · Cloudflare Worker (Hono)"]
direction TB
MW["Middleware<br/>auth → workspace"]
REST["REST routes<br/>/api/projects · issues · wiki · comments · workspaces"]
MCP["MCP endpoint<br/>POST /mcp/:workspaceId<br/>(JSON-RPC 2.0)"]
BOOT["/bootstrap · /health<br/>(dev convenience)"]
REG["PluginRegistry<br/>(in-memory)"]
end
Pages["projektor-web<br/>(Astro + Preact SPA)"]
end
subgraph Storage["Cloudflare Data"]
D1[("D1 / SQLite<br/>workspaces, users, projects,<br/>issues, comments, wiki, tokens,<br/>activity, revisions")]
KV[("KV<br/>CF certs,<br/>user-by-email cache")]
R2[("R2<br/>file attachments<br/>(upload / download API)")]
end
Plugins["plugins/github<br/>(defined, NOT wired in)"]
AI -->|"Bearer token + X-Workspace-Slug"| MCP
Browser -->|"CF_Authorization cookie"| Access --> REST
Browser --> Pages
CI -->|"release artifact"| Worker
MW --> REST
MW --> MCP
MCP --> REG
REST --> D1
MCP --> D1
MW --> D1
MW --> KV
REST --> R2
REG -.->|"never registered"| Plugins
classDef gap fill:#ffe0e0,stroke:#c00,color:#900;
classDef stub fill:#fff5d6,stroke:#c90,color:#960;
class Plugins gap;
class BOOT stub;
Layer breakdown
Section titled “Layer breakdown”| Layer | Tech | Package | Notes |
|---|---|---|---|
| Frontend | Astro + Preact | apps/web |
Full SPA — issues, board, sprints, wiki, settings, tokens. Dev-proxies /api + /mcp to :8787. |
| API / edge runtime | Hono on Cloudflare Workers | apps/api |
REST + MCP, two-mode auth, workspace tenancy. |
| MCP server | JSON-RPC 2.0 over HTTP | apps/api/src/routes/mcp.ts |
113 tools across 22 domains (coordination + project data). Primary surface. |
| Plugin system | Registry + SDK | apps/api/src/plugins, packages/plugin-sdk |
definePlugin / defineMCPTool. Not loaded at runtime. |
| Data model | Drizzle ORM → D1 | packages/db |
Drizzle is the schema and primary query layer; raw DB.prepare remains in the auth/workspace middleware hot path, the dev bootstrap, and a handful of service queries (FTS, counters) where hand-written SQL is clearer. |
| Shared types | TS | packages/types |
HonoEnv, Plugin, MCPTool, PluginContext. |
| Auth | CF Access JWT (RS256) + API tokens (SHA-256) | middleware/auth.ts |
Plus dev bypass via DEV_USER_EMAIL. |
| Deploy | wrangler + GitHub Actions | projektor-deploy-example |
projektor publishes a release artifact; a config-only deploy repo downloads + ships it (no submodule). |
API surfaces
Section titled “API surfaces”REST (/api/*) is the SPA’s private contract — versionless, and may change shape
between releases without notice. MCP (/mcp/:workspaceId) is the stable public
surface for agents. The two are usually kept at parity (see AGENTS.md’s
service-layer contract), but a small, documented subset of REST endpoints — file
attachments, tokens, public share links, feedback submission — has no MCP
equivalent and is safe to depend on; see REST endpoints.
Platform constraints
Section titled “Platform constraints”A few Cloudflare platform limits are encoded directly in the service layer, not just implied by the runtime:
- D1 rejects any query with more than 100 bound parameters. A query whose parameter
count scales with an input array —
inArray, a rawIN (...), a batched mutation keyed by ids — throws once the array is large enough. This is invisible in tests: the vitest runner backs D1 with SQLite, whose limit is 32766, so an un-chunked query passes CI and only fails against real D1.inChunksinapps/api/src/services/sql.tssplits such queries into chunks of 90 (leaving headroom for the query’s other predicates), and is used wherever a query binds a caller- or row-scaled array, e.g.apps/api/src/services/issue-links.tsandapps/api/src/services/wiki-links.ts. - D1 has no interactive transactions. A read-then-insert can race: two concurrent
claims can each see “one slot free” and both proceed.
claimIssueinapps/api/src/services/issue-leases.ts(PROJ-290) avoids this by folding both atomicity guards — a per-issue uniqueness check and a per-project WIP-cap count — into a single INSERT, relying on SQLite’s write lock rather than a transaction boundary. Multi-statement atomic writes elsewhere usectx.db.batch()instead, e.g.apps/api/src/services/wiki.ts. - D1 limits compound SELECT to a small number of terms.
fetchActivityRowsinapps/api/src/services/project-activity.tsruns one query per event category and merges and sorts the results in JS rather than a single UNION. - A Worker isolate has a 128 MB memory ceiling.
apps/api/src/services/wiki-export.tsbounds wiki export size against it — a hard cap of 500 pages and 100 MB of attachments per export, on top of the existing 50 MB per-file upload cap inapps/api/src/services/files.ts. A streaming zip build is the primary defense against buffering the whole export into memory; these caps are a fast-failing backstop against pathological page counts or attachment totals.
Request flow
Section titled “Request flow”- Agent →
POST /mcp/:workspaceIdwithAuthorization: Bearer pk_…+X-Workspace-Slug. authMiddleware: CF Access JWT → API-token (D1 hash lookup) → dev bypass.workspaceMiddleware: resolve slug → load workspace → verifyworkspace_membersrow.- MCP router dispatches one of three method groups:
initialize— server info and capabilities.tools/list/tools/call→ core tool handler → D1.prompts/list/prompts/get→ a composed playbook directive.prompts/getdelegates tocompose_playbook, so it reads the epic it is given but is otherwise served from the shipped templates, not the database.
Workspace membership is only the outer gate. Which projects a member can see and what they can do inside them is decided per-request in the service layer — see Access control.
Built by Verdient.