Formats and API
P99 keeps three contracts: the config file every command reads, the bundle p99 import reads, and the Worker’s HTTP API, whose JSON objects are also what every --json output prints.
Configuration
Section titled “Configuration”p99 setup writes ~/.config/p99/config.toml ($XDG_CONFIG_HOME/p99/config.toml when that is set, or the file P99_CONFIG names) with mode 0600, and every command reads it.
default_account = "main"
[me]name = "Ada Lovelace"email = "ada@example.com"
[accounts.main]account_id = "0123456789abcdef0123456789abcdef"token = "<the Cloudflare API token, or a reference to it>"worker = "p99"database = "p99"database_id = "00000000-0000-0000-0000-000000000000"bucket = "p99"key = "<the key setup made, or a reference to it>"
[hosts."example.com"]account = "main"env = "production"
[hosts."dev.example.com"]account = "main"env = "dev"theme = { accent = "#ffc42e" }strings = { file = "Send" }One person on two accounts, under a different e-mail on the second:
default_account = "main"
[me]name = "Ada Lovelace"email = "ada@example.com"
[accounts.main]account_id = "0123456789abcdef0123456789abcdef"token = "<the token, or a reference to it>"key = "<the key, or a reference to it>"
[accounts.client]account_id = "fedcba9876543210fedcba9876543210"token = "<the token, or a reference to it>"key = "<the key, or a reference to it>"
[accounts.client.me]name = "Ada"email = "ada@example.org"
[hosts."example.com"]account = "main"
[hosts."example.org"]account = "client"Commands on example.com act as Ada Lovelace ada@example.com, and on example.org as Ada ada@example.org. An agent on another machine that acts as someone else sets its own [me], or agent = true in [accounts.<name>.me] to mark only that account’s commands.
- Secrets (
token,key,resend_key,access_client_secret) are written inline, or as a reference P99 reads through the 1Password CLI when it is installed (op://vault/item/field). An inline secret makes P99 refuse a config file anyone else can read. [me]is who the CLI and the TUI act as, on every account that doesn’t say otherwise. An agent acting as that person marks its items, comments and changes, which then read asAda Lovelace*in every view and in mail:--agentmarks one command,P99_AGENT=1(any value but empty or0) the commands of a shell, andagent = truehere every command;P99_AGENT=0turns the config’s mark off. Only signed requests carry the mark; the browser never does.- An account is one Cloudflare account with its Worker, D1 database and R2 bucket. The CLI reaches the Worker at
https://<host>/_p99of one of the account’s hosts: the host a command names first, then the hosts whose environment isproduction, then the rest. A host that answers with Cloudflare Access’s login, or redirects elsewhere, is skipped for the next, and the one that answered is used from then on; the signed API names the host it means with?host=, so any of them serves every host.urlreplaces that list with one address.resend_keyandmail_fromturn mail on. When every host of the account sits behind Cloudflare Access, either seturlto an address of the Worker that is not behind Access, or setaccess_client_idandaccess_client_secretto an Access service token’s pair: every request of the CLI, the TUI andp99 announcethen carries them asCF-Access-Client-IdandCF-Access-Client-Secret. Set both or neither. P99’s own signature still guards every request. [accounts.<name>.me](name,email,agent, each optional) is who P99 acts as on that account, field by field over[me]: its commands, the TUI’s view of it (“assign to me” and the people list),p99 announce,p99 import, and the managerp99 setupnames.p99 setup --account <name> --name … --email …writes it when[me]already holds someone else, and never changes[me].- A host belongs to one account and has one environment;
theme(any--p99-*custom property without its prefix) andstrings(any of the panel’s English words, by key) are pushed to the Worker withp99 hosts.
Importing items
Section titled “Importing items”p99 import brings items from another tracker into P99 and keeps their keys: BUG-3 stays BUG-3, with its comments, files, history, landings and who has read it. Text stays UTF-8 from end to end, so accents and diacritics arrive as they left.
p99 import ./bundle --host example.com --dry-runp99 import ./bundle --host example.com--host names a host P99 already serves (p99 hosts add first); every imported item belongs to it. --json prints the summary as JSON and sends the progress lines to standard error. Imports sign with the account’s own key, so they run from the CLI only, and the daily limits never apply to them.
The bundle
Section titled “The bundle”A bundle is a directory with a bundle.jsonl and the files its attachments name beside it:
bundle/ bundle.jsonl files/receipt.pngbundle.jsonl holds one JSON object per line, "type" first. Blank lines are skipped. Times are ISO 8601 (2025-03-01T10:00:00Z); the Worker stores them in UTC.
| Type | Fields |
|---|---|
item |
key (BUG-3; the number is taken from it, the prefix must match the kind), kind, title, body, status, duplicate_of (a key or null), reporter {email, name, agent}, assignee (an e-mail or null), env (null: the host’s environment), page {url, path, title} (null: the host’s front page), context {}, marks [], created_at, updated_at, closed_at |
comment |
item (a key), uid (a stable id of your own), author {email, name, agent, manager}, body, internal, created_at |
attachment |
item, uid, comment (a comment’s uid, or null), role (file, screenshot, page or region), mark (a region’s mark index), name (null: the file’s name), content_type, file (a path inside the bundle directory), internal, created_at |
event |
item, actor {email, agent} (or null for the system), kind, detail {}, internal, at |
landing |
item, env, sha, at |
read |
item, email, at |
manager |
email |
The limits are P99’s own: a title of 1 to 200 characters, a body of up to 20 000, files of up to 20 MB and 30 per item, and a file’s first bytes must match its content_type (the same types the panel takes). An item that is not open gets closed_at from updated_at when the bundle leaves it out.
A manager line imports nothing. P99’s managers are people with a manager code, or e-mails in the Worker’s MANAGERS secret; the summary lists every manager in the bundle who has neither yet, with the p99 invite --manager line to mint one.
A landing records when an item first reached an environment, so P99 never announces that landing again. The item shows the environment at once.
A complete example
Section titled “A complete example”bundle/bundle.jsonl:
{"type":"item","key":"BUG-7","kind":"bug","title":"The café menu shows the wrong price","body":"The crème brûlée costs 12,50 € on the menu and 13 € at checkout.","status":"resolved","duplicate_of":null,"reporter":{"email":"ada@example.com","name":"Ada Lovelace","agent":false},"assignee":"grace@example.com","env":"production","page":{"url":"https://example.com/menu","path":"/menu","title":"Menu"},"context":{},"marks":[],"created_at":"2025-03-01T10:00:00Z","updated_at":"2025-03-04T08:00:00Z","closed_at":"2025-03-04T08:00:00Z"}{"type":"comment","item":"BUG-7","uid":"old-comment-41","author":{"email":"grace@example.com","name":"Grace Hopper","agent":false,"manager":true},"body":"Confirmed, the menu reads an old price list.","internal":false,"created_at":"2025-03-02T09:00:00Z"}{"type":"attachment","item":"BUG-7","uid":"old-file-12","comment":"old-comment-41","role":"file","mark":null,"name":"receipt.png","content_type":"image/png","file":"files/receipt.png","internal":false,"created_at":"2025-03-02T09:00:00Z"}{"type":"event","item":"BUG-7","actor":{"email":"grace@example.com","agent":false},"kind":"status","detail":{"from":"confirmed","to":"resolved"},"internal":false,"at":"2025-03-04T08:00:00Z"}{"type":"landing","item":"BUG-7","env":"production","sha":"4f1c2a9e0b7d","at":"2025-03-04T07:30:00Z"}{"type":"read","item":"BUG-7","email":"ada@example.com","at":"2025-03-04T09:00:00Z"}{"type":"manager","email":"grace@example.com"}$ p99 import ./bundle --host example.comitem: 1 of 1 sentcomment: 1 of 1 sentattachment: 1 of 1 sentevent: 1 of 1 sentlanding: 1 of 1 sentread: 1 of 1 sentmanager: 1 of 1 sentexample.com: 7 records imported type new existing conflicts item 1 0 0 comment 1 0 0 attachment 1 0 0 event 1 0 0 landing 1 0 0 read 1 0 0 manager 1 0 0 files uploaded: 1 items recounted: 1, duplicates linked: 0these managers have no manager code yet; mint one each, or list them in MANAGERS: p99 invite example.com --manager --name "<name>" --email grace@example.comChecking first
Section titled “Checking first”Before it sends anything, p99 import reads the whole bundle and checks every line; it lists each problem with its line number (line 12: comment: body must be 1 to 20000 characters) and stops. --dry-run then asks the Worker what would happen, type by type, {new, existing, conflicts}, and writes nothing.
Running it again
Section titled “Running it again”An import can stop and run again at any point, and running it twice changes nothing the second time:
- an item whose key already exists with the same title is left as it is; with another title it is a conflict, reported and never overwritten;
- comments and attachments are matched by
uid, so keep eachuidstable between runs; a file is uploaded only when the Worker does not hold it yet; - events are matched by item, kind and time, landings by item and environment, reads by item and person.
The command ends with a summary (JSON with --json) and exits with an error when there were conflicts.
Numbering
Section titled “Numbering”P99 keys every item <PREFIX>-<number> over one numbering for all kinds (BUG FEAT IMP Q TASK). An imported item keeps its number, so a bundle must not use one number twice, even across kinds. New items continue above the highest number in the account: after importing BUG-7, the next item filed is 8 or higher. A number already held by an item with another title is a conflict; import into a fresh account, or renumber the bundle, to avoid them.
The routes
Section titled “The routes”The CLI talks to three signed routes, which take the key p99 only:
| Route | Does |
|---|---|
POST /_p99/v1/import?host=&dry_run=1|0 |
a JSON array of up to 500 records (the CLI sends 200); answers {"dry_run","counts":{"<type>":{"new","existing","conflicts":[{"ref","reason"}]}},"upload":[uid],"managers":[email]}. attachment records are only checked here: upload lists the ones whose files the Worker does not hold yet. |
PUT /_p99/v1/import/attachments/{uid}?host=&item=&comment=&role=&mark=&name=&internal=&created_at= |
one file as the raw body, Content-Type its type; comment is the comment’s uid; 201 attachment, or 200 when it is already stored |
POST /_p99/v1/import/finish?host= |
{"items":[key],"duplicates":[{"item","duplicate_of"}]} links duplicates and recounts the items’ comments, files and activity; answers {"recounted","duplicates":{"linked","conflicts"}} |
The HTTP API
Section titled “The HTTP API”One Worker, one D1 database (binding DB) and one R2 bucket (binding BUCKET) per Cloudflare account. The Worker is mounted on every host it serves as the route <host>/_p99/*, so a page talks only to its own origin. It may also sit on a custom domain of its own.
The host of a request is the URL’s hostname. A host must be listed in the hosts table to be served; an unknown host answers 404 on everything but /_p99/health. The signed API is the exception: it names the host itself (below), so it answers on any address the Worker is mounted on.
| Path | What |
|---|---|
GET /_p99/health |
{"ok":true,"version"}, unsigned |
GET /_p99/p99.js |
the loader |
GET /_p99/panel.js, /_p99/p99.css, /_p99/capture.js, /_p99/art/concrete.svg, /_p99/art/concrete-dark.svg |
the lazy parts |
/_p99/api/* |
the browser API, authorised by a personal code |
/_p99/v1/* |
the signed API, for the CLI, the TUI and p99 announce |
Static files are answered from text modules bundled into the Worker (worker/src/web/). The loader is Cache-Control: public, max-age=300; every other file is fetched as <path>?v=<version> and answered public, max-age=31536000, immutable. __P99_VERSION__ in any served file is replaced by the Worker’s VERSION variable.
Errors
Section titled “Errors”Every error is {"error":"<code>","message":"<English>"} with 400 invalid, 401 unauthorized, 403 forbidden, 404 not_found, 409 conflict, 413 too_large, 429 rate_limited, 500 internal. A client_uid seen before answers the original object with 200, never a duplicate. The message is written for the person who reads it: the panel and the CLI show it as it is.
The browser API
Section titled “The browser API”Every request carries X-P99-Code: <code>. A code is 12 characters of Crockford base32 (0123456789abcdefghjkmnpqrstvwxyz); the Worker lowercases it, reads i and l as 1 and o as 0, drops - and spaces, and looks up its keyed hash, HMAC-SHA256 under the Worker secret CODE_PEPPER, so the codes table alone gives no code away (p99 setup makes that secret once and p99 deploy keeps it). A missing, unknown or revoked code, or one not scoped to the request’s host, answers 401, and the loader then forgets it; a revocation takes effect everywhere within ten seconds. Each code is limited to 120 requests a minute (the RATE rate-limiting binding, when the Worker has one) and to the daily limits below. Since the code travels in a header and never in a cookie, there is no CSRF surface.
The actor of a browser request is the code’s person: {email, name, manager}. A manager is a code minted with manager, or an e-mail in the Worker’s MANAGERS secret (comma separated).
| Route | Who | Does |
|---|---|---|
GET /_p99/api/me?path= |
any code | {"name","email","manager","host","env","version","counts":{"page_open","mine_open","mine_unread","all_open"},"theme":{},"strings":{}}; all_open is null for non-managers; theme and strings are the host’s config |
GET /_p99/api/issues?scope=mine|all|page&path=&status=open|closed|any|<status>&kind=&q=&cursor=&limit= |
any code; all managers only |
{"issues":[summary],"next_cursor"}, newest first, this host’s items only, limit at most 100 (default 50) |
POST /_p99/api/issues |
any code (task managers only) |
{"client_uid","kind","title","body","page","context","marks"}; 201 issue |
GET /_p99/api/issues/{key} |
its reporter or a manager | a thread, {"issue","comments","attachments","events"}; marks it read |
PATCH /_p99/api/issues/{key} |
as the signed API | 200 issue |
POST /_p99/api/issues/{key}/comments |
its reporter or a manager (internal managers only) |
{"client_uid","body","internal"}; 201 comment |
PUT /_p99/api/issues/{key}/attachments/{client_uid}?name=&role=&mark=&comment= |
its reporter or a manager | raw body, Content-Length required, X-Content-SHA256 optional (the Worker hashes the body when it is absent); 201 attachment |
GET /_p99/api/files/{id} |
its reporter or a manager | the file, Content-Disposition: attachment |
The signed API
Section titled “The signed API”Every route under /_p99/v1/ is signed:
| Header | Value |
|---|---|
X-P99-Key-Id |
the key id, p99 |
X-P99-Actor |
base64url, no padding, of the actor JSON; absent only on POST /_p99/v1/builds |
X-Content-SHA256 |
lowercase hex SHA-256 of the body (empty body: the hash of nothing) |
X-P99-Auth |
v1 ts=<unix seconds>,sig=<hex>, sig = HMAC-SHA256(secret, "v1\n" + ts + "\n" + METHOD + "\n" + path_and_query + "\n" + X-P99-Actor + "\n" + X-Content-SHA256); refused when ts is more than 60 s away |
The secret of key id p99 is the Worker secret KEY_P99, with KEY_P99_NEXT accepted beside it during a rotation. Key id test (the secret KEY_TEST) works only on 127.0.0.1 or localhost, for the Worker’s own tests. There is no nonce: a captured request could be replayed within that minute, so the signed API travels over HTTPS only.
The actor is {"email","name","agent"?}, agent true when an agent acts as this person, left out otherwise. Items, comments and events keep it, and every view shows such an actor as the person’s name with an asterisk, Ada Lovelace* (the panel adds the hint “AI” on the asterisk). The browser API never carries it.
Key p99 is the account’s own key, the one p99 setup makes and the CLI and TUI sign with. Its actor is always a manager, so the daily limits never apply to it. It names the host with ?host=<host> on any route (host=*, or no host, on the list and counts reads every host), and the environment with env in a build’s body. On the list and counts an unlisted host answers 404; on an item’s routes host is a filter, so an item of another host answers 404; POST /_p99/v1/issues takes the query’s host first, then the body’s.
| Route | Who | Does |
|---|---|---|
POST /_p99/v1/builds |
the key | {"sha","branch","version","dirty","env","items":[{"id","rel","sha","subject"}]} replaces that environment’s row for that branch; an env that is not an environment answers 400; 204 |
GET /_p99/v1/counts?path= |
actor | {"page_open","mine_open","mine_unread","all_open"} |
GET /_p99/v1/issues?scope=&path=&status=&kind=&q=&cursor=&limit= |
actor; all managers only |
as the browser API; with host=*, every host’s items |
POST /_p99/v1/issues |
actor | as the browser API, plus "host"; 201 issue |
GET /_p99/v1/issues/{key}, PATCH, POST …/comments, PUT …/attachments/{uid}, GET /_p99/v1/files/{id} |
as the browser API | |
GET /_p99/v1/hosts |
managers | {"hosts":[{"host","env","config","created_at"}]} |
PUT /_p99/v1/hosts/{host} |
the key | {"env","config"}; adds or updates a host; 200 host |
DELETE /_p99/v1/hosts/{host} |
the key | 204; its items stay |
GET /_p99/v1/envs |
managers | {"envs":[{"name","kind","position","notify"}]} |
PUT /_p99/v1/envs/{name} |
the key | {"kind":"development"|"release","position","notify"}; 200 env |
GET /_p99/v1/codes?host= |
the key | {"codes":[code]}, newest first |
POST /_p99/v1/codes |
the key | {"name","email","hosts":["…"],"manager"}; 201 {"code":code,"secret":"<12 characters>","links":["https://<host>/#p99=<secret>"]}; the secret is shown this once |
POST /_p99/v1/codes/{id}/revoke |
the key | 200 code |
GET /_p99/v1/admin/backup-status |
managers | the last and the running backup |
POST /_p99/v1/import, PUT /_p99/v1/import/attachments/{uid}, POST /_p99/v1/import/finish |
the key | an import that keeps keys and numbers, as Importing items describes |
A code object is {"id","name","email","hosts","manager","hint","created_at","created_by","revoked_at","last_used_at"}, hint the secret’s last four characters.
- Keys are
<PREFIX>-<id>over one sequence per account, the prefix from the kind:bugBUG,featureFEAT,improvementIMP,questionQ,taskTASK; any prefix with the right number, or the bare number, names the same item. - Statuses:
new,confirmed,in_progress,waiting(on the reporter),resolved,abandoned,duplicate(withduplicate_of); the first four are open. - Managers change
status,kind,assignee,duplicate_ofandtitle; the reporter changestitleandbodywhilenew, setsabandonedon an open item, andnewto reopen a resolved one. Every change is one event. - Everyone but a manager sees only what they reported; internal comments and their files are for managers only.
- Attachments: 20 MB (20 MiB) each, 30 per item; roles
screenshot,page,region(withmark),file; the type is the body’sContent-Type, checked against the file’s first bytes: PNG, JPEG or WebP for the images; for afilealso GIF, PDF,text/plain,text/csv,text/x-log, JSON, zip, and Word, Excel, PowerPoint and OpenDocument files (p99 file --attachandp99 comment --attachpick it from the file’s extension). A name ending in an executable or web page extension (.html,.svg,.xml,.js,.exe,.bat,.shand the like) is refused, whatever the type. - Limits per person and day: 30 items, 200 comments, 100 MB of files. Managers have none.
- Search (
q) matches the title and body with accents and case folded on both sides (cafefindsCafé): the Worker keepssearch_text, the NFD form of title and body without combining marks, lowercased, and foldsqthe same way. Aqthat is a key also finds that item.
A summary is {"id","key","kind","status","title","host","env","page":{"url","path","title"},"reporter":{"email","name","agent"},"assignee","created_at","updated_at","comments","attachments","unread","landing"}; an issue adds "body","duplicate_of","closed_at","context","marks","commits":[{"sha","subject","rel","envs"}]; a comment is {"id","client_uid","author":{"email","name","manager","agent"},"body","internal","created_at","attachments":[id]}; an attachment {"id","client_uid","role","mark","name","type","bytes","sha256","comment_id","created_at"}; an event {"actor","agent","kind","detail","at"}, agent true when an agent acting as that person made it; agent is always a boolean. assignee, event actors (and the from and to of an assignee event) and a comment author’s email show to managers only: for anyone else they are null, unless they are that person’s own.
Environments and landings
Section titled “Environments and landings”An environment is a name with a kind. A development environment counts the build of branch main; a release environment counts its latest announced build, whatever the branch. p99 announce (or a host’s own build) posts a build with the items its commits name in Fixes: and Refs: trailers; an item lands in an environment when a counted build there carries a Fixes: commit for it, or when an import records a landing there, and the first landing is an event. An environment with notify mails the reporter at an item’s first landing there, when mail is set up.
landing is {"branch":bool,"envs":[{"env","kind","at","sha","version"}]} in position order, branch true when a fix sits only on a branch no counted build carries.
Off unless the Worker has RESEND_API_KEY and MAIL_FROM. New items and comments by non-managers go to every manager; a manager’s non-internal comment or status change goes to the reporter; a first landing in a notify environment goes to the reporter. A manager’s own new item mails nobody, and no one is mailed about their own action. Mail goes to Resend as UTF-8 JSON, so subjects and bodies keep their accents; an agent acting as a person reads as Ada Lovelace*. Sending never fails a request; each send is an event. Links are https://<item's host><page path>#p99=<key>, which opens the panel on that item.
Backups
Section titled “Backups”The cron trigger (*/15 * * * *) runs a weekly backup of every table into the bucket under backups/<date>/, in steps small enough for one invocation, keeping eight.
Schema
Section titled “Schema”worker/migrations/0001_p99.sql is the schema. Migrations are recorded in d1_migrations (id, name, applied_at), the table wrangler keeps, so a database migrated by either stays in step.
Host config
Section titled “Host config”PUT /_p99/v1/hosts/{host}’s config is {"theme":{…},"strings":{…},"corner","offset":{"x","y"},"above","off":[…]}, as the host reference describes. The Worker writes the host’s corner, offset, above, off and theme.accent into the loader it serves for that host (replacing __P99_HOST__ with that JSON), so the button draws in place before any request; /_p99/api/me answers the whole config.
Test vector
Section titled “Test vector”Secret test-secret, ts 1760400000, POST /_p99/v1/issues?host=example.com.
- Actor JSON:
{"email":"ada@example.com","name":"Ada Lovelace"} X-P99-Actor:eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsIm5hbWUiOiJBZGEgTG92ZWxhY2UifQ- Body:
{"client_uid":"0b9c2f4e-1a2b-4c3d-8e9f-001122334455","kind":"bug","title":"The total does not update","body":"","page":{"url":"https://example.com/orders","path":"/orders","title":"Orders"},"context":{},"marks":[]} X-Content-SHA256:ecf774eccd7e95d4a00046092fe70059b83b5d650c1a1da9e9cc6c22e5ce22bfX-P99-Auth:v1 ts=1760400000,sig=89b1d507574a2e8bb9c58d3ba92a29eab0f03baad0e422c4882f17c8ba33505a