Skip to content

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.

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 as Ada Lovelace* in every view and in mail: --agent marks one command, P99_AGENT=1 (any value but empty or 0) the commands of a shell, and agent = true here every command; P99_AGENT=0 turns 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>/_p99 of one of the account’s hosts: the host a command names first, then the hosts whose environment is production, 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. url replaces that list with one address. resend_key and mail_from turn mail on. When every host of the account sits behind Cloudflare Access, either set url to an address of the Worker that is not behind Access, or set access_client_id and access_client_secret to an Access service token’s pair: every request of the CLI, the TUI and p99 announce then carries them as CF-Access-Client-Id and CF-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 manager p99 setup names. 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) and strings (any of the panel’s English words, by key) are pushed to the Worker with p99 hosts.

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-run
p99 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.

A bundle is a directory with a bundle.jsonl and the files its attachments name beside it:

bundle/
bundle.jsonl
files/receipt.png

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

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.com
item: 1 of 1 sent
comment: 1 of 1 sent
attachment: 1 of 1 sent
event: 1 of 1 sent
landing: 1 of 1 sent
read: 1 of 1 sent
manager: 1 of 1 sent
example.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: 0
these managers have no manager code yet; mint one each, or list them in MANAGERS:
p99 invite example.com --manager --name "<name>" --email grace@example.com

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.

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 each uid stable 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.

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 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"}}

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.

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.

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

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: bug BUG, feature FEAT, improvement IMP, question Q, task TASK; 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 (with duplicate_of); the first four are open.
  • Managers change status, kind, assignee, duplicate_of and title; the reporter changes title and body while new, sets abandoned on an open item, and new to 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 (with mark), file; the type is the body’s Content-Type, checked against the file’s first bytes: PNG, JPEG or WebP for the images; for a file also GIF, PDF, text/plain, text/csv, text/x-log, JSON, zip, and Word, Excel, PowerPoint and OpenDocument files (p99 file --attach and p99 comment --attach pick it from the file’s extension). A name ending in an executable or web page extension (.html, .svg, .xml, .js, .exe, .bat, .sh and 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 (cafe finds Café): the Worker keeps search_text, the NFD form of title and body without combining marks, lowercased, and folds q the same way. A q that 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.

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.

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.

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.

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.

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: ecf774eccd7e95d4a00046092fe70059b83b5d650c1a1da9e9cc6c22e5ce22bf
  • X-P99-Auth: v1 ts=1760400000,sig=89b1d507574a2e8bb9c58d3ba92a29eab0f03baad0e422c4882f17c8ba33505a