Writing /
How we built jpd-labs.si: a lab site that follows its own rules
A static site with no cookies, no JavaScript of its own on most pages, a strict CSP, and an Agent Card validator that never sends your data anywhere. Here is how, and what broke on day one.
We tell people that AI agents should be identifiable, limited, and observable. It would be awkward if our own website loaded a dozen trackers, set cookies, and sent whatever you typed to a server you never chose.
So the lab site had one rule: it should pass the same review we’d want an agent to pass. This post covers what we built, the decisions behind it, and the things that went wrong on launch day.
What shipped
- The lab site at jpd-labs.si: experiments, writing, open-source specs, a changelog, and the early-seller program for platform.ai.id.
- The Agent Card Validator, the first of four browser-only experiments. Paste an Agent Card, the small JSON document that describes an agent to the marketplace. You get every error with its JSON path and a plain-English fix, warnings for values that look off, and a “ready to list” checklist.
- A public JSON Schema for Agent Cards at /schemas/agent-card.json.
Three more experiments follow, one a month: a Delegation Token Playground, an Agent Health Check, and a Prompt Injection Check.
The constraints we set first
Before writing any page we wrote down the budget. Every page has to meet it:
| Constraint | Target | Launch |
|---|---|---|
| Our JS on non-experiment pages | 0 KB (except a theme script) | 0 KB |
| JS on an experiment page (gzipped) | ≤ 60 KB | ~19 KB |
| CSS, whole site (gzipped) | ≤ 20 KB | ~7 KB |
| Cookies | none | none |
| Lighthouse, mobile, all categories | ≥ 95 | 100 on home, validator, and join |
| Automated accessibility (axe) | zero violations | zero, in both themes |
None of this is hard on its own. It gets hard when you also want a strict Content Security Policy.
A strict CSP changes your stack
Our policy starts with default-src 'self' and has no unsafe-inline and no unsafe-eval. Two common tools break under that.
Syntax highlighting
Astro’s default highlighter, Shiki, colours code by writing style="color: …" on every token. A style-src 'self' policy blocks inline style attributes, so the colours silently disappear. We switched to Prism, which emits class names, and styled those classes with our theme tokens. Same result, and nothing inline.
JSON Schema validation
The validator uses Ajv, the standard JSON Schema library. By default Ajv compiles a schema into a JavaScript function at runtime with new Function, which is eval in all but name. A CSP without unsafe-eval blocks it.
The fix is to compile at build time instead. Ajv has a standalone mode that writes the validator out as plain code:
const ajv = new Ajv2020({ allErrors: true, code: { source: true, esm: true } });
const validate = ajv.compile(schema);
writeFileSync('schema-validator.generated.js', standaloneCode(ajv, validate));
The browser ships the generated function, not the compiler. The page needs no eval, and it is smaller too.
Hashing the inline scripts we do have
Two kinds of inline script remain. One is ours: a few lines in <head> that set the light or dark theme before first paint, so the page doesn’t flash. The other is Astro’s small bootstrap for interactive islands.
Rather than allow unsafe-inline, a post-build step reads every generated HTML file, computes a SHA-256 hash of each inline script and style, and writes them into the CSP header. The same step fails the build if it finds anything the policy would block: an inline style= attribute, an onclick= handler, or an external script. A careless change can’t quietly weaken the policy, because the build stops first.
Privacy by construction, not by promise
“We don’t store your data” is a promise. “Your data never leaves your browser” is something you can check, so we built for that:
- The validator runs entirely client-side. Our end-to-end tests record every network request while it validates a card and fail if there is even one.
- The validator does not contact the endpoint in your card, not even to check that it exists. That job belongs to the Health Check experiment, and the page says so.
- URL inputs in upcoming experiments accept only
https://and reject localhost, private ranges such as10.0.0.0/8and192.168.0.0/16, and link-local addresses like169.254.169.254. A browser tool shouldn’t be usable as a probe against your own network. - The theme preference lives in
localStorage. There are no cookies anywhere.
Testing what we claim
Claims like “no cookies” and “no violations” are only worth something if a machine checks them on every change, so the test suite asserts them directly:
- Unit tests cover the validation logic: every schema rule, every error message, and the “fixed example” generator. Coverage on the validator module is about 98%, and CI fails below 90%.
- End-to-end tests run against the real Worker with the real security headers, not a dev server. Any CSP violation shows up as a browser console error, and the tests fail on any console error.
- axe accessibility scans run on every page in both the dark and light themes, on desktop and mobile.
- Lighthouse CI fails a pull request that drops any category below 95.
What broke on launch day
Building in public means writing this part down too.
The deploy token couldn’t deploy. The first Cloudflare token we had was scoped to DNS only, so it could edit records but not publish a site. The second was read-only across the board. The third worked. Lesson: write the exact permissions in the README, which we have now done.
Our own CSP blocked our analytics. Cloudflare injects its Web Analytics beacon at the edge, after our build. The strict policy did its job and blocked a script it hadn’t been told about. The end-to-end tests run against production caught it within minutes. We chose Cloudflare’s cookieless analytics on purpose, so we allowed exactly its two hostnames and nothing broader. It is the one third-party script on the site, and we’d rather say that than pretend otherwise.
A redirect loop, but only on our laptops. We redirect http:// to https:// in the Worker. In local development the tooling rewrites every request to http://jpd-labs.si/, so the redirect fired on itself forever and the test server never came up. The fix was to read the visitor’s real scheme from the CF-Visitor header, which only Cloudflare’s edge sets.
Links to products that don’t exist yet. The home page linked to platform.ai.id, iam.ai.id, and ops.ai.id. Those domains don’t serve anything yet, so the links led to a DNS error. They are now plain text marked “soon”, and a test makes sure they stay unlinked until each product is live.
The stack, briefly
- Astro producing static HTML, with small Preact islands only on experiment pages
- Plain CSS with design tokens shared with jpd-labs.com; no CSS framework
- Self-hosted Inter and JetBrains Mono, so there are no font CDNs
- Cloudflare Workers serving static assets, plus a small Worker for the join form and redirects
- Vitest, Playwright, axe, and Lighthouse CI in GitHub Actions
What’s next
Next month brings the Delegation Token Playground. You’ll generate an Ed25519 key in your browser, sign a token with scopes, a per-transaction limit, and a budget, then watch a simulated payment pass or fail against it. It shows what iam.ai.id is for.
If you’re building agents, check your Agent Card and join the early-seller program. If you find a security issue with this site, our security.txt says where to send it.
Don’t panic. Build the guardrails.