Case study
An open tournament data site on WREN
data.tkd-scores.com lists who is entered in upcoming World Taekwondo events, with each athlete's world-ranking seed, Olympic rankings and past results. It is a browsable site and an open JSON API at the same time. A scheduled pipeline writes the data, WREN stores and serves it, and a small Cloudflare Worker puts it on its own domain. This is how it's built, what we worked around, and how we'd build it with WREN 0.10.0.
The site
data.tkd-scores.com covers World Taekwondo (WT) and WT Europe kyorugi and poomsae tournaments. For each event it shows the registered athletes per weight category with their current world-ranking rank and points, and for past events where each athlete finished and how many points they earned. A separate page shows the Olympic (LA 2028) rankings. The data is scraped from the registration platform the events use. Events that are still open for registration and start within a month refresh daily, the full set every three days.
| At a glance | |
|---|---|
| Content | 88 events in the index (28 upcoming or running, 60 past), 36,237 entries in total; Olympic rankings for 8 weight classes and 1,488 athletes |
| Writer | one machine running a bash, Node and Python pipeline on a schedule: daily at 09:00 and a full run every three days |
| WREN data | its own org; public collection events with 94 documents and 4,096 versions; tree events with 92 paths (88 event slugs plus index, olympic, results-map, olympic-declarations) |
| Site | public tree site with 13 paths; the app is a single 84 KB HTML file served as /index.html |
| Front door | a 56-line Cloudflare Worker on the custom domain |
Who uses it
- Coaches, athletes and fans, in the words of the tkd-scores.com landing page. The site highlights Switzerland by default; any country, club or athlete can be highlighted or searched instead.
- AI assistants. The site publishes its own
llms.txtand a help page with prompts people can paste into a chat assistant. - Other projects. The live tracker at tournaments.tkd-scores.com (see the tracker case study) reads the entry lists every day to build provisional brackets for events whose registration has closed, and to fill in athletes' clubs. An analysis project for team selection read the same entry lists and seeds.
Part A
As built
The org was created on 2026-04-09, and the first version of the events index was written the same day. Everything since then was added in small steps, almost all of it by AI coding agents; how they release changes is described in websites maintained by AI agents.
Data model
Each event is one JSON document in the events collection: name, dates, city, country, registration deadline, venue, the event's documents, a registration history, and a report with every entry. A seeded entry looks like this:
{
"name": "…", "startDate": "09/19/2026", "endDate": "09/20/2026",
"city": "…", "country": "…", "regDeadline": "…", "venue": { … }, "documents": [ … ],
"history": [ …, { "date": "2026-09-16", "total": 929, "cadet": 233, "junior": 309,
"senior": 387, "male": 499, "female": 430 } ],
"report": { "divisions": [ { "name": "SENIOR DIVISION", "categories": [
{ "name": "F-57 kg", "total_athletes": 24,
"ranked": [ { "rank": 11, "points": 32.48, "name": "…", "country": "…", "club": "…",
"gmsId": "…", "result": { "place": …, "points": …, "gRank": … } }, … ],
"unranked": [ … ] } ] } ] }
}result is filled in for past events. In this category 13 athletes are seeded and 11 are unranked.
Some details matter for what follows:
- The slug lives only in the URL. Documents have no
slugfield. An event is found by its path in theeventstree, for example/{event}-2026. - One collection for everything. The events index, the Olympic rankings, a 4.2 MB map of athlete results and the Olympic declarations are documents in the same collection, mounted at fixed paths.
- No schema. Dates are
MM/DD/YYYYstrings, so awhereon dates doesn't work; both the pipeline and other projects parse dates in their own code. - Sizes. The average event document is 161 KB of JSON; all current documents together are 15 MB.
Getting data in
The pipeline opens the registration platform in headless Chrome (the site is behind a Cloudflare challenge), reads the entry lists through its REST API, looks up each athlete's ranking, and writes one JSON file per event to a local folder. The local folder is the source of truth. The deploy step then pushes those files to WREN:
# 1. map slugs to document ids, from the public tree listing
curl -s "$WREN_BASE/api/v1/orgs/{slug}/tree/events" > tree.json # children: [{path, documentId}]
# 2. a new event: create the document, then mount it at /<slug>
id=$(curl -s -X POST "$WREN_BASE/api/v1/events" -H "Authorization: Bearer $WREN_API_KEY" \
-H "Content-Type: application/json" --data-binary @"events/data/$slug.json" | jq -r .id)
curl -s -X PUT "$WREN_BASE/api/v1/tree/events/$slug" -H "Authorization: Bearer $WREN_API_KEY" \
-H "Content-Type: application/json" -d "{\"documentId\":\"$id\"}"
# 3. a known event: replace the whole document
curl -s -X PUT "$WREN_BASE/api/v1/events/$uuid" -H "Authorization: Bearer $WREN_API_KEY" \
-H "Content-Type: application/json" --data-binary @"events/data/$slug.json"Around that core sit the safeguards the project needed over time:
- A single-instance lock. A daily run and a full run share the local folder and the live documents, and two runs at once would corrupt each other.
- A recovery script. When the WREN server was unreachable mid-deploy,
finish-deploy.shpolled the events index for up to six hours and then pushed everything again. - A bootstrap step. When the project moved from a Mac to a Windows machine in September 2026, the local folder had to be filled from WREN before the first run. Otherwise a daily run would have pushed documents without athlete results over the good ones in WREN.
Site pages are deployed separately: each file is uploaded with a multipart PUT to its document in the media collection, and new files are mounted in the site tree.
Serving it
Both trees are public, so nothing needs a key to read. The Worker on data.tkd-scores.com forwards /api/* to WREN and serves everything else from the site tree:
// wrangler.toml: UPSTREAM = "https://wren.aemwip.com/orgs/{slug}/tree/site", BUILD = "2026-08-08a"
if (path.startsWith("/api/")) { // the app's data and _query calls
const r = await fetch(new Request(WREN + path + url.search,
{ method: request.method, headers: clean(), body: request.body }));
const out = new Response(r.body, r);
out.headers.set("cache-control", "public, max-age=30, must-revalidate");
return out;
}
if (path.endsWith("/")) path += "index.html"; // directory → index.html
const cb = (url.search ? "&" : "?") + "cb=" + env.BUILD; // bump BUILD to flush the edge
const mutable = /\.(html|js|json)$/i.test(path);
return fetch(env.UPSTREAM + path + url.search + cb,
{ cf: { cacheTtl: mutable ? 30 : 3600, cacheEverything: true } });The app is a single HTML file. It loads /api/v1/orgs/{slug}/tree/events/index for the overview and one tree path per event, and reads the payload from .document.data. Searching for an athlete across all events is a single public _query, with no search index:
// which events list this athlete? array paths are unnested, $path is the event slug
const res = await fetch("/api/v1/orgs/{slug}/events/_query", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
where: `report.divisions[].categories[].ranked[].gmsId ~* ^${id}$`,
aggregate: { groupBy: ["$path"],
metrics: { n: { count: "report.divisions[].categories[].ranked[].gmsId" } } },
limit: 300 }) });
// the app runs the same query for unranked[] and merges the slugs from rows[].key.$pathA second Worker serves a one-page landing site on the tkd-scores.com apex and links to both tools.
For AI assistants
The site has its own /llms.txt (3.6 KB). It describes the index, one event report, the Olympic rankings and the cross-event query, notes that tree responses wrap the payload as { "document": { "data": … } }, and links WREN's generated org llms.txt as the "underlying store schema". A help page, /llms.html, gives people prompts to paste into a chat assistant. A hook in the project reminds whoever changes the app to check whether the help page, llms.html and llms.txt need an update too.
Timeline and scale
| When | What |
|---|---|
| 2026-04-09 | Org created; first events index and first version of the app |
| August 2026 | Olympic page (Aug 4), athlete results map (Aug 7), llms.txt and llms.html (Aug 27); the Worker's build id is 2026-08-08a |
| 2026-09-16 | Pipeline moved from a Mac to a Windows machine; daily run at 09:00, full run every three days |
| 2026-09-17 | Mobile-first redesign live (app version 43), after staging under /preview/ |
| 2026-10-05 | Events index at version 132; 4,096 event versions in total |
Writes grew month by month. Versions added to the events collection per month: April 294, May 254, June 248, July 583, August 902, September 1,673. All 4,096 versions hold 440 MB of JSON (175 MB as stored).
What worked well
- Open data with no backend code. The same public URLs feed the app, AI assistants and the tracker's daily jobs. No one wrote an API.
_queryover nested arrays. Athlete search across every event and every category is one request against public data.- Versions as a time series. The registration curves on the event cards were first rebuilt from each event's version history, one snapshot per day. The curve now lives in each document's
history. - Rollback without backups. The rollback plan for the redesign was simply "WREN version 41 of the app".
- A small front door. The custom domain is 56 lines of Worker code.
Warts and workarounds
An honest list. The status column refers to WREN 0.10.0, the release that publishes this page.
The workarounds from all case studies, with the better way for each, are collected in I did this. Is there a better way?
| What happened | Workaround | Root cause | Status |
|---|---|---|---|
Every deploy first listed the tree to find document ids; a new event took a POST plus a tree PUT | slug-to-id map built from GET /tree/events | No natural key; the slug is only a tree path | Available: naturalKey and PUT /by-key (Part B) |
| 2,078 of 4,002 event updates were identical to the version before (228 MB of JSON) | None | Every run replaces whole documents, changed or not, and a JSON write always made a version | Fixed in 0.9.0: a write equal to the current version returns "unchanged": true and creates no version; trim the old repeats with retention |
| Date filters failed | Parsing dates in Python and JavaScript | MM/DD/YYYY strings | Data modeling: ISO dates and a schema; since 0.9.0 range filters compare ISO dates (Part B) |
| Files stayed stale at the edge | Build id in the cache key; 30-second edge TTL for HTML, JS and JSON | WREN used to send a long max-age | Fixed: max-age=60, stale-while-revalidate; errors no-store |
| A redesign needed a preview that worked on a phone | A copy under /preview/ in the public tree; after go-live, 12 paths and 11 documents deleted by hand | No label filter on the site rule; no atomic promote at the time | Available: labels and atomic promote (Part B) |
Listing the media collection stopped at 200 documents | None needed at the time | The list endpoint returns at most 200 | Use _query with cursor (paging fixed in 0.7.0) |
791 of 937 public media documents aren't part of any tree, but anyone can list them | None | A collection:media rule in addition to tree:site | A tree rule alone serves a site (Part B) |
| A WREN outage interrupted a deploy | A script that waits up to six hours, then redeploys | Server unreachable | Operational; by-key writes make re-runs safe |
| The local folder had to be rebuilt from WREN on a new machine | A documented manual bootstrap | Local files are the source; writes replace whole documents | Design choice, see Part B |
On Windows, slug\r broke ids, and CRLF in .env corrupted values | Stripping \r; pinning LF | Shell glue between bash and native Python | Fewer pipes once writes are single upserts |
Part B
The same site, built today
Same scraper, same app, same domain, same public URLs. What changes is that the workarounds above become WREN features. Existing tree URLs stay, because other projects and the site's llms.txt link to them.
1 · Keys and upserts
Put the slug into each document and let an admin declare it as the natural key, together with a schema that requires ISO dates. Run the proposed schema through POST /api/v1/events/_schema/validate first: it reports which existing documents would fail without changing anything. Then write each event once with a slug field. Since 0.9, setting the natural key registers the keys of the documents that already exist, so the first upsert by key finds the old event instead of creating a duplicate; if two live documents share a slug, the schema change is refused with 409 and lists them.
PUT /api/v1/events/_schema
{
"naturalKey": "slug",
"displayName": "{name}",
"schema": {
"type": "object",
"required": ["slug", "name", "startDate", "endDate"],
"properties": {
"slug": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"startDate": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" },
"endDate": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }
}
}
}The index, Olympic rankings and results map move to their own keyed collection (for example datasets), so events holds only events. The deploy step becomes one idempotent call per event. The client no longer needs to know what changed: since 0.9, a write whose JSON equals the current version (compared by meaning, so key order and whitespace don't matter) returns 200 with "unchanged": true and creates no version.
for f in events/data/*.json; do
slug=$(basename "$f" .json)
code=$(curl -s -o resp.json -w "%{http_code}" -X PUT \
"$WREN_URL/api/v1/events/by-key/$slug" \
-H "Authorization: Bearer $WREN_API_KEY" -H "Content-Type: application/json" \
--data-binary @"$f")
case $code in
200) jq -e .unchanged resp.json >/dev/null && echo "same $slug" || echo "updated $slug" ;;
201) mount_in_tree "$slug" ;; # new event
*) echo "PUT $slug failed: $code" >&2 ;;
esac
doneNo tree listing, no id map, and a run that dies halfway can simply run again. A 201 means the event is new, which is the only time a tree PUT is still needed to keep the /tree/events/{slug} URLs working. New readers can use GET /api/v1/orgs/{slug}/events/by-key/{slug} instead.
With WREN as the source of truth, a new machine doesn't need a bootstrap step. The pipeline reads the current document by key, merges in what it scraped, and writes it back.
2 · Public rules, on purpose
Entry lists change every day and should be visible as soon as they're written, so the data rules keep no label filter. Pages are different: they should change only when released. The media rule goes, because a tree rule alone serves every file in the tree.
# data: public and live, as today
{"principal":"*", "resource":"collection:events", "access":"read"}
{"principal":"*", "resource":"tree:events", "access":"read"}
# pages: only what was promoted
{"principal":"*", "resource":"tree:site", "access":"read", "labelFilter":"published"}
# removed: {"principal":"*", "resource":"collection:media", "access":"read"}Order matters. Label the live files first (wren promote site), then add the label filter. The other way round, the site is offline until the next promote. And before dropping the media rule, make sure nothing links to raw media URLs directly.
3 · Releasing pages: preview, then promote
The redesign was staged as a copy under /preview/ and cleaned up by hand afterwards. With a label filter on the site tree, a release looks like this:
export WREN_URL=https://wren.aemwip.com WREN_API_KEY=wren_… # from the environment, never in a script wren deploy ./site --tree site --label preview # changed files only (SHA-256) curl -H "Authorization: Bearer $WREN_API_KEY" \ "$WREN_URL/api/v1/tree/site/index.html?label=preview" # check it wren promote site --from preview --label release-2026-10-05 # name the release wren promote site --from preview # every page live in one transaction
The app, the Olympic page, the help page, llms.txt and the screenshots change together, so visitors never get a new app with an old help page. Rolling back is wren promote site --from release-2026-09-17 instead of a git tag and a version number on a sticky note. Unchanged files are stored once and create no new version.
4 · The index as a query
The events index is built by a script that reads every event file. Part of it is a plain projection, and a materialized query keeps that part current after every write:
PUT /api/v1/events/_materialized/upcoming
{
"query": { "select": ["slug", "name", "startDate", "endDate", "city", "country", "regDeadline"] },
"refreshOn": "write"
}The rest of the index is computed outside WREN: entry counts per country, registration trends, and bracket links found by probing the tracker site. Those fields stay in a document the script writes, and that document pulls the live list in with a $ref, so one read returns both:
{
"slug": "index",
"brackets": { "example-open-2026": ["https://tournaments.tkd-scores.com/…"] },
"events": { "$ref": "query:events",
"$select": ["slug", "name", "startDate", "endDate", "city", "country"],
"$limit": 200 }
}
// GET /api/v1/orgs/{slug}/datasets/by-key/index?depth=1With ISO dates, "what's coming up" is also a plain filter: since 0.9, range operators compare anything that isn't a number as text, so GET /api/v1/orgs/{slug}/events?where=endDate>=2026-10-05&select=slug,name,startDate works on the public route. Before, every reader parsed MM/DD/YYYY in its own code.
References resolve under the reader's own rules, so a public reader only ever sees public data through them. A club website that just wants a list of upcoming events doesn't need the app at all:
<script src="https://wren.aemwip.com/wren.js"
data-base="https://wren.aemwip.com/api/v1/orgs/{slug}"></script>
<ul>
<wren-materialized collection="events" name="upcoming">
<template><li>{{name}}, {{city}} ({{startDate}})</li></template>
</wren-materialized>
</ul>5 · The domain and its agents
The Worker keeps mapping the domain onto /orgs/{slug}/tree/site. Two things change. Its cache workarounds can go: WREN now sends max-age=60, stale-while-revalidate on public reads and no-store on errors, which the live site already shows. And /api/* should only reach public reads. Today it forwards any method with its headers, so the domain also works as an alias of the private API for anyone who sends a key.
// public data only: GET, and POST for _query, under /api/v1/orgs/{slug}/
const isPublic = path.startsWith("/api/v1/orgs/{slug}/") &&
(request.method === "GET" || (request.method === "POST" && path.endsWith("/_query")));
if (path.startsWith("/api/") && !isPublic) return new Response("Not found", { status: 404 });
// AI agents: this domain's own MCP endpoint
if (path === "/mcp") return fetch(env.MCP, { method: request.method, headers: mcpHeaders(request),
body: request.method === "POST" ? request.body : undefined });
// wrangler.toml
// MCP = "https://wren.aemwip.com/orgs/{slug}/mcp?tree=events&collections=events"Without a key, an agent connected to https://data.tkd-scores.com/mcp gets read-only tools over exactly what the domain publishes, and can run the same _query aggregations as the search box. That replaces "read llms.txt, then fetch JSON by hand" for clients that speak MCP; llms.txt stays for those that don't. With a key from the site's org, the owner's own agents get full tools; keys from other orgs are refused.
Agents write to live data here. MCP writes label the new version preview, but the events rule has no label filter, so the new version is public at once. Give agents that only need to read a key narrowed to read access with key: rules.
6 · Reacting to changes instead of polling
The tracker's daily job looks for events whose registration has closed. A webhook on the events collection tells it when something changed; deliveries are batched over five seconds, signed with HMAC, retried, and can be replayed for seven days:
POST /api/v1/webhooks
{ "url": "https://hooks.example.com/tkd-events", "events": ["document.created", "document.updated"] }
// payload events carry collection, id, version and key (the slug), never the dataThe receiver must be a public HTTPS address. The tracker's job runs on a desktop machine today, so it either keeps its daily check or gets a small public receiver. For a browser, live events need no setup at all: an open event page can listen to the public stream and reload its document when a run updates it. Because unchanged writes create no version, a run that finds nothing new sends no events at all.
const es = new EventSource("https://wren.aemwip.com/orgs/{slug}/_events?collections=events");
es.onmessage = e => {
const ev = JSON.parse(e.data);
if (ev.type === "document.updated" && ev.key === currentSlug) reloadEvent(currentSlug);
if (ev.type === "reset") reloadEvent(currentSlug); // missed events: re-read
};A receiver that wants to know what changed, say who entered since yesterday, can ask WREN instead of comparing files. With deep=true (0.9), the diff walks nested objects and arrays and reports each changed leaf:
GET /api/v1/events/{id}/diff?v1=130&v2=131&deep=true
→ { "op": "add", "path": "/report/divisions/2/categories/4/unranked/7", … }
{ "op": "replace", "path": "/history/41/total", "value": …, "oldValue": … }7 · Keeping history in check
Each run rewrote every event it touched, changed or not, so history grew either way. Since 0.9, identical writes create no version, so new repeats stop without any client code. For the history that's already there, a retention policy on events trims old versions; the current version and every labeled version are always kept:
POST /api/v1/retention/events/_preview {"maxVersions": 30} # what would go, changes nothing
POST /api/v1/events/{id}/labels {"label": "final"} # keep a finished event's last entry list
PUT /api/v1/retention/events {"maxVersions": 30} # then hourlyOn the 2026-10-05 backup, maxVersions: 30 would remove 1,754 of 4,096 event versions, 121 MB of the 440 MB of JSON.
Versions were once the history. The registration curves were first rebuilt from version history, and the backfill script that did it replaces each event's history with what it finds in the versions. The curves now live in the documents, so trimming loses nothing the site shows, but retire that script before the first trim.
Files are stored once per content since 0.8.0. The site's own files gain little, since every version of them is different. The org as a whole does: its media collection has 1,047 file versions taking 118 MB, with only 338 distinct contents (about 14 MB).
Side by side
| Concern | As built | Built today |
|---|---|---|
| Finding a document | tree listing, slug-to-id map | by-key/{slug} |
| Writing an event | POST + tree PUT, or PUT by id | PUT /by-key/{slug}, changed files only |
| Source of truth | local JSON folder | WREN |
| Dates | MM/DD/YYYY, parsed in code | ISO, enforced by schema |
| Index | built by a script | materialized query plus a $ref |
| Page releases | live on upload; /preview/ copy | preview label, atomic promote, named releases |
| Edge caching | build id in cache key, 30 s TTL | WREN's headers |
| Public surface | all of media listable | only the trees and events |
| AI assistants | llms.txt, fetch by hand | llms.txt plus /mcp on the domain |
| Other projects | daily polling | webhooks or live events |
| History size | every run adds a version | unchanged writes add none; retention policy |
Still open
- Keys and paths are separate. An upsert by key doesn't mount a tree path. As long as the
/tree/events/{slug}URLs are kept, a new event still needs one treePUT. - Previews on a phone. A labeled preview is only visible with a key. The redesign was staged on the public domain because a phone couldn't reach the development machine. Checking a preview on a phone needs a signed-in browser or a private proxy that adds the key.
- Natural keys need an admin. That's intentional, but it is a one-time step before the pipeline can upsert.
- Overlapping runs. With WREN as the source of truth, the pipeline reads an event, merges and writes it back. Since 0.10 the write can carry the version it read (
If-Match), so an overlapping run gets412instead of overwriting; it re-reads and merges again. Keep the single-instance lock anyway: it avoids the wasted work.
Lessons
- Put the key in the data, not only in the URL. A slug that exists only as a tree path forced every deploy to rebuild a lookup table.
- Know what your writes cost. Half of this project's history is repeats. WREN now drops identical writes for you, but a pipeline that rewrites everything still pays for it in requests and time.
- Treat data and pages differently. Data: public, no label filter, live on write. Pages:
labelFilter: "published", preview and promote. - Make only what you mean public. A tree rule serves a site. A collection rule on the file store also publishes everything else in it.
- Let WREN be the source of truth. Local files as the source made moving machines a risk to live data.
- Describe your data for machines. A hand-written
llms.txtmade the data usable by assistants and other projects with no further work; an MCP route on the domain is the next step.
New to these ideas? Start with Concepts, or read the live tracker case study for the sibling site and websites maintained by AI agents for how this site's releases are made.