Skip to content

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.

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.

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 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).

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.

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 raw IN (...), 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. inChunks in apps/api/src/services/sql.ts splits 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.ts and apps/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. claimIssue in apps/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 use ctx.db.batch() instead, e.g. apps/api/src/services/wiki.ts.
  • D1 limits compound SELECT to a small number of terms. fetchActivityRows in apps/api/src/services/project-activity.ts runs 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.ts bounds 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 in apps/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.
  1. AgentPOST /mcp/:workspaceId with Authorization: Bearer pk_… + X-Workspace-Slug.
  2. authMiddleware: CF Access JWT → API-token (D1 hash lookup) → dev bypass.
  3. workspaceMiddleware: resolve slug → load workspace → verify workspace_members row.
  4. 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/get delegates to compose_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.