This project was built entirely in Claude Code with the Cloudflare MCP server — not a line of code was written by hand. Given the low stakes — a novelty site about South African politics — the brief was simple: make it fun, make it fast, and see how far you get in a morning. To write this post, I handed Claude the project chat transcripts and asked it to pull out the parts worth explaining. What follows is the result.

There are 396 people sitting in the South African National Assembly. Most South Africans couldn’t name more than ten. So I built a site that shows you two of them, side by side, and asks the only question that matters: who would you rather have as president?

The mechanic is simple: head-to-head matchups. Pick one, get the next pair. With enough votes across a big enough sample, you collapse all those pairwise preferences into a single ranked list — a total ordering. The corpus started as all 396 MPs, then grew to include a handful of non-MP politicians worth judging.

It’s at whoshouldbepresident.org. Tap a face, get the next pair, repeat. There’s a leaderboard. The whole thing runs on Cloudflare and took a morning.

This is how it came together — and the three or four interesting fights along the way.


The constraint: all-Cloudflare, planned through an MCP

I started with a constraint, not a stack search. The very first prompt I sent Claude was:

I want to host this website on CloudFlare, all in JavaScript, on cloudflare workers, backed by a cloudflare database. Please figure out what the simplest configuration is, and then lets build a local version, so we can test it and get ready for a cloudflare key etc.

Two things in that sentence matter. First, all Cloudflare — Workers for compute, D1 for the database, no third-party services to wire up, one bill that’s probably zero. Second, “figure out what the simplest configuration is” — meaning you go read the docs, I don’t want to.

The “go read the docs” bit was the interesting one. Before that prompt I’d installed the Cloudflare Docs MCP — a Model Context Protocol server that gives Claude direct, tool-call access to Cloudflare’s own documentation. So instead of dredging up half-remembered Wrangler trivia, the model could query the real docs and ground its architectural choices in current pages, version-correct flags, current limits.

Across the project there were exactly four search_cloudflare_documentation calls. They are, in order:

  1. Workers D1 database local development wrangler setup
  2. Workers R2 static assets images serve
  3. Workers static assets limits files size upload
  4. wrangler rate limiting binding configuration wrangler.toml not unsafe

The first three landed in the first five minutes. From them, the shape of the system fell out almost immediately:

  • Workers for the API and HTML — single src/index.js handling routes
  • D1 (Cloudflare’s SQLite) for the ratings table — two tables, members and ratings
  • Workers Static Assets for the 396 headshot JPGs — total 52 MB, well inside the static assets limit
  • R2 was investigated and rejected, because static assets are simpler and the photos are tiny

That fourth query, about rate limiting, came hours later when the anti-abuse work started — more on that in a minute. But the headline is: the MCP let me skip the usual “what does Cloudflare even call this thing” tax. I didn’t have to debate KV-vs-D1 in my head; the docs answered it. By the end of the architecture session there was a working wrangler.jsonc, a schema, and a seed script — and the model had never written a line of pre-2024 Wrangler config from memory.

If you’re building anything on a cloud provider with an MCP-backed doc set, this is the workflow. Use it. The cost is the price of one tool call per question; the saving is not shipping last-year’s API surface.


Scraping parliament: 21 minutes, three problems, one nasty WAF

Where do you get a list of all 396 National Assembly members? The parliament’s own site, of course: parliament.gov.za/group-details?chamber=2. (chamber=2 is the NA; chamber=3 is the NCOP, which I didn’t want.)

I’d love to tell you this took an afternoon. But time-stamps on the chat transcripts say it took 21 minutes end to end, from first prompt to a working scrape.py returning 396 of 396 members. The reason it took that long and not five minutes is the three problems in a row.

Problem 1: the empty shell

I did the obvious thing first — curl. The response was a navigation skeleton with zero member data. Predictable in retrospect: the site is an AngularJS single-page app, and the member list is injected into the DOM after Angular boots. A plain HTTP fetch never sees the data.

Claude’s first-take diagnosis from the transcript:

“The site is JavaScript-rendered. Let me use the Chrome DevTools tools to inspect the actual page and find the underlying API.”

There was a brief detour chasing a phantom API. The browser’s network tab surfaced a PHP route at /themes/parliament/assets/ajax/foldertree.php — but it responded 200 OK with content-length: 0 and a request body of dir=%2Fmnt%2Fweb%2Fhtml%2Fundefined. The literal string undefined in a server path. Some long-departed contractor’s bug, faithfully calling itself every page load. Not an API.

Problem 2: the AngularJS scope hack

The breakthrough came from poking around in the live page. AngularJS attaches its data to DOM elements via scopes, and the whole member dataset turned out to be sitting on the .tabs-content element:

const scope = angular.element(document.querySelector('.tabs-content')).scope();
scope.members      // { 'a-d': [...], 'e-g': [...], ..., 'x-z': [...] }
scope.memberCount  // "396"

Seven alphabetical buckets, one object per member. No pagination, no API to reverse-engineer, no rate limiting to dance around. One page.evaluate() call from Playwright grabs the lot.

Each member object had everything I needed:

{
  "id": 6067,
  "full_name": "Zelna Saira Abader",
  "party": "MK",
  "province": "Gauteng",
  "national": 0,
  "profile_pic_url": "/storage/app/media/MemberImages/6067.jpg"
}

So the plan was: launch a real browser with Playwright, wait for Angular to settle, reach into the scope, pull everything, then download the JPGs from parliament.gov.za/storage/app/media/MemberImages/{id}.jpg. Easy.

Problem 3: “Attack ID: 20000051”

It was not easy. The first Playwright run came back not with member data but with a block page — “Web Page Blocked! Attack ID: 20000051”. The site sits behind a WAF (Imperva/Incapsula, by the look of the block-page signature) that detects stock headless browsers by fingerprint. It was happy to talk to my real Chrome during the DevTools poking; it slammed the door on Playwright the moment it tried.

Standard headless-detection fingerprints. The fix is the well-known stealth dance:

browser = playwright.chromium.launch(
    headless=True,
    args=["--disable-blink-features=AutomationControlled"],
)
context = await browser.new_context()
await context.add_init_script("""
    Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
    Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
""")

That flag disables Chromium’s automation banner; the init script patches navigator.webdriver (the giveaway property the WAF was checking) and stuffs a fake plugins array. With those in place, the next run rendered 142 members. The run after that, all 396.

The whole arc — empty shell → API red herring → scope discovery → WAF block → bypass → 396/396 — fits in the transcript inside about a fifteen-minute window. The scraper hasn’t needed to change since. I can run uv run scrape.py every couple of months; it diffs the current list against the local one, downloads new MPs’ photos, deletes departed ones, and rewrites members.json.


The vote endpoint was wide open

With the data scraped, a Worker stood up, and a leaderboard rendering, I had a working site. I also had a glaring security hole.

The first version of the API was naive in the most boring way. POST /api/vote accepted a JSON body of { winner_id, loser_id } and incremented wins on the first and losses on the second. There was nothing tying that POST to a pair the server had actually offered. A single fetch() loop in any browser tab could send arbitrary winner/loser combos and run a candidate’s score to infinity in a minute.

A few hours in I sent the prompt I should have sent up front:

Please review the current implementation of our ranking website. We want to make sure that … once it is deployed, we don’t have people gaming the system, or spamming it with bots.

The diagnosis came back blunt:

“The vote endpoint is effectively wide open — there’s nothing tying a vote to a human, a real session, or even a pair the server actually offered.”

I sat down and thought about it for a few minutes. The threat model is:

  1. Bots spamming votes — easy if there’s no rate limit.
  2. Bots crafting arbitrary pairs — easy if /api/vote doesn’t check that the (winner, loser) pair was ever offered by /api/pair.
  3. Cross-origin abuse — easy if CORS is *.

Each one has a cheap fix. I ended up with three:

Layer 1: HMAC-signed pair tokens

When the client asks /api/pair, the server picks two random members and returns them — plus a token. The token is a base64url-encoded JSON payload:

{
  w: 6067,     // winner candidate id
  l: 4521,     // loser candidate id
  n: nonce,    // 16 random bytes, hex
  e: 1747...   // expiry, unix seconds
}

…concatenated with an HMAC-SHA256 signature over the payload, keyed by a HMAC_SECRET stored as a Wrangler secret. On /api/vote, the worker:

  1. Splits the token into payload.sig
  2. Recomputes the HMAC and constant-time-compares
  3. Decodes the payload, checks the expiry
  4. Checks that the body’s winner_id/loser_id are exactly the w/l from the token (in either order — the user can pick whichever side they want)

If anything fails, return 400. Now the only valid votes are ones the server itself just handed out, and they’re only valid for a short window. A bot can’t craft pairs; it can at best replay the one the server gave it.

Layer 2: rate limit

Cloudflare’s RATE_LIMITER binding does this in two lines of wrangler.jsonc and one await env.RATE_LIMITER.limit({ key: ip }) in the handler. I tuned the cap a few times. First pass was 10/minute, which I almost immediately bumped:

“That changes the rate limit to be permissive for enthusiastic humans (one vote every 2 seconds is still faster than a person naturally clicks).”

It’s at 120/minute now. The point isn’t to stop bots dead — they can rotate IPs — it’s to make a single-IP attack uneconomic.

Layer 3: CORS lock-down

ALLOWED_ORIGIN was set to the production domain. No more *. POST cross-origin now eats a CORS error.

What I cut: Turnstile

I’d originally planned a fourth layer: Cloudflare Turnstile, the invisible-Captcha widget, verified server-side on every vote. I built the docs for it before I built the code, looked at the resulting flow, and ripped it back out. The HMAC pair-token plus a rate limit is enough for a site whose worst-case outcome is “DA’s leaderboard score is slightly inflated.” Turnstile is real friction on every vote for a benefit that’s mostly theatre at this scale. YAGNI.

Token expiry, the UX kicker

The token has a 60-second expiry. That number is the result of one piece of real-world pain. I left a tab open for ten minutes, came back, voted, got “Vote failed — please try again.” Tried again. Still failed. The token was dead.

The fix wasn’t to extend the expiry — long-lived signed tokens are worse, not better, for replay. The fix was to make the client treat any vote error as “fetch a new pair silently.” If the token’s stale, the user sees the next two MPs without ever seeing the failure. The server logs the failure regardless, so I can still tell from wrangler tail whether a single IP is repeatedly probing with bad tokens versus benign expired-tab traffic.


Wilson score, not Elo

The first version of the leaderboard used Elo. It looked sophisticated; it was actually wrong for this problem.

The issue with Elo for a “rate things from a corpus of 396” task is that it’s designed for opponents who play many games. Most MPs in this dataset would have one or two head-to-head matchups before anyone looked at the leaderboard. Elo with two games of data is essentially random.

I asked Claude about it directly:

“I see you’re using Elo to rank members. Why do we use that instead of say a beta distribution with up and down votes?”

The honest answer was that Elo was the first thing it reached for. The Bayesian Beta posterior, or the Wilson lower bound, is the right tool for “is this success rate real or just noise from a small sample.” For 396 MPs with sparse vote counts, Wilson lower bound at 95% confidence is the answer:

function wilsonScore(wins, total, z = 1.96) {
  if (total === 0) return 0;
  const p = wins / total;
  const z2 = z * z;
  return (
    (p + z2 / (2 * total) - z * Math.sqrt((p * (1 - p) + z2 / (4 * total)) / total)) /
    (1 + z2 / total)
  );
}

Why I like it for this:

  • A member with zero wins scores 0 regardless of how many losses, so they sink to the bottom without an arbitrary “minimum votes” threshold.
  • A member with 10 wins, 0 losses scores around 0.72 — better than a member with 1-and-0 (who scores 0.21), because we trust the larger sample more.
  • It degrades gracefully. Nobody dominates from a single lucky win.

The leaderboard renders instead of 0% for members who haven’t won a matchup yet — a Wilson score of zero is technically correct, but “0%” reads as a value judgement when it’s really “we don’t have data yet.”

There’s also a bias fix in /api/pair. The naive version is “pick two random members.” Random pairing means popular candidates show up too often and obscure ones never. The version that ships picks 5 candidates at random, then takes the two with the fewest total matchups (wins + losses), then shuffles which side each is shown on. That keeps head-to-heads spread evenly across all 396 instead of clumping around whoever’s already at the top.


What got tuned, what got cut

A few smaller things worth mentioning:

  • Static assets, not R2. The 396 JPGs total 52 MB — well inside Workers Static Assets limits, no R2 needed. The MCP query “Workers static assets limits files size upload” was the one that settled this.
  • Symlinks across the worker boundary. worker/public/images is a symlink to the repo’s ../../images directory. Wrangler follows the symlink in production. One source of truth, no copy step. Worth checking the symlink isn’t accidentally an empty directory after a fresh clone — mine was, once.
  • TDD as a retrofit. The project’s CLAUDE.md now says “New features must be built with TDD.” I added that line after the MVP shipped, having noticed how many regressions a small test suite would have caught. The right time to write that line was at the start; the second-best time is when you’re closing the barn door.
  • Deploy SHA in the version tag. I twice told myself I’d shipped a fix only to find the old code still serving. Now npm run deploy injects the current git SHA into the page as a <meta> tag, and the worker exposes it at /version. If the SHA in the browser doesn’t match the SHA in git, I haven’t deployed.

Closing

It runs on Cloudflare’s free tier — Workers, D1, Static Assets, the rate-limiter binding, all in. The data refresh is one command; the deploy is one command; the schema reset is one command.

The interesting question isn’t can you build this in a morning — of course you can, it’s a CRUD app over 396 rows. The interesting question is where did the time actually go. Most of it wasn’t the scraper or the Worker. It was the anti-abuse layer (designing it, then walking back the Turnstile bit), the leaderboard math, the bias fix, the UX polish, and the contrast pass to hit Lighthouse 100/100.

If I were doing it again, the lesson I’d write on a sticky note is: build the threat model before you build the leaderboard. A leaderboard implies a vote endpoint, and a vote endpoint is going to get hit by something dumb the moment it’s public. The pair-token + rate-limit pattern is two evenings of work if you do it last, and forty minutes if you do it first. The difference is in how confident you can be about the numbers you publish.

Vote on a few MPs while you’re here. Some of the matchups are genuinely hard.

One last thing: this post was assembled the same way the site was built. I handed Claude the chat transcripts from the project and asked it to read back through them and pull out what was actually interesting. The structure above — the four technical problems worth writing about — came out of that pass. The same tool that wrote the code also read the conversations about writing the code, and decided what was worth your time.