How we built the Dashboard

The dashboard is a Pithy application, built on the kit it administers. That is not a gesture — it makes us the first consumer of our own seams, and every friction we hit is filed as a bug in the kit rather than worked around.

This page is the record of standing it up: the commands in order, what each one left behind, and what did not work.

No product code appears here. What appears is the command sequence, the config shapes, where the secrets went, and the honest summary — so that you can start your own project the same way.

The eight commands

What the dashboard is is the overview; the quickstart is the same sequence for your own project; contributing is how to work on the kit itself.

CommandOutcome
pithy initScaffolded, in a repository already cloned
bun install195 packages. Nothing 404s
pithy ui add reactTen files, and the asset allowlist derived from the real route table
pithy add <capability> ×5Each left a project that typechecks
pithy migrateSix migrations, composed in order
pithy seedIdempotent, and structurally unable to touch production
pithy devThe app correctly refuses to serve without provisioned bindings
pithy deployNeeds an account. Verified up to that boundary

1. Scaffold

pithy init --name dash --worker board

Root package.json, pithy.config.ts, both vitest configs, biome.jsonc with its two lint plugins, .gitignore — and apps/board/ with its own config, wrangler.jsonc, pithy.worker.jsonc and mount file.

It ran in a repository already cloned, which is how projects normally start. It refuses only on collision with a path it would write.

2. Add the front end

pithy ui add react --worker board

Ten files, plus rewiring pithy.worker.jsonc and package.json. The part worth having is that it derives the asset allowlist from the real composed route table — get that list wrong and every API route silently returns the SPA shell, with a 200 and no error anywhere.

One thing to set expectations on: the React package is not a component library. It ships zero runtime exports — no button, no table, no layout. It is a template copier. The design system is the adopter’s to write, and in this repository it is.

3. Compose the capabilities

pithy add secrets  --worker board
pithy add email    --worker board
pithy add audit    --worker board
pithy add auth     --worker board
pithy add payments --worker board

Each installs the package, reads its manifest, wires that Worker’s config and bindings, scaffolds its config options, and runs that Worker’s dev migrations.

Order does not matter. Capabilities carry a fixed migration order, and the ledger composes in that order regardless of the order you add them in.

Every capability leaves a project that typechecks. A required option the CLI cannot invent — a secret registry, a ledger’s currencies — is scaffolded as a working example with a comment saying to replace it, so the project compiles and loads before you have written a line.

4. Where the secrets went

This is the part most worth understanding, because it is not obvious from any single command.

~/.config/pithy/
    cloudflare.json       the CLI's own credentials. Account-scoped, shared by every project
    dash/
        secrets.jsonc     every secret this project has  ← the source
        dev.json          the dev-login preference

<repo>/apps/board/
    .dev.vars         generated. Never edit it
    .dev.vars.local   hand-authored overrides, merged last, wins

Nothing the CLI reads lives in the checkout. Not an application secret, and not a credential either.

The credentials are account-scoped rather than project-scoped: one Cloudflare account holds many projects, so a per-project copy would store the same token repeatedly and make rotation an N-place edit. They are also the strongest credential in the system — the token every other token is minted from — and a maintainer’s live token reaching a published npm tarball from a file inside a repo is a real, filed incident. npm pack does not consult .gitignore when the package declares a file list, so gitignoring was never the protection it looked like.

The secrets file is the source, not a copy. That was a real defect once: a value was copied into a second file by the seeding step and the generator read that, so editing the secrets file changed nothing until something re-seeded — and this project had two secrets diverged between the two files, with the Worker running on the copy. Generation reads the source directly now.

5. Migrate

pithy migrate
SECRETS              0100_secrets_0001_init          Success
DB                   0200_email_0001_init            Success
DB                   0250_audit_0001_init            Success
DB                   0300_auth_0001_init             Success
DB                   1000_payments_0001_purchases    Success
DB                   2000_app_0001_init              Success
EMAIL_SUPPRESSIONS   0100_email_0001_suppressions    Success

Our own migration sits at 2000 — core capabilities low, the adopter’s app high — so it runs after every table it references exists.

6. Seed, and run

pithy seed
pithy dev

Seeding mints a dev session from the preference file, so pithy dev starts signed in rather than making you round-trip a magic link. It is idempotent by comparison rather than convergence: running it again rewrites no bytes and re-mints nothing.

Dev secrets can never reach a managed environment. Not by flag, not by argument — the refusal is structural, because no signature in that path accepts one.

Cold start is slow: Vite takes around 28 seconds before the first request is served.

The most instructive output of the whole exercise

GET /health returned 500 until one real Cloudflare resource existed:

{"error":{"code":"core/internal","message":"Missing required bindings: workflow:EMAIL_SENDER"}}

The master key was not in that list, and the difference is the thing to understand. It is a registry secret, so a dev value was minted locally and reaches the Worker through the generated file — local dev needs no account for it. The email Workflow binding points at a deployed Worker, and there is no local stand-in for one.

That is fail-fast binding validation working as designed: a composed capability declares what it needs, and the Worker refuses on the first request naming what is absent rather than failing later somewhere unrelated. The static client served regardless, so every pane was reachable.

7. Deploy, up to the account boundary

Not run — it needs an account, an API token and an active zone. What was verified is everything before that:

bun run build
bunx wrangler deploy --dry-run --outdir /tmp/out

Eleven bindings resolve and wrangler accepts the config.

One subtlety CI hit: the build writes a deploy-config redirect for a single already-resolved environment, so a dry run with a different environment flag after a dev build reads dev’s bindings and ignores the flag without saying so. The environment is chosen at build time, and CI does the dry run once per environment.

What still needs a real account

NeedsFor
An account, Workers Paid, a bootstrap tokenEvery deploy, every remote migrate
An active zoneThe public hostnames
A Secrets StoreRecording the store id. One per account, so nothing to choose
pithy secrets provisionThe master key in deployed environments
pithy email provisionThe send Workflow, and a verified sending domain
A payment rail’s keys and webhook secretSelf-billing
A real project composing the control-plane seamEvery read pane

The honest summary

All eight commands work today. That was not true when this document was first written. Three of them failed, a fourth left the project in a state where a later unrelated command failed, and this file was a list of defects with issue numbers beside them.

Ninety-three gaps were filed while building on the kit; eighty-four are closed. Every one was found by hitting it — by scaffolding a real application, running the real commands, and reading the real output. Several were found only by running a command twice, or by deleting the checkout and looking, or by planting a symlink where a tool did not expect one.

Three lessons came out of it that are worth more than the fixes.

Distrust the report. Roughly a third of confident findings did not survive contact with the code: an issue blamed the wrong file, a claim about which directory a test walked turned out false when measured, a suspected second defect simply was not there. Every fix in the later rounds began by reproducing the issue’s own claims before acting on them, and a surprising amount of the value came from that.

Defects come in families. Four classes had three or more producers each, always because a rule lived at a call site rather than at the thing being called — a file read whose failure was discarded, a string interpolated into generated source, a path joined without validation, a private directory walk. Reliable enough to be a search strategy: fix one, then go looking for the second and third, because they are there.

Workarounds are the measure. This repository carried three scripts that existed only to route around the CLI. Two are gone — capability installs work against a linked checkout, and each Worker’s local variables are generated. One stays until the packages publish, and it exists only because they have not.

That ratio is the argument for building the commercial product on the open-source kit. Nobody else would have found these before an adopter did — and an adopter would have found them alone, in the dark, with no issue tracker to file them in.

ESC