Patterns
I did this. Is there a better way?
Every entry below is something a real app on WREN did, most of them written by AI coding agents. Each one was a reasonable answer to the WREN of its time. Each entry says why it hurts and what to do instead, with the version where the better way arrived, and links to the case study where it happened.
Writing data
“Before creating a document, I search for it, then PUT or POST.”
Why it hurts: two calls, not atomic, and a failed search or a race creates a duplicate. Variants of the same thing: a slug-to-id map built by listing a tree before every deploy, ids saved in a local file.
Better: declare a natural key once (an admin does this), then write with one idempotent upsert.
PATCH /api/v1/events/_schema { "naturalKey": "slug" } ← once, admin
PUT /api/v1/events/by-key/swiss-open-2026 { "slug": "swiss-open-2026", … } ← every runSetting a natural key registers the keys of documents that already exist, so they're found rather than duplicated (0.9). More in Update without duplicates. Seen in: tournament tracker, tkd-scores.
“I read the document, change a field and write it back. Sometimes a change disappears.”
Why it hurts: two writers that read the same version both write; the later one silently replaces the earlier one's change. In the stock tracker, two devices moving the same item within a second lost a movement. A comment added by an agent and one added on the board at the same moment can do the same.
Better: make the write conditional on the version you read (0.10). On 412, read again and re-apply your change.
GET /api/v1/stock/abc → ETag: "7"
PUT /api/v1/stock/abc If-Match: "7" → 200 (version 8), or 412 { "currentVersion": 8 }Never compute a write from a cached read: an offline cache or a service worker can hand you an old version. Seen in: stock tracker, task tracker, tkd-scores.
“Re-running my seed or import script would overwrite what people changed since.”
Why it hurts: you end up treating the script as a one-off you're afraid to run again.
Better: a create-only upsert: PUT …/by-key/{key}?ifVersion=0 creates the document if it doesn't exist and returns 412 if it does (0.10). Re-runs fill gaps and leave live data alone. Seen in: stock tracker.
“My script sets the schema on every run, and settings someone changed in the Admin UI keep disappearing.”
Why it hurts: PUT …/_schema replaces the whole definition: display name, list columns, indexes and natural key.
Better: PATCH …/_schema changes only the fields you send (0.10). Better still, set schemas once, deliberately. Seen in: stock tracker.
“Every run rewrites every document, changed or not.”
Why it hurts: each write used to add a version. One pipeline's history was half identical repeats: 2,078 of 4,002 updates, 228 MB of JSON.
Better: nothing to change in the script. Since 0.9, a write equal to the current version returns "unchanged": true and adds no version; identical file uploads do the same since 0.8. Add ?force=true if you really want a version. Trim the repeats already stored with a retention policy. Seen in: tkd-scores, AI-maintained sites.
“Autosave writes a draft every few seconds, and history keeps growing.”
Why it hurts: every write keeps the previous version. One drafts collection reached 477 versions, 217 of them on a single document.
Better: a retention policy for that collection (0.8). Current and labeled versions are always kept.
POST /api/v1/retention/drafts/_preview { "maxVersions": 5, "maxAgeDays": 14 } ← see what would go
PUT /api/v1/retention/drafts { "maxVersions": 5, "maxAgeDays": 14 }Seen in: stock tracker, test data.
“I set a label on every version that matters, as an audit trail.”
Why it hurts: it isn't needed. Every write is already a version, with who wrote it and when. A label that arrives halfway through, or whose failures are ignored, only marks some of the versions it was meant to.
Better: a label is a name for one version worth coming back to: published, counted, release-12, fixture-2026-10-01. Decide labels before the first write. A wrong label can be removed (0.9). Seen in: stock tracker.
Reading and querying
“I store each date twice, once as a number (20261005), so I can filter by range.”
Why it hurts: two fields to keep in sync, and every writer has to know. The same goes for parsing MM/DD/YYYY in every reader.
Better: store ISO dates (2026-10-05 or full timestamps). Since 0.9, range filters compare anything that isn't a number as text, which sorts ISO dates correctly: ?where=endDate>=2026-10-05. Seen in: task tracker, tkd-scores, tournament tracker.
“I ask for ?limit=1000 and use whatever comes back.”
Why it hurts: the list endpoint returns at most 200, newest first, so the oldest documents look missing. One seed script concluded existing stock was absent and would have recreated it. _query caps a page at 1,000.
Better: compare with total and page with offset, or use POST /{collection}/_query and follow its cursor until there is none. Seen in: stock tracker, tournament tracker, task tracker.
“Every page loads all documents and computes the summary itself.”
Why it hurts: slow, and each client can get it slightly differently.
Better: _query with aggregate (count, sum, min, max, avg, grouped) runs it in the database. If many readers need the same answer, a materialized query stores it and refreshes on every write: PUT /{collection}/_materialized/{name}. If you set one up, read it: one app refreshed a materialized query on every write that nothing ever read. Seen in: tournament tracker, task tracker.
“WREN's diff said the whole object changed, so I wrote my own field-by-field diff.”
Better: GET …/diff?v1=fixture&v2=7&deep=true reports each changed nested field and array element, and accepts label names (0.9). Seen in: test data.
Reacting to changes
“The page polls every few seconds” or “there's a Refresh button”.
Better: live events (0.7). One stream per page, filtered to what it shows; events carry ids and versions, so re-read what changed. Public streams follow the public rules, so drafts stay invisible.
const es = new EventSource("/orgs/acme/_events?collections=stock");
es.onmessage = e => { const ev = JSON.parse(e.data); if (ev.type !== "ready") refresh(ev); };Seen in: stock tracker, task tracker.
“Another system gets our data by someone running a script (or an agent passing it along).”
Why it hurts: the copy is as old as the last run. In one shop, stock counts were 18 days old.
Better: a webhook on the collection, so the other system is told within seconds and re-reads with a key that can only read that collection. Deliveries are batched, signed, retried and replayable.
POST /api/v1/webhooks { "url": "https://shop.example/hooks/stock", "events": ["document.updated", "document.created"] }Seen in: stock tracker.
Files and sites
“I upload files with POST and keep track of their ids” or “by-key returned 404 for my uploads, so I got duplicates”.
Better: files by name (0.9). Key a file collection by filename and upload with PUT …/by-key/{name}: created, replaced or unchanged, never duplicated.
PATCH /api/v1/invoices/_schema { "collectionType": "binary", "naturalKey": "filename" }
PUT /api/v1/invoices/by-key/2026-10-05-0042.pdf (multipart, field "file")
GET /api/v1/invoices/by-key/2026-10-05-0042.pdf/rawSeen in: AI-maintained sites, stock tracker.
“Every deploy uploads every file” or “I keep my own manifest of hashes”.
Better: wren deploy ./dist --tree site compares SHA-256 hashes and uploads only what changed; an identical upload adds no version anyway (0.8). Seen in: tournament tracker, AI-maintained sites.
“To preview a redesign, I copied the site under /preview/ in the live tree and deleted the copies afterwards.”
Why it hurts: the preview is public, and the clean-up is by hand (12 paths and 11 documents, in one case).
Better: give the public rule a label filter, deploy with a label, look at it, then promote. Promote is one transaction, so readers never see half a release.
wren deploy ./dist --tree site --label preview # private: the public rule shows only "published" # check it: GET …/tree/site/index.html?label=preview (with a key), or on a phone via a private link wren promote site --from preview # now everyone sees it
More in Publish a site. Seen in: AI-maintained sites, tkd-scores.
“To roll back, I note which document version each file was on, plus a git tag.”
Better: name each release when you promote it (wren promote site --from preview --label release-12). To go back, restore the tree to that label in one transaction (0.9): wren restore --tree site --label release-12, then promote. Seen in: AI-maintained sites.
“I add ?v= stamps or a build id to every URL, because the edge kept old files.”
Better: usually not needed any more. Public responses are max-age=60 with stale-while-revalidate, and errors are no-store, so a 404 is no longer cached for hours. If a CDN or Worker sits in front, let it follow WREN's Cache-Control instead of setting its own. Seen in: tournament tracker, tkd-scores, task tracker.
“Public pages need data, so a Worker holds a key and forwards the calls.”
Why it hurts: a key at the edge, a proxy to maintain, and if it forwards all of /api/*, more than you meant to expose.
Better: if the data is meant to be public, make it public with a rule, ideally with a label filter, and read it from the browser: public routes send Access-Control-Allow-Origin: *. A domain Worker then needs no key and only maps public paths. Seen in: tournament tracker, tkd-scores; see custom domain.
“Product images are private, so the shop copies them from somewhere else.”
Better: keep what's operational private and publish only what's meant for visitors: a public rule on that file collection, and since 0.10 its files are served by name at /orgs/{slug}/{collection}/by-key/{name}/raw. Seen in: stock tracker.
“I made the media collection public as well as the site tree.”
Why it hurts: anyone can then list the whole collection, including the files no page uses (791 of 937 in one case).
Better: a tree:site rule alone serves everything mounted in the tree. Seen in: tkd-scores.
“To rename a site I created a second tree, and the old one still serves a stale page.”
Better: an alias on the public rule adds a second name for the same tree without a copy. Seen in: AI-maintained sites.
Keys and access
“All scripts and agents share one key, the owner's.”
Why it hurts: each of them can do anything the owner can, history can't tell them apart, and revoking it stops everything at once.
Better: one key per job, narrowed with key: rules to what it needs (0.7). A key with rules of its own uses only those.
POST /api/v1/keys { "name": "stock-sync" } → { "id": "…", "key": "wren_…" }
POST /api/v1/permissions { "principal": "key:<id>", "resource": "collection:stock", "access": "read" }For agents, MCP with browser sign-in avoids handing them a key at all; see MCP. Seen in: stock tracker, task tracker.
“The key is in the script” (or on curl's command line, or in a prompt).
Why it hurts: it ends up in git, in shell history, or copied into every summary of a long agent session.
Better: environment variables or an ignored file, never the repo or the conversation. If one leaked, create the replacement first, switch every user of it, then revoke the old one. Narrowed keys limit the damage. Seen in: AI-maintained sites, stock tracker.
“Everything is recorded under one user, whether a person or a script wrote it.”
Better: invite people, give them groups (Editors, Viewers, or your own) (0.6), and give each script its own key. Then each version says who actually wrote it. Seen in: stock tracker.
Test data
“To snapshot test data, I label each document one by one” (hundreds of requests).
Better: mount the fixtures in a tree and label them all in one transaction: POST /api/v1/tree/fixtures/_promote {"label": "fixture-2026-10-01"}. Seen in: test data.
“After a test run, I roll back every document separately, with retries.”
Better: one transactional restore (0.9). Documents created by the test can go too.
POST /api/v1/members/_restore { "label": "fixture-2026-10-01", "deleteUnlabeled": true }
POST /api/v1/tree/fixtures/_restore { "label": "fixture-2026-10-01" }Seen in: test data.
“Each test run sets the test-snapshot label again at the start.”
Why it hurts: setting a label moves it, so a run after a failed one snapshots the broken state.
Better: dated fixture labels that tests never set again; move a separate fixture-current label when you change fixtures. And use an org per test suite (with its own keys), not the org your other projects live in. Seen in: test data.
Small gotchas
| What happened | What to do |
|---|---|
Guessed routes like /api/me returned 404 | Everything authenticated is under /api/v1 (/api/v1/me). Point agents at /llms.txt, which starts with a URL map. |
A collection named projects collided with a WREN route | Avoid projects as a collection name; /api/v1/projects is the public directory. |
Keys containing / broke by-key URLs | URL-encode keys (encodeURIComponent, quote(key, safe="")), or use letters, digits, - and :. |
Python scripts got 403 error code: 1010 | Not WREN: a Cloudflare browser check in front of the server. Send a User-Agent, or relax the check for /api. |
slug\r in ids, broken .env values on Windows | Line endings in shell glue. Pin LF; fewer pipes once writes are single upserts. |
| Deploys stopped when the CLI's login expired | Use an API key: WREN_API_KEY=… wren deploy …. |
Found a workaround that isn't here? It's probably a gap in WREN. Open an issue at github.com/usewren.