
Projektor
Track team issues, boards and a project wiki on your Cloudflare account.
Projektor is a self-hosted Jira/Trello alternative built on Cloudflare (D1, Durable Objects, KV, R2, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.
Source & license
Upstream license: MIT
License TL;DR
You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.
Explain MIT in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Free tier eligible within limits
The documented Projektor deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs.
Hosting requirements
- Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits.
- Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows.
- Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes.
- Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account.
- Use the configured SQLite Durable Object classes within 100,000 requests/day, 13,000 GB-s duration/day, 5 million SQL rows read/day, 100,000 written/day and 5 GB storage; active sockets consume duration.
- Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations.
- Use an eligible Cloudflare Access Free proof-of-concept entitlement and confirm its current user limits; paid Access seats are separate if needed.
Sources checked 01/10/2026
Repository snapshot: 4329b3e. Hosting eligibility reflects the deployment documentation and listed assumptions.
- jira ↗
> **AI-native project management, self-hosted on Cloudflare.**
- trello ↗
> **AI-native project management, self-hosted on Cloudflare.**
- workers ↗
e. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Documents. `global_fetch_strictly_public` stops the outbound CIMD fetch using # legacy same-zone origin routing (SSRF protection); `cache_option_enabled` allows
- d1 ↗
# Cron expressions are UTC; 03:00 UTC is off-peak for all deployed regions so far. [triggers] crons = ["0 3 * * *"] [[d1_databases]] binding = "DB" database_name = "projektor" database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID" migrations_dir = "../../packages/db/migrations" [[kv_namespaces]] binding = "KV" id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" [[kv_namespaces]] # PROJ-656/657: OAuth grants, authorization codes and access/refresh tokens. The # binding name is fixed by the librar
- kv ↗
dflare resource IDs for the deployed # instance live in the private projektor-workspace repo's wrangler.toml - never # commit real account/database/KV IDs here. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Doc
- r2 ↗
e of one cache never silently signs everyone out. binding = "OAUTH_KV" id = "REPLACE_WITH_YOUR_OAUTH_KV_NAMESPACE_ID" [[r2_buckets]] binding = "R2" bucket_name = "projektor-files" # [[durable_objects.bindings]] # name = "WORKSPACE_HUB" # class_name = "WorkspaceHub" # # Always keep this migration, even with the binding above commented out: the class # ships in every build, and wrangler only applies migrations after the last deployed # tag, so adding "v1" later (after "v2") would nev
- durable-objects ↗
r (a D1 write on every request) and logs a warning; # that fallback is removed in a later release (PROJ-924). [[durable_objects.bindings]] name = "RATE_LIMITER" class_name = "RateLimiter" [[migrations]] tag = "v2" new_sqlite_classes = ["RateLimiter"] [vars] ENVIRONMENT = "development" CF_ACCESS_TEAM_DOMAIN = "" CF_ACCESS_AUDIENCE = "" # Login provisioning (services/provisioning.ts). ADMIN_EMAILS should include your # DEV_USER_EMAIL so the first local login auto-creates the default workspa
- free-tier-eligible ↗
e. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Documents. `global_fetch_strictly_public` stops the outbound CIMD fetch using # legacy same-zone origin routing (SSRF protection); `cache_option_enabled` allows
- free-tier-eligible ↗
# Cron expressions are UTC; 03:00 UTC is off-peak for all deployed regions so far. [triggers] crons = ["0 3 * * *"] [[d1_databases]] binding = "DB" database_name = "projektor" database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID" migrations_dir = "../../packages/db/migrations" [[kv_namespaces]] binding = "KV" id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" [[kv_namespaces]] # PROJ-656/657: OAuth grants, authorization codes and access/refresh tokens. The # binding name is fixed by the librar
- free-tier-eligible ↗
dflare resource IDs for the deployed # instance live in the private projektor-workspace repo's wrangler.toml - never # commit real account/database/KV IDs here. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Doc
- free-tier-eligible ↗
up>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 seconds)<br> Max of 15 minutes of CPU time per [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) or [Queue Consumer](https://developers.cloudflare.co
- free-tier-eligible ↗
oudflare.com/workers/platform/pricing/#workers) | | --- | --- | --- | | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows | | Storage (per GB stored) | 5 GB (total) | First 5 GB included + $0.75 / GB-mo | Track your D1 usage To accurately track your usage, use the [meta object](https://developers.cloudflare.com/d1/worker-api/return-object/), [GraphQL Analytics API](https://developers.cloudflare.com/d1/observability/metrics-analytics/#query-via-the-graphql-api), or the [Cloudflare dashboard ↗︎](https://dash.cloudflare.com/?to=/:account/workers/d1/). Select your D1 database, then vie
- free-tier-eligible ↗
cing/). | | Free plan<sup>1</sup> | Paid plan | | --- | --- | --- | | Keys read | 100,000 / day | 10 million/month, + $0.50/million | | Keys written | 1,000 / day | 1 million/month, + $5.00/million | | Keys deleted | 1,000 / day | 1 million/month, + $5.00/million | | List requests | 1,000 / day | 1 million/month, + $5.00/million | | Stored data | 1 GB | 1 GB, + $0.50/ GB-month | <sup>1</sup> The Workers Free plan includes limited Workers KV usage. All limits reset daily at 00:00 UTC. If you exceed any one of these limits, further operations of that type will fail with an error. Note Workers KV pricing for read, write and delete operations is on a per-key basis. Bulk read operations are billed by the amount
- free-tier-eligible ↗
infrequent access storage) for 1.1 GB, you will be billed for 2 GB. ### Free tier You can use the following amount of storage and operations each month for free. | | Free | | --- | --- | | Storage | 10 GB-month / month | | Class A Operations | 1 million requests / month | | Class B Operations | 10 million requests / month | | Egress (data transfer to Internet) | Free <sup>[1](#user-content-fn-1)</sup> | Caution The free tier only applies to Standard storage, and does not apply to Infrequent Access storage. ### Storage usage Storage is billed using gigabyte-month (GB-month) as the billing metric. A GB-month is calculated by averaging the *peak* storage per day over a billing period (30 days). For examp
- free-tier-eligible ↗
jects are available both on Workers Free and Workers Paid plans. - **Workers Free plan**: Only Durable Objects with [SQLite storage backend](https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/#create-sqlite-backed-durable-object-class) are available. - **Workers Paid plan**: Durable Objects with the SQLite storage backend are available. The [key-value storage backend](https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/#storage-backends) is only available to accounts that already have a key-value-backed namespace. If you wish to downgrade from a Workers Paid plan to a Workers Free plan, you must first ensure that you have deleted all Durable Object namespaces with the key-value storage backend. On Workers Free plan: - If you exceed any one of the free tier limits, further operations of that type will fail with an error. - Daily free limits reset at 00:00 UTC. ## Compute billing Durable Objects are billed for compute duration (wall-clock time) while the Durable Object is actively running or is idle in memory but unable to [hibernate](https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/). Durable Objects that are idle and eligible for hibernation are not billed for duration, even before the runtime has hibernated them. Requests to a D
- free-tier-eligible ↗
billed accordingly. | | Free plan | Paid plan | | --- | --- | --- | | Requests | 100,000 / day | 1 million / month, + $0.15/million<br> Includes HTTP requests, RPC sessions<sup>1</sup>, WebSocket messages<sup>2</sup>, and alarm invocations | | Duration<sup>3</sup> | 13,000 GB-s / day | 400,000 GB-s / month, + $12.50/million GB-s<sup>4,5</sup> | <details> <summary> Footnotes </summary> <sup>1</sup> Each <a href="https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/">RPC session</a> is billed as one request to your Durable Object. Every <a href="https://developers.cloudflare.com/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/">RPC method call</a> on a <a href="https://developers.cloudflare.com/durable-objects/">Durable Objects stub</a> is its own RPC session and therefore a single billed request. RPC method calls can return objects (stubs) extending <a href="https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/#lifetimes-memory-and-resource-management"><code>RpcTarget</code></a> and invo
- free-tier-eligible ↗
/). | | Workers Free plan | Workers Paid plan | | --- | --- | --- | | Rows reads <sup>1,2</sup> | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written <sup>1,2,3,4</sup> | 100,000 / day | First 50 million / month included + $1.00 / million rows | | SQL Stored data <sup>5</sup> | 5 GB (total) | 5 GB-month, + $0.20/ GB-month | <details> <summary> Footnotes </summary> <sup>1</sup> Rows read and rows written included limits and rates match <a href="https://developers.cloudflare.com/d1/platform/pricing/">D1 pricing</a>, Cloudflare's serverless SQL database. <sup>2</sup> Key-value methods like <code>get()</code>, <code>put()</code>, <code>delete()</code>, or <code>list(
- free-tier-eligible ↗
nt-blade-headline lh-1_1 headline-2 mb5 mb5-ns mb5-m mb0-l flex-1 f8">Start a proof of concept with our free plan today.</h2></div><div class="enablement-blade-actions flex flex-wrap ml0 ml0-ns ml0-m ml5-l" style="row-gap:20px;column-gap:20px"><a cla
- MIT ↗
MIT License Copyright (c) 2026 Thomas Dickson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONIN
- architecture ↗
e. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Documents. `global_fetch_strictly_public` stops the outbound CIMD fetch using # legacy same-zone origin routing (SSRF protection); `cache_option_enabled` allows
- architecture ↗
# Cron expressions are UTC; 03:00 UTC is off-peak for all deployed regions so far. [triggers] crons = ["0 3 * * *"] [[d1_databases]] binding = "DB" database_name = "projektor" database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID" migrations_dir = "../../packages/db/migrations" [[kv_namespaces]] binding = "KV" id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" [[kv_namespaces]] # PROJ-656/657: OAuth grants, authorization codes and access/refresh tokens. The # binding name is fixed by the librar
- architecture ↗
dflare resource IDs for the deployed # instance live in the private projektor-workspace repo's wrangler.toml - never # commit real account/database/KV IDs here. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Doc
- architecture ↗
e of one cache never silently signs everyone out. binding = "OAUTH_KV" id = "REPLACE_WITH_YOUR_OAUTH_KV_NAMESPACE_ID" [[r2_buckets]] binding = "R2" bucket_name = "projektor-files" # [[durable_objects.bindings]] # name = "WORKSPACE_HUB" # class_name = "WorkspaceHub" # # Always keep this migration, even with the binding above commented out: the class # ships in every build, and wrangler only applies migrations after the last deployed # tag, so adding "v1" later (after "v2") would nev
- architecture ↗
r (a D1 write on every request) and logs a warning; # that fallback is removed in a later release (PROJ-924). [[durable_objects.bindings]] name = "RATE_LIMITER" class_name = "RateLimiter" [[migrations]] tag = "v2" new_sqlite_classes = ["RateLimiter"] [vars] ENVIRONMENT = "development" CF_ACCESS_TEAM_DOMAIN = "" CF_ACCESS_AUDIENCE = "" # Login provisioning (services/provisioning.ts). ADMIN_EMAILS should include your # DEV_USER_EMAIL so the first local login auto-creates the default workspa
Upstream screenshot · TAJD/projektor repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Team issues, boards, sprints and project wiki content; complete commercial administration and marketplace integrations are excluded.
See supporting source ↗Team issues, boards, sprints and project wiki content; complete commercial administration and marketplace integrations are excluded.
See supporting source ↗How it works
The shape of Projektor on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit 4329b3e7febc. Files were read as data; upstream applications and CI jobs were not executed.
Partial source coverage: 62 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.
Deployment configuration · 3 files
Cloudflare Workers · compatibility 2024-09-23
projektor-api · default
Entrypoint: src/index.ts
Cron triggers (UTC): 0 3 * * *
DB→ D1KV→ KVOAUTH_KV→ KVR2→ R2RATE_LIMITER→ Durable Objects · class RateLimiter
Cloudflare Workers · compatibility 2024-09-23
projektor-api-test · default
Entrypoint: src/index.ts
DB→ D1KV→ KVOAUTH_KV→ KVR2→ R2RATE_LIMITER→ Durable Objects · class RateLimiter
Cloudflare Workers · example/template, excluded from overview · compatibility __COMPAT_DATE__
projektor · default
Entrypoint: ./vendor/worker.js
Static assets: ./vendor/web · Worker first: ["/api/*","/mcp/*","/wiki","/.well-known/*"]
Cron triggers (UTC): 0 3 * * *
DB→ D1KV→ KVOAUTH_KV→ KVR2→ R2RATE_LIMITER→ Durable Objects · class RateLimiterASSETS→ Static assets
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L857 · fetch handler exported · calls isOAuthProviderPath, tokenEndpointRateLimited, oauthProvider.fetch, isOAuthAccessToken, app.fetch
- L869 · scheduled handler exported · calls ctx.waitUntil, purgeAllWorkspacesExpiredWikiPages, purgeExpiredOAuthData, purgeExpiredRetentionData, runFtsDedupeOnce
- L88 · app.route("/api/feedback")
- L90 · app.use("*")
- L96 · app.use("*")
- L110 · app.get("/health")
- L113 · app.get("/api/health")
- L114 · app.route("/api/config")
- L119 · app.use("/api/*")
- L120 · app.use("/mcp/*")
- L124 · app.use("/.well-known/*")
- L125 · app.route("/.well-known")
- L130 · app.use("/oauth/*")
- L131 · app.use("/oauth/*")
- L132 · app.route("/oauth")
- L136 · app.get("/bootstrap")
- L224 · app.route("/api/share")
- L227 · app.route("/auth")
- L230 · app.get("/api/workspaces")
- L235 · app.post("/api/workspaces")
- L248 · app.use("/api/workspaces/:slug/*")
- L250 · app.get("/api/projects")
- L260 · app.use("/api/projects/*")
- L261 · app.use("/api/issues/*")
- L262 · app.use("/api/issue-links/*")
- L263 · app.use("/api/wiki/*")
- L264 · app.use("/mcp/*")
- L274 · app.use("/api/files/*")
- L275 · app.use("/api/files/*")
- L276 · app.use("/api/task-types/*")
- L277 · app.use("/api/task-statuses/*")
- L278 · app.use("/api/custom-fields/*")
- L279 · app.use("/api/sprints/*")
- L280 · app.use("/api/agents/*")
- L281 · app.use("/api/file-claims/*")
- L282 · app.use("/api/issue-leases/*")
- L283 · app.use("/api/agent-messages/*")
- L284 · app.use("/api/feedback-sources/*")
- L286 · app.use("/api/workflow/*")
- L287 · app.use("/api/playbooks/*")
Environment references: c.env.ENVIRONMENT · c.env.BOOTSTRAP_SECRET · c.env.DEV_USER_EMAIL · c.env.DB · c.env.ASSETS · env.KV · env.DB · env.WIKI_NOTIFICATION_RETENTION_DAYS · env.AGENT_SESSION_RETENTION_DAYS · env.ACTIVITY_RETENTION_DAYS · env.R2
- L14 · jsonBody calls (conditional paths may differ): c.req.json
- L12 · serviceErrToResponse calls (conditional paths may differ): c.json
- L1 · slugifyForUrl calls (conditional paths may differ): replace, text.toLowerCase
- L13 · issuePath calls (conditional paths may differ): slugifyForUrl
- L21 · wikiPagePath calls (conditional paths may differ): encodeURIComponent
- L29 · safeDecodeURIComponent calls (conditional paths may differ): decodeURIComponent
- L18 · resolveWikiWorkspaceContext calls (conditional paths may differ): c.req.header, subdomainRoutingEnabled, c.get, first, bind, c.env.DB.prepare
- L54 · resolveWikiPageForSsr calls (conditional paths may differ): authMiddleware, resolveWikiWorkspaceContext, c.set, getWikiPage, ctxFromHono
- L79 · plainTextExcerpt calls (conditional paths may differ): trim, replace, markdown.replace, plain.slice
- L111 · injectWikiMetadata calls (conditional paths may differ): plainTextExcerpt, transform, on
Environment references: c.env.WORKSPACE_SUBDOMAIN_ROUTING · c.env.DEFAULT_WORKSPACE_SLUG · c.env.DB
- L35 · requireInteractiveHuman calls (conditional paths may differ): c.get, isPublicViewer, c.json
- L44 · isTruthy calls (conditional paths may differ): includes
- L53 · tooManyAuthFailures calls (conditional paths may differ): c.req.header, parseInt, bumpRateCounter, console.error, String
- L101 · oauthGrantProps calls (conditional paths may differ): Array.isArray
- L109 · tryOAuthGrantAuth calls (conditional paths may differ): oauthGrantProps, filter, props.scopes.map, checkTokenScope, c.set, oauthGrantIdFromRequest
- L146 · oauthGrantIdFromRequest calls (conditional paths may differ): c.req.header, header.startsWith, split, header.slice
- L153 · tryCfAccessAuth calls (conditional paths may differ): c.req.header, parseCookie, validateCfAccessJwt, c.json, ensureUserProvisioned, c.set
- L178 · tooManyAuthFailuresResponse calls (conditional paths may differ): tooManyAuthFailures, c.json
- L191 · checkTokenScope calls (conditional paths may differ): c.req.path.startsWith, capabilityForMethod, tokenAllows, c.json
- L209 · authenticateApiToken calls (conditional paths may differ): hashToken, first, bind, c.env.DB.prepare, tooManyAuthFailuresResponse, Date.now, parseScopes, checkTokenScope, c.set, token.startsWith, Math.floor, c.executionCtx.waitUntil, run
- L265 · tryBearerTokenAuth calls (conditional paths may differ): c.req.header, authHeader.slice, authenticateApiToken
- L274 · tryDevBypassAuth calls (conditional paths may differ): mcpWorkspaceIdFromPath, upsertUserByEmail, ensureUserProvisioned, c.set
- L296 · tryPublicViewerAuth calls (conditional paths may differ): isTruthy, upsertUserByEmail, provisionPublicViewer, c.set
- L316 · withMcpAuthChallenge calls (conditional paths may differ): mcpWorkspaceIdFromPath, response.headers.set, unauthorizedChallenge
- L324 · authMiddleware calls (conditional paths may differ): attempt, withMcpAuthChallenge, next, c.json
- L366 · decodeJwtFields calls (conditional paths may differ): JwtHeaderSchema.safeParse, JSON.parse, base64urlDecode, JwtPayloadSchema.safeParse
- L380 · jwtClaimsValid calls (conditional paths may differ): Math.floor, Date.now, Array.isArray, aud.includes
- L398 · verifySignatureAgainstKeys calls (conditional paths may differ): encode, base64urlToUint8Array, crypto.subtle.importKey, crypto.subtle.verify
- L421 · verifyJwtPayload calls (conditional paths may differ): jwt.split, decodeJwtFields, jwtClaimsValid, verifySignatureAgainstKeys
- L442 · preScreenCfAccessJwt calls (conditional paths may differ): decodeJwtFields, jwtClaimsValid
- L453 · validateCfAccessJwt calls (conditional paths may differ): jwt.split, preScreenCfAccessJwt, getCfAccessKeysOrUnavailable, verifyJwtPayload, upsertUserByEmail
- L499 · fetchAndCacheCfAccessKeys calls (conditional paths may differ): fetch, res.json, env.KV.put, JSON.stringify, console.error, Date.now
- L515 · getCfAccessKeysOrUnavailable calls (conditional paths may differ): getCfAccessKeys
- L526 · getCfAccessKeys calls (conditional paths may differ): Date.now, fetchAndCacheCfAccessKeys, env.KV.get, console.error
- L565 · resetAuthCachesForTests calls (conditional paths may differ): inMemoryUserCache.clear
- L570 · upsertUserByEmail calls (conditional paths may differ): inMemoryUserCache.get, Date.now, kv.get, console.error, inMemoryUserCache.set, crypto.randomUUID, email.split, Math.floor, run, bind, db.prepare, first, kv.put, JSON.stringify
- L627 · parseCookie calls (conditional paths may differ): cookieHeader.split, split, part.trim, k.trim, trim, rest.join
- L635 · base64urlDecode calls (conditional paths may differ): replace, s.replace, slice, atob
- L640 · base64urlToUint8Array calls (conditional paths may differ): base64urlDecode, Uint8Array.from, c.charCodeAt
- L645 · hashToken calls (conditional paths may differ): crypto.subtle.digest, encode, join, map, Array.from, padStart, b.toString
Environment references: c.env.RATE_LIMIT_WINDOW_SECS · c.env.RATE_LIMIT_AUTH_FAIL_MAX · c.env.DB · c.env.ENVIRONMENT · c.env.DEV_USER_EMAIL · c.env.PUBLIC_READ_ONLY · c.env.KV · env.CF_ACCESS_AUDIENCE · env.CF_ACCESS_TEAM_DOMAIN · env.DB · env.KV
- L22 · etagMiddleware calls (conditional paths may differ): next, arrayBuffer, c.res.clone, sha256Prefix, ifNoneMatchMatches, c.req.header, c.res.headers.set
- L45 · ifNoneMatchMatches calls (conditional paths may differ): header.trim, replace, t.trim, some, header.split, stripWeak
- L52 · sha256Prefix calls (conditional paths may differ): crypto.subtle.digest, join, map, Array.from, slice, padStart, b.toString
- L21 · rateLimitMiddleware calls (conditional paths may differ): parseInt, Number, Number.isFinite, Date.now, Math.floor, c.req.header, authHeader.slice, oauthGrantKey, sha256Prefix, incrementCounter, console.error, String, next, c.header, c.json
- L78 · oauthGrantKey calls (conditional paths may differ): token.split
- L89 · bumpRateCounter calls (conditional paths may differ): incrementCounter, Math.floor, Date.now
- L94 · sha256Prefix calls (conditional paths may differ): crypto.subtle.digest, encode, join, map, Array.from, slice, padStart, b.toString
- L109 · pruneStaleRateLimitRows calls (conditional paths may differ): Math.floor, Date.now, bind, db.prepare
- L129 · incrementCounter calls (conditional paths may differ): ns.get, ns.idFromName, rateLimiterObjectName, setTimeout, reject, Promise.race, stub.increment, clearTimeout, console.warn, Math.floor, incrementD1Counter
- L159 · incrementD1Counter calls (conditional paths may differ): bind, db.prepare, Math.random, statements.push, pruneStaleRateLimitRows, db.batch
Environment references: c.env.RATE_LIMIT_WINDOW_SECS · c.env.ENVIRONMENT · c.env.RATE_LIMIT_TEST_NOW_MS · c.env.RATE_LIMIT_API_MAX · c.env.RATE_LIMIT_AUTH_MAX · env.ENVIRONMENT · env.RATE_LIMIT_TEST_EPOCH · env.RATE_LIMITER · env.DB
- L6 · subdomainRoutingEnabled calls (conditional paths may differ): includes
- L13 · subdomainCandidate calls (conditional paths may differ): host.split, test, hostname.split
- L25 · warnIfIgnoredSubdomain calls (conditional paths may differ): subdomainCandidate, c.req.header, first, bind, c.env.DB.prepare, console.warn
- L50 · mcpWorkspaceIdFromPath calls (conditional paths may differ): exec
- L66 · missingWorkspaceResponse calls (conditional paths may differ): warnIfIgnoredSubdomain, c.json
- L96 · resolveWorkspaceTarget calls (conditional paths may differ): c.get, c.req.query, c.req.header, mcpWorkspaceIdFromPath
- L112 · workspaceMiddleware calls (conditional paths may differ): c.req.header, subdomainRoutingEnabled, resolveWorkspaceTarget, missingWorkspaceResponse, c.get, first, bind, c.env.DB.prepare, c.json, tokenConfinedToOtherWorkspace, c.set, next
Environment references: c.env.DB · c.env.WORKSPACE_SUBDOMAIN_ROUTING
- L30 · isOAuthAccessToken calls (conditional paths may differ): request.headers.get, split, header.slice
- L69 · createOAuthProvider calls (conditional paths may differ): providerOptions
- L85 · oauthApi calls (conditional paths may differ): getOAuthApi, providerOptions
- L100 · tokenEndpointRateLimited calls (conditional paths may differ): request.headers.get, parseInt, bumpRateCounter, console.error, String, Response.json
Environment references: env.RATE_LIMIT_WINDOW_SECS · env.RATE_LIMIT_AUTH_MAX
- L14 · router.get("/login")
- L20 · router.get("/me")
- L37 · router.post("/tokens")
- L49 · router.delete("/tokens/:id")
- L10 · isSafeRedirectPath calls (conditional paths may differ): url.startsWith
Environment references: c.env.DB
- L9 · router.get("/:id/code-heatmap")
- L15 · router.get("/brand")
- L10 · deriveBrandMark calls (conditional paths may differ): name.trim, toUpperCase, firstChar
Environment references: c.env.BRAND_NAME · c.env.BRAND_MARK · c.env.BRAND_ACCENT · c.env.BRAND_ON_ACCENT · c.env.BRAND_LOGO_URL
- L26 · publicRouter.use("*")
- L91 · publicRouter.post("/submit")
- L115 · authedRouter.get("/:id/feedback")
- L127 · authedRouter.get("/:id/feedback/summary")
- L137 · authedRouter.patch("/:id/feedback/:feedbackId")
- L149 · authedRouter.post("/:id/feedback/bulk-mark-reviewed")
- L160 · authedRouter.post("/:id/feedback/bulk-convert-to-issue")
- L171 · authedRouter.post("/:id/feedback/:feedbackId/convert-to-issue")
- L42 · checkFeedbackRateLimit calls (conditional paths may differ): parseInt, c.req.header, hashFeedbackToken, Promise.all, bumpRateCounter, console.error, String, c.json
- L73 · parseFeedbackBody calls (conditional paths may differ): jsonBody
- L81 · submitFeedbackErrorResponse calls (conditional paths may differ): c.json
Environment references: c.env.RATE_LIMIT_WINDOW_SECS · c.env.RATE_LIMIT_FEEDBACK_MAX · c.env.RATE_LIMIT_FEEDBACK_IP_MAX · c.env.DB
- L14 · router.get("/")
- L27 · router.post("/links")
- L62 · router.post("/")
- L110 · router.get("/:id/metadata")
- L119 · router.get("/:id")
- L155 · router.delete("/:id")
- L38 · parseUploadFormData calls (conditional paths may differ): c.req.formData, c.json
- L46 · extractUploadInput calls (conditional paths may differ): formData.get, c.json, EntityTypeEnum.safeParse
Environment references: c.env.R2
- L9 · router.get("/:id/flow-metrics")
- L21 · router.get("/:slug/groups")
- L30 · router.post("/:slug/groups")
- L41 · router.get("/:slug/member-groups")
- L50 · router.get("/:slug/groups/:groupId")
- L59 · router.patch("/:slug/groups/:groupId")
- L68 · router.delete("/:slug/groups/:groupId")
- L77 · router.post("/:slug/groups/:groupId/members")
- L86 · router.delete("/:slug/groups/:groupId/members/:userId")
- L95 · router.put("/:slug/groups/:groupId/grants")
- L104 · router.delete("/:slug/groups/:groupId/grants/:projectId")
- L12 · router.get("/")
Build and deployment pipeline · 5 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: pull_request, workflow_call
Test & type-check · no job dependencies declared
- actions/checkout@v4
actions/checkout@v4 - pnpm/action-setup@v4
pnpm/action-setup@v4 - actions/setup-node@v4
actions/setup-node@v4 - Shell command
pnpm install --frozen-lockfile - Generated docs are fresh
pnpm gen:docs git diff --exit-code -- apps/docs/src/content/docs/agents/tool-catalog.md \ apps/docs/src/content/docs/agents/workflow-spec.md \ apps/docs/src/generated/mcp-stats.json README.md \ apps/docs/src/content/docs/contributing/conventions.md \ apps/docs/src/content/docs/guides/feedback-widget-integration.md \ || { echo "::error::Generated docs are stale. Run 'pnpm gen:docs' and commit the result."; exit 1; } # A directory, not a fixed file list (PLAYBOOKS can gain/lose entries), so # `gi… - Shell command
pnpm lint - Shell command
pnpm turbo type-check - Shell command
pnpm --filter @projektor/db test - Shell command
pnpm --filter @projektor/api test:coverage - Shell command
pnpm --filter @projektor/web test:coverage - Shell command
pnpm --filter @projektor/web build - Bundle size budget (PROJ-841)
pnpm bundle-budget - Shell command
pnpm --filter @projektor/docs build
Triggers: push, workflow_dispatch
Build site · no job dependencies declared
- actions/checkout@v4
actions/checkout@v4 - pnpm/action-setup@v4
pnpm/action-setup@v4 - actions/setup-node@v4
actions/setup-node@v4 - Shell command
pnpm install --frozen-lockfile - Shell command
pnpm gen:docs - Shell command
pnpm --filter @projektor/docs build - Shell command
node scripts/submit-indexnow.mjs - actions/upload-pages-artifact@v3
actions/upload-pages-artifact@v3
Deploy to Pages · after build
- actions/deploy-pages@v4
actions/deploy-pages@v4
Triggers: workflow_dispatch
Bump version and open PR · no job dependencies declared
- actions/checkout@v4
actions/checkout@v4 - Bump version
NEW_VERSION=$(npm version "${{ inputs.bump }}" --no-git-tag-version | sed 's/^v//') echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - Open release PR
VERSION="${{ steps.bump.outputs.version }}" BRANCH="chore/release-v$VERSION" git checkout -b "$BRANCH" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add apps/web/package.json git commit -m "chore(web): bump version to $VERSION for release" git push -u origin "$BRANCH" gh pr create \ --title "chore(web): bump version to $VERSION for release" \ --body "Automated version bump ahead of cutting v$VERSION. Merging this will trigger…
Triggers: pull_request
Create and push release tag · no job dependencies declared
Condition: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
- actions/checkout@v4
actions/checkout@v4 - Determine version
VERSION=$(node -p "require('./apps/web/package.json').version") echo "version=$VERSION" >> "$GITHUB_OUTPUT" - Tag and push
TAG="v${{ steps.version.outputs.version }}" if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then echo "::error::Tag $TAG already exists — skipping to avoid re-triggering release.yml." exit 1 fi git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag "$TAG" git push origin "$TAG"
Triggers: push, workflow_dispatch
CI checks · no job dependencies declared
Uses: ./.github/workflows/ci.yml
Build & publish release artifact · after ci
- actions/checkout@v4
actions/checkout@v4 - pnpm/action-setup@v4
pnpm/action-setup@v4 - actions/setup-node@v4
actions/setup-node@v4 - Shell command
pnpm install --frozen-lockfile - Build release artifact
bash scripts/build-release.sh "$VERSION" - Create GitHub Release
# A hyphen in the version means a pre-release (v1.2.0-rc.1). Marking it # as such keeps it out of "latest", so a deploy repo pinned to latest # never picks up a release candidate by accident. PRERELEASE="" case "$VERSION" in *-*) PRERELEASE="--prerelease" ;; esac gh release create "$VERSION" "dist/projektor-$VERSION.tar.gz" \ --title "$VERSION" \ --generate-notes $PRERELEASE - Notify deploy repo
gh api "repos/${{ vars.DEPLOY_DISPATCH_REPO }}/dispatches" \ -f event_type=projektor-release \ -F "client_payload[version]=$VERSION"Condition: vars.DEPLOY_DISPATCH_REPO != ''
build: turbo builddeploy: turbo deploy
build: wrangler deploy --dry-run --outdir distdeploy: wrangler deploy
build: astro build && node scripts/gen-llms-txt.mjs
build: astro build && node scripts/assert-sw.mjs && node scripts/assert-eager-chunks.mjs && node scripts/gen-csp-headers.mjs
Repository README
View original on GitHub ↗Full upstream document by @TAJD · README.md · snapshot 4329b3e
projektor
AI-native project management, self-hosted on Cloudflare.
Docs · Live demo · Deploy your own

What it is
Projektor is an issue tracker and wiki that an AI coding agent runs as well as you do. It holds issues, boards, sprints and a wiki, and it exposes
122 tools across 22 domains over MCP,so the agent files the ticket, moves it and writes the page instead of asking you to. The whole thing is one Cloudflare Worker in your own account.
Connect it to Claude
Add your instance as a connector in Claude — web, desktop, mobile, or Claude Code — and sign in with the account you already use for the web UI. There is no token to copy into a config file.
Projektor is an OAuth 2.1 authorization server: discovery over RFC 9728 / RFC 8414,
client identity via CIMD rather than dynamic registration, PKCE throughout, and an
RFC 8707 resource parameter that binds every grant to exactly one workspace — so a
token minted for one workspace is not a credential for another. Each authorization is
personal, expires on its own, and appears in your settings with a Disconnect beside
it.
A grant never exceeds what you can already do. Your live workspace role is checked on every call, so the connector inherits your ceiling rather than the scopes it asked for. See connecting an agent for the flow and deploying for the Cloudflare Access carve-outs it needs.
One work graph, not a tracker plus a sidecar
Running a fleet of agents normally means assembling three things: a tracker for the work, a coordination layer so two agents do not edit the same file, and worktree tooling to keep their checkouts apart. That leaves two or three sources of truth about what is being worked on, and nothing that can answer a question spanning them.
In Projektor the coordination state is the work graph. The lease is on the issue and the claim is on the file, in one schema behind one auth boundary:
- Issue leases — an agent takes a work-item lease before starting, and a
per-project cap (
agent_wip_limit, default 3) bounds how many issues the fleet can hold at once. That is admission control on the backlog, not a rate limit: it decides how much work is allowed to be in flight. - File claims — path-level claims stop two agents editing the same code. A refused claim is rejected whole and names the issue and agent already holding the path, so the blocked agent knows who to talk to, and every contended path is recorded.
- Liveness — both tiers derive from the holder's heartbeat, so a lease or claim whose
agent stopped reporting is reclaimed by the next claim in the same call. An agent that
crashes mid-ticket does not deadlock the backlog, or the files, behind it. Reclaiming a
dead holder is not logged as contention — the
coordination model
covers why, and the one case that still needs a manual
force.
And because it is deployed rather than local, the fleet is not limited to one machine. A coordination store on somebody's laptop can only be consulted by processes on that laptop: a CI runner cannot take a lease, an agent on a second machine cannot see the first one's claims, and a hosted agent cannot participate. Those participants are excluded by construction, not by throughput — and exposing a localhost server to fix it means taking on TLS, auth and uptime, which is the deployment problem you were avoiding. Projektor's ceiling is a property of the deployment — one that can be raised without changing how anything works — rather than a property of a laptop that also happens to be running the agents.
Because it is one graph, "which issue is this claim for" is a join, not an integration. The coordination model documents the design. How Projektor differs compares it against beads, MCP Agent Mail, Linear and the worktree tools, and is honest about what each of those does better.
Contention is data
When a claim collides, most systems return an error and forget it. Projektor writes the contended path to an event log — whether the claim was refused or forced through — and ranks it, so the code heatmap has two modes: where the fleet is working, and where the fleet is colliding.
That closes a loop back into the backlog. A directory that shows up hot in contention week after week is telling you something about how the work is sliced — two tickets that keep fighting over the same module probably wanted to be one ticket, or the module wanted splitting. Refused claims are evidence about your plan, not just failures.
Both surfaces, checked mechanically
REST and MCP are two doors into one service layer:
REST /api/* ─┐
├─► services/<domain>.ts ─► D1 (SQLite)
MCP /mcp/:wsId ─┘
Routes and MCP tools are thin wrappers; the business logic and the SQL live in
services/. An agent is not driving a reduced API built for robots, it is calling the
code the browser calls.
That used to be a convention held up by review. It is now a test: mcp-parity.node.test.ts
cross-references every exported service function against the REST routes, the runtime MCP
registry and the documented catalog, and fails the build on drift — including a catalog
reordered without the dispatch table. Operations deliberately on one surface only are
enumerated with a reason each, and the test fails if that list goes stale, so the
exceptions stay few and visible rather than accumulating quietly.
Every surface described here was used to build Projektor: the epics, the tickets, the coordination primitives and the wiki pages all carried their own development. The tool catalog is a discovered surface, not a designed one.
You still run the project
Agents do the filing. You do the steering, and you do it in a normal web app: backlog and kanban board, epics, sprint planning, a nested wiki with full-text search and revision history, flow metrics, and a feedback widget that turns user reports into issues. Nothing an agent does is buried in a log - it lands on the board, where you can read it, argue with it and move it.
What is in it
- Issues - status, priority, assignee, labels, parent/child hierarchy and
cross-issue links. Referenced as
PROJ-42. - Boards and sprints - kanban board, list view, sprint planning, flow metrics.
- Wiki - nested markdown pages, full-text search, revision history.
- MCP server - the primary surface, not an add-on. Any MCP agent connects; Claude
Code does it with
claude mcp add. - Fleet coordination - agent registry, issue leases with a per-project WIP cap, file claims, agent messages, and a contention heatmap over every contended path.
- Ops - file attachments, workspaces, projects and members with roles, scoped API tokens, share links, installable PWA.
Serverless on your own Cloudflare account: Hono on Workers, D1 for data, KV for cache, R2 for attachments. No servers, no containers.
Deploy it
Projektor installs into your Cloudflare account from a small config-only repo that downloads a pre-built release. There is no source checkout and no build step. The one-click Deploy to Cloudflare button in projektor-deploy-example provisions D1, KV and R2 for you and deploys. A script and a CI flow do the same job; see the deploy guide.
Put Cloudflare Access in front of the Worker before anyone logs in, then mint a token for your agents. CONFIGURE.md covers both.
Connect an agent
Projektor serves a JSON-RPC 2.0 MCP endpoint at POST /mcp/<workspaceId>. Open
Settings → Tokens, create a token, and copy the claude mcp add command shown
beside it with the workspace and token filled in. The
agent connection guide has
the protocol reference and the full tool catalog.
Where to go next
| Getting started | first workspace, projects, issues |
| Self-hosting and deploying | Cloudflare setup, API token scopes, updates |
| MCP connection and tool catalog | wiring an agent up, every tool it gets |
| Agentic workflows | how agents are meant to use the tracker |
| Coordination model | leases, claims, liveness and contention as a designed system |
| How Projektor differs | compared against beads, MCP Agent Mail, Hiveship, Linear |
| System design | the two surfaces and the service layer beneath them |
| AGENTS.md | contributor guide: conventions, file layout, the service-layer contract |
Development
pnpm install
cp apps/api/.dev.vars.example apps/api/.dev.vars # set DEV_USER_EMAIL + BOOTSTRAP_SECRET
cp apps/web/.env.example apps/web/.env # set PUBLIC_WORKSPACE_SLUG=projektor
pnpm dev # API on :8787, web on :4321
pnpm dev applies D1 migrations to the local Miniflare database first, so a fresh
checkout will not 500 with "no such table". Seed a workspace with
curl -H "X-Bootstrap-Secret: localdev" http://127.0.0.1:8787/bootstrap, then open
http://localhost:4321. With DEV_USER_EMAIL set, the dev-auth bypass logs you in.
pnpm --filter @projektor/api test # vitest against an in-process Worker + Miniflare D1
pnpm turbo type-check # tsc --noEmit across the monorepo
Both must be green before you open a PR. pnpm install also wires the lefthook
pre-commit and pre-push hooks. CI runs more; AGENTS.md lists it, along
with the engineering conventions.
Contributing
Projektor is built with itself - the live dogfood instance tracks its own bugs and feature requests. CONTRIBUTING.md explains how issues and PRs are handled, SECURITY.md how to report a vulnerability, and AGENTS.md what to read before opening a PR. Licensed under MIT.
Frequently asked about Projektor
What is Projektor?+
Projektor is a self-hosted Jira/Trello alternative built on the Cloudflare developer platform. Track team issues, boards and a project wiki on your Cloudflare account.
What does Projektor replace?+
Projektor is listed as an alternative to Jira, Trello. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does Projektor use?+
Projektor is built on D1, Durable Objects, KV, R2, Workers.
How much does Projektor cost to run?+
The documented Projektor deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs. Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits. Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows. Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes. Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account. Use the configured SQLite Durable Object classes within 100,000 requests/day, 13,000 GB-s duration/day, 5 million SQL rows read/day, 100,000 written/day and 5 GB storage; active sockets consume duration. Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations. Use an eligible Cloudflare Access Free proof-of-concept entitlement and confirm its current user limits; paid Access seats are separate if needed. Check current Cloudflare pricing before deploying.
Is Projektor open source?+
The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/TAJD/projektor/4329b3e7febc0a9a500d0a1bd2a820a4d6852218/LICENSE. Source code and contributor credit are available at https://github.com/TAJD/projektor.


Discussion · 0
sign in to comment →