# Wren — Complete LLM Reference > Self-hosted versioned JSON storage. Every write creates a new immutable version. Postgres-backed. Deploy with Docker. This document is the complete reference for LLMs and AI agents working with the Wren API. It covers authentication, every endpoint with request and response shapes, error handling, and both client libraries. --- ## Overview Wren stores JSON documents in named collections. Every mutation creates a new version — the old data is never overwritten. Versions can be listed, diffed pairwise, labeled, or rolled back. Each organization gets an isolated Postgres schema. The REST API is the primary interface; official clients exist for TypeScript and Python. Base URL: configured at deploy time (e.g. `https://wren.example.com`). All paths below are relative to the base URL and are shown in full — including the `/api/v1` prefix. ### URL map — the three URL families | Family | URL shape | Auth | Org comes from | |---|---|---|---| | Private API (read + write) | `/api/v1/{collection}…`, `/api/v1/tree/{tree}/{path}`, `/api/v1/me`, `/api/v1/keys`, `/api/v1/permissions`… | `Authorization: Bearer wren_…` or session cookie | the API key / session — never the URL | | Public data (read-only) | `/api/v1/orgs/{slug}/{collection}…`, `/api/v1/orgs/{slug}/tree/{tree}/{path}` | none | `{slug}` in the URL | | Public site (read-only) | `/orgs/{slug}/tree/{tree}/{path}` (GET-only alias of the line above) | none | `{slug}` in the URL | - Every authenticated route starts with `/api/v1/`. Unversioned paths (`/collections`, `/{collection}`, `/api/me`, `/api/keys`, `/api/permissions`…) return **404**. - Public URLs return only what a `principal='*'` read rule allows (otherwise **403**), ignore any auth header, and are read-only: anything but GET (and `POST …/_query`) returns **405**. - Your org slug: `GET /api/v1/me` → `org.slug`. Each public org also serves `GET /orgs/{slug}/llms.txt`. - Give people `https:///orgs/{slug}/tree/{tree}/index.html`; in code use `/api/v1/orgs/{slug}/…`. - See "Public access" below for the step-by-step recipe. --- ## Authentication ### Bearer token (API key) — recommended for programmatic access ``` Authorization: Bearer wren_ ``` Create keys at: Admin UI → Settings → API Keys, or via `POST /api/v1/keys`. Keys are shown once at creation. Prefix format: `wren_` followed by random hex. ### Session cookie — used by browser / CLI After `POST /api/auth/sign-in/email`, the response sets `better-auth.session_token=`. Subsequent requests authenticate by including this cookie. --- ## Operational behavior Guarantees and non-guarantees agents commonly need before they act: - **Rate limits.** No rate limits are enforced by default. No `X-RateLimit-*` or `Retry-After` headers are sent. Deployments may put a reverse proxy in front with its own limits; that is out of band. - **Idempotency.** `POST /api/v1/{collection}` is **not** idempotent — each call creates a new document. There is no `Idempotency-Key` header. If deduplication matters, declare a `naturalKey` on the collection schema and use `PUT /api/v1/{collection}/by-key/{value}` (which is idempotent). For raw uploads, check for an existing document by natural key or label before creating. - **Dry-run.** There is no `?dry_run=true` flag on destructive operations. To preview: `GET` the document before `DELETE`, or `GET /api/v1/{collection}/_schema` and run `POST /api/v1/{collection}/_schema/validate` before `PUT`ting a new schema. - **Retries on 5xx.** `GET`, `PUT /{id}`, `DELETE /{id}`, and `PUT /by-key/{value}` are safe to retry — each is idempotent at the resource level (PUT overwrites, DELETE converges to deleted). `POST /api/v1/{collection}` is not — retrying after a 5xx may produce duplicate documents. - **Writes are transactional.** Each mutation (create, update, delete, rollback, label change) commits atomically with its version row. Partial writes are not observable. - **Soft delete.** `DELETE` creates a tombstone and makes the document 404 on subsequent reads. Nothing is physically removed. The UUID is not reused. - **Version history is kept** unless the org sets a retention policy (see "Retention policies"), which permanently removes old versions it matches. A document's current version and every labeled version are never removed. --- ## Common workarounds and the better way Things real apps on WREN did, and what to do instead. Details: /guides/better-ways. | If you're about to… | Do this instead | |---|---| | search for a document, then PUT or POST it | declare `naturalKey` once (admin), then `PUT /api/v1/{collection}/by-key/{key}` | | read, modify and PUT back a document others also write | send `If-Match: ""` (or `?ifVersion=n`); on 412 re-read and re-apply. Never compute a write from a cached read | | re-run a seed/import that could overwrite live edits | `PUT …/by-key/{key}?ifVersion=0` (create-only) | | PUT the schema on every run | `PATCH /api/v1/{collection}/_schema` with only the fields to change; better, set it once | | skip writes yourself to avoid empty versions | not needed: an identical JSON write or file upload returns `unchanged: true` and adds no version | | label versions as an audit trail | every write is already a version with author and time; labels name versions to come back to | | store dates twice (e.g. `20261005`) to range-filter | store ISO dates; `where=date>=2026-10-05` compares them as text | | request `?limit=1000` and trust the result | lists return at most 200 (newest first): page with `offset`/`total`, or `_query` with `cursor` | | load all documents to compute totals in the client | `_query` with `aggregate`, or a materialized query | | poll for changes | live events: `/api/v1/_events` or `/orgs/{slug}/_events` | | copy data to another system with a script run by hand | a webhook, plus a key narrowed to read that collection | | POST files and track their ids | key the file collection by `filename`; `PUT …/by-key/{name}` (multipart) | | copy a site under /preview/ in the live tree | public rule with `labelFilter: "published"`; `wren deploy --label preview`, then `wren promote --from preview` | | note document versions to roll a site back | promote with a release label; `wren restore --tree site --label release-12` | | add `?v=` cache busters | WREN sends `max-age=60` and `no-store` on errors; let proxies follow it | | put a key in a Worker so public pages can read data | make that data public with a rule (with a label filter) and read it directly (CORS *) | | make a collection public next to the site tree | a `tree:` rule alone serves the site; a collection rule lets anyone list everything in it | | give every script and agent the owner's key | one key per job narrowed with `key:` rules; MCP browser sign-in for agents | | label each fixture document in a loop / roll each back after a test | `POST /api/v1/tree/{name}/_promote {"label":"fixture-…"}`; `POST /api/v1/{collection}/_restore {"label":…, "deleteUnlabeled":true}` | | re-set the same snapshot label at the start of each test run | dated fixture labels that are never re-set; one org per test suite | | let autosave or sync scripts grow history forever | a retention policy (`_preview` first) | ## Error responses All errors return JSON: ```json { "error": "human-readable message" } ``` Validation errors (422) also include: ```json { "error": "Schema validation failed", "details": ["field: message", …] } ``` | Status | Meaning | |--------|---------| | 400 | Bad request — missing or invalid body fields | | 401 | Unauthorized — missing or invalid credentials | | 403 | Forbidden — authenticated but not permitted | | 404 | Not found | | 409 | Conflict (e.g. invite already accepted) | | 410 | Gone (e.g. invite expired or revoked) | | 422 | Schema validation failed | --- ## Collections ### List collections ``` GET /api/v1/collections Authorization: Bearer wren_… Response 200: { "collections": [ { "name": "articles", "count": 42, "updatedAt": "2026-04-06T12:00:00Z" } ] } ``` An empty org returns 200 with `{"collections": []}`, not 404. Any authenticated principal (session or valid API key) sees every collection in the org; this endpoint is not per-collection gated, so a narrowly-scoped key will still see collection names it cannot read from. 401 is returned only when no (or invalid) credentials are sent. --- ## Documents ### List documents in a collection ``` GET /api/v1/{collection} Query params: label — return only docs where this label is set (e.g. label=published); a permission rule's labelFilter takes precedence over this where — filter on data fields: path op value, joined with AND / OR ops: : = != > >= < <= ~* !~* @> e.g. where=category:golf AND year>=2024 select — comma-separated fields to return, e.g. select=title,author.name limit — max results, default 50, max 200 offset — skip N results (pagination) Response 200: { "collection": "articles", "total": 42, "items": [ , … ] } For cursor pagination and aggregation use POST /api/v1/{collection}/_query. ``` ### Create a document ``` POST /api/v1/{collection} Content-Type: application/json { "title": "Hello", "body": "World", "category": "news" } Response 201: ``` ### Get a document (current or labeled version) ``` GET /api/v1/{collection}/{id} GET /api/v1/{collection}/{id}?label=published ← returns the version pinned to that label Response 200: Response 404: not found or deleted ``` ### Update a document (creates a new version) ``` PUT /api/v1/{collection}/{id} Content-Type: application/json { "title": "Updated title", "body": "Updated body" } Response 200: ← version is incremented ``` **Unchanged writes create no version.** If the data equals the current version (compared by meaning: key order and whitespace don't matter), the response is `200` with the current `version` and `"unchanged": true`, and nothing is written. The same holds for upserts by key and for file uploads with the same bytes, name and type. Add `?force=true` to write a version anyway. ### Delete a document (soft delete — creates tombstone) ``` DELETE /api/v1/{collection}/{id} Response 200: { "id": "uuid", "deleted": true } ``` ### Conditional writes (avoid lost updates) ``` GET /api/v1/stock/abc → 200, ETag: "7", { "version": 7, … } PUT /api/v1/stock/abc If-Match: "7" { …new data… } → 200 version 8 PUT /api/v1/stock/abc If-Match: "7" (someone else already wrote v8) → 412 { "error": "Version mismatch", "currentVersion": 8 } ``` A read-then-write is safe when the write carries the version it read: `If-Match: ""` (or `?ifVersion=`) on `PUT` by id, `PUT …/by-key/{key}` (JSON or file), and `DELETE`. On `412`, read again and re-apply the change. `ifVersion=0` means "only if it doesn't exist yet" — a create-only upsert, so re-running an import can't overwrite live data — and `If-Match: *` means "only if it exists". Reads return the version as `ETag`; browsers on other origins may send `If-Match` and read `ETag`. ### Undelete a document ``` POST /api/v1/{collection}/{id}/undelete Response 200: { "id": "uuid", "undeleted": true, "version": 4 } ``` Brings a deleted document back with its whole history (emits `document.undeleted`). `404` if no deleted document has that id; `409` if another document took its natural key meanwhile. ### Natural keys — upsert / read / delete by external identifier A collection can declare one document field as its natural key (commonly `slug`). Once declared, the `by-key/{value}` routes address documents by that value instead of the UUID. `PUT /by-key/{value}` is a **transactional upsert** — create if absent, update if present — which makes it the idempotent alternative to `POST` for ingestion scripts. Declare a natural key on the collection schema: ``` PUT /api/v1/{collection}/_schema { "schema": { … }, "naturalKey": "slug" } ``` Upsert (create-or-update) by key: ``` PUT /api/v1/{collection}/by-key/{value} Content-Type: application/json { "slug": "hello", "title": "Hello" } ← data[naturalKey] must match {value} in the URL Response 201: ← created Response 200: ← updated (new version) Response 400: body's natural-key field disagrees with URL {value} Response 409: another document already owns this key (rename collision) ``` Get by key: ``` GET /api/v1/{collection}/by-key/{value} GET /api/v1/{collection}/by-key/{value}?label=published Response 200: Response 404: no document with that key ``` Soft delete by key: ``` DELETE /api/v1/{collection}/by-key/{value} Response 200: { "id": "uuid", "deleted": true } ``` ### Get tree paths for a document ``` GET /api/v1/{collection}/{id}/paths Response 200: { "id": "uuid", "collection": "articles", "paths": [ { "tree": "main", "path": "/blog/hello-world" } ] } ``` ### DocumentResponse shape ```json { "collection": "articles", "id": "550e8400-e29b-41d4-a716-446655440000", "version": 3, "labels": ["published", "featured"], "createdAt": "2026-04-01T10:00:00Z", "updatedAt": "2026-04-06T14:30:00Z", "data": { "title": "Hello", "body": "World" } } ``` --- ## Versions ### List version history ``` GET /api/v1/{collection}/{id}/versions Response 200: { "collection": "articles", "id": "uuid", "versions": [ { "version": 1, "labels": [], "createdAt": "2026-04-01T10:00:00Z", "createdBy": "user-id" }, { "version": 2, "labels": [], "createdAt": "2026-04-02T11:00:00Z", "createdBy": "user-id" }, { "version": 3, "labels": ["published"], "createdAt": "2026-04-06T14:30:00Z", "createdBy": "user-id" } ] } ``` ### Get a specific version ``` GET /api/v1/{collection}/{id}/versions/{n} Response 200: ← data is the state at version n ``` ### Rollback to a previous version Creates a new version whose data matches the target version. Does not delete history. ``` POST /api/v1/{collection}/{id}/rollback/{n} Response 200: { "id": "uuid", "version": 4, "rolledBackTo": 1 } ``` ### Diff two versions ``` GET /api/v1/{collection}/{id}/diff?v1=1&v2=3 Response 200: { "id": "uuid", "collection": "articles", "v1": 1, "v2": 3, "diff": [ { "op": "replace", "path": "/title", "value": "New title", "oldValue": "Old title" }, { "op": "add", "path": "/publishedAt", "value": "2026-04-06" }, { "op": "remove", "path": "/draft" } ] } ``` `op` is one of `"add"`, `"remove"`, `"replace"`. `path` is a JSON Pointer (RFC 6901). - `v1`/`v2` take a version number or a label name: `?v1=published&v2=preview` compares what's live with what's staged. - By default only top-level fields are compared (a changed nested object is one `replace`). `&deep=true` walks nested objects and arrays and reports each changed leaf: `{ "op": "replace", "path": "/venue/city", … }`, `{ "op": "add", "path": "/tags/2", … }`. --- ## Labels Labels are named pointers to a specific version. Setting a label again moves the pointer. ### Set a label ``` POST /api/v1/{collection}/{id}/labels { "label": "published" } ← pins to current version { "label": "published", "version": 2 } ← pins to specific version Response 200: { "id": "uuid", "label": "published", "version": 3 } ``` Common label names: `published`, `draft`, `staging`, `featured`, `archived`. Fetch a labeled version: `GET /api/v1/{collection}/{id}?label=published` --- ## Retention policies Every write keeps the old version, so history grows without bound. A retention policy removes old versions you don't need. Policies are per org: one **org default** (`*`) and, optionally, a policy per collection that **replaces** the default for that collection. Only org owners and admins can read or change them (others get **403**). **Always kept, whatever the rules say:** a document's **current version** and **every version a label points to**. Of the remaining versions, one is removed if **any** rule of its collection's policy says so: | Rule | Meaning | |---|---| | `labeledOnly: true` | Keep only labeled versions | | `maxVersions: n` | Keep the newest n versions (the current one counts) | | `maxAgeDays: n` | Remove versions older than n days | | `afterLabel: "name"` | Remove versions older than the version the label `name` points to (per document; documents without that label are untouched by this rule) | A policy with **no rule** (`{}`) keeps everything; use it to exempt a collection from the org default. Internal collections (names starting with `_`) can't have a policy and are never touched. Policies run **hourly**, and on demand with `_apply`. Removal is permanent: a removed version returns **404** (`GET …/versions/{n}`), and version numbers keep their gaps (e.g. 1, 4, 5; numbers are never reused). File bytes are freed once no remaining version uses them. Always `_preview` before saving or applying. ### Get policies and recent runs ``` GET /api/v1/retention Response 200: { "default": { "collection": "*", "labeledOnly": false, "maxVersions": 20, "maxAgeDays": null, "afterLabel": null, "updatedAt": "2026-10-05T09:00:00Z", "updatedBy": "user-id" }, ← null when there is no default "collections": [ { "collection": "contracts", "labeledOnly": false, "maxVersions": null, "maxAgeDays": null, "afterLabel": null, … } ← exempt ], "runs": [ ← the last 20 runs that removed something { "collection": "articles", "versionsRemoved": 120, "bytesFreed": 482113, "triggeredBy": "schedule", "ranAt": "2026-10-05T10:00:00Z" } ] } ``` `triggeredBy` is the user id of whoever called `_apply`, or `"schedule"` for the hourly run. ### Set a policy ``` PUT /api/v1/retention/{collection} ← or /api/v1/retention/* for the org default { "maxVersions": 20, "maxAgeDays": 90 } Response 200: ← same shape as in GET Response 400: invalid rule (e.g. maxVersions < 1) or a name starting with "_" ``` `PUT` replaces the whole policy: rules left out are switched off. `maxVersions` and `maxAgeDays` are whole numbers ≥ 1 or `null`; `labeledOnly` is a boolean; `afterLabel` is a label name or `null`. ### Remove a policy ``` DELETE /api/v1/retention/{collection} ← or * for the org default Response 200: { "collection": "articles", "deleted": true } Response 404: no policy for that collection ``` Without its own policy a collection follows the org default; without a default nothing is removed. ### Preview (changes nothing) ``` POST /api/v1/retention/{collection}/_preview ← the saved policy POST /api/v1/retention/{collection}/_preview ← or a proposed one, before saving it { "maxVersions": 5 } Response 200: { "collections": [ { "collection": "articles", "versions": 120, "documents": 14, "bytes": 482113 } ], ← only collections with something to remove "total": { "versions": 120, "documents": 14, "bytes": 482113 } } ``` For `*`, the preview covers every collection that has no policy of its own. ### Apply now ``` POST /api/v1/retention/_apply Response 200: same shape as the preview — what was actually removed ``` ### Example: keep 20 versions everywhere, keep everything in `contracts` ```bash curl -X POST "$WREN/api/v1/retention/*/_preview" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"maxVersions":20}' curl -X PUT "$WREN/api/v1/retention/*" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"maxVersions":20}' curl -X PUT "$WREN/api/v1/retention/contracts" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{}' ``` Pin anything you must keep with a label first (e.g. `published`, `release-2026-10`): labeled versions survive every rule. Also in the Admin UI (Settings → Retention), the CLI (`wren retention get|set|preview|remove|apply`), and both clients (`wren.retention.get/set/remove/preview/apply`). --- ### Remove a label ``` DELETE /api/v1/{collection}/{id}/labels/{label} Response 200: { "id": "uuid", "label": "snapshot", "removed": true, "version": 3 } ``` ### Restore a collection or tree to a label ``` POST /api/v1/{collection}/_restore { "label": "fixture", "deleteUnlabeled": true } POST /api/v1/tree/{name}/_restore { "label": "release-12" } Response 200: { "label": "fixture", "restored": 3, "undeleted": 1, "deleted": 2, "unchanged": 40, "collections": ["members"] } ``` One transaction for the whole set: every document that carries the label gets a new version with the labeled content (unless it already matches — files included, by hash), deleted ones come back, and with `deleteUnlabeled: true` documents without the label (made since it was set) are deleted. History moves forward, so the restore itself can be undone. Use it to reset test fixtures after a run, or to undo a bad import or release. A tree restore needs write access to every collection in the tree. `404` if no document has the label; `409` (nothing changed) if it would give two live documents the same natural key. ## Query API The list endpoint and a dedicated `_query` endpoint support SQL-level filtering, field projection, and aggregation — replacing the need for hand-built index documents. ### Projection and filtering on the list endpoint ``` GET /api/v1/{collection}?select=title,category&where=category:news&limit=10 Response 200: { "collection": "articles", "items": [{ "id": "…", "version": 1, "data": { "title": "…", "category": "news" } }], "total": 3 } ``` `?select=` returns only the named fields from `data`. `?where=` filters at the SQL level (not post-query). Operators: `:` (equals), `=`, `!=`, `>`, `>=`, `<`, `<=`, `~*` (regex), `@>` (containment). Combine with `AND`/`OR`. Range operators compare numbers numerically and anything else as text, so ISO dates work directly: `where=day>=2026-09-15` (store dates as `YYYY-MM-DD` or full ISO timestamps; other formats like `MM/DD/YYYY` don't sort). ### Query endpoint (projection + aggregation) ``` POST /api/v1/{collection}/_query { "select": ["title", "category"], "where": "category:news", "aggregate": { "groupBy": ["category"], "metrics": { "count": { "count": "title" }, "authors": { "countDistinct": "author" } } }, "label": "published", "limit": 100, "cursor": "…" } ``` Without `aggregate`, returns projected documents (same shape as list endpoint, with cursor pagination). With `aggregate`, returns metric rows: ``` Response 200 (aggregate): { "rows": [ { "key": { "category": "news" }, "count": 12, "authors": 4 }, { "key": { "category": "opinion" }, "count": 3, "authors": 2 } ] } ``` Metric ops (v1): `count`, `countDistinct`, `sum`, `min`, `max`, `avg`. `groupBy` accepts field paths plus `$documentId`, `$path`, `$collection`. Array paths with `[]` are unnested before aggregation — e.g. `report.divisions[].categories[].ranked[].gmsId` flattens the nested array and counts/aggregates over the leaf values. ### Materialized queries Named, persisted query results that auto-refresh on every write to the source collection. The result is stored as a versioned document, so it participates in labels and the promote workflow. ``` PUT /api/v1/{collection}/_materialized/{name} { "query": { "select": ["title", "category"] }, "refreshOn": "write" } Response 200: { "collection": "articles", "name": "slim-list", "refreshOn": "write", "resultDocId": "…" } ``` ``` GET /api/v1/{collection}/_materialized/{name} Response 200: { "collection": "articles", "name": "slim-list", "result": { "id": "…", "version": 3, "data": { "items": […], "refreshedAt": "…" } } } ``` Max 5 materialized queries per collection. `refreshOn: "write"` triggers on every create/update/delete; `refreshOn: "manual"` requires an explicit PUT to refresh. --- ## JSON Schemas Schemas are optional. When set, every create/update is validated before writing. ### Change part of a schema ``` PATCH /api/v1/{collection}/_schema { "naturalKey": "sku" } ``` Only the fields sent change (`schema`, `displayName`, `collectionType`, `listColumns`, `naturalKey`, `indexes`; `null` clears one). `PUT` replaces the whole definition — a script that PUTs its schema on every run would drop a `naturalKey` or index added since; use `PATCH` in scripts. ### Get schema ``` GET /api/v1/{collection}/_schema Response 200: { "collection": "articles", "collectionType": "json", "schema": { "type": "object", "required": ["title"], "properties": { … } }, "displayName": "{title}", "updatedAt": "2026-04-01T10:00:00Z" } Response 404: no schema set ``` ### Set schema ``` PUT /api/v1/{collection}/_schema { "schema": { "type": "object", "required": ["title", "author"], "properties": { "title": { "type": "string" }, "author": { "type": "string" }, "category": { "type": "string", "enum": ["news", "opinion"] } } }, "displayName": "{title}", ← field used as human-readable name in Admin UI "collectionType": "json", ← "json" (default) or "binary" "naturalKey": "slug" ← optional; declares a field as the document's natural key (see Documents) } Response 200: (with "keysRegistered": n when naturalKey is set) Response 409: existing documents share a natural-key value (nothing changed) Response 422: invalid JSON Schema ``` Setting `naturalKey` registers the key of every existing document from its current data, so by-key reads and upserts find them (instead of creating duplicates); `keysRegistered` says how many. If two live documents have the same value, the request is refused with `409` listing the values, and nothing changes. An unchanged write still updates a document's stored key. ### Delete schema ``` DELETE /api/v1/{collection}/_schema Response 200: { "collection": "articles", "deleted": true } ``` ### Dry-run: validate a schema against existing documents Read-only. Runs the current schema (or a proposed one in the request body) against every existing document in the collection and reports which ones would fail — without touching anything. Useful before changing a schema on a collection with live data. ``` GET /api/v1/{collection}/_schema/validate ← validates current stored schema POST /api/v1/{collection}/_schema/validate ← validates a proposed schema Content-Type: application/json { "schema": { "type": "object", "required": ["title"], … } } or just the bare JSON Schema as the whole body Optional query params: max — cap documents checked (default 10000, hard max 50000) limit — cap number of failure details returned (default 100, hard max 1000) Response 200: { "collection": "articles", "schemaSource": "current" | "proposed", "checked": 42, "limitReached": false, "valid": 40, "invalid": 2, "failures": [ { "id": "uuid", "version": 3, "errors": ["/title must be string"] } ], "failuresTruncated": false } Response 404: no schema set and no proposed schema in body Response 422: invalid JSON Schema ``` --- ## Trees Trees provide hierarchical path-based navigation. Useful for CMS site maps, navigation menus, and content routing. ### List trees ``` GET /api/v1/tree Response 200: { "trees": [{ "name": "main", "count": 12 }] } ``` ### Get full tree snapshot ``` GET /api/v1/tree/{name}?full=true Response 200: { "tree": "main", "nodes": [ { "path": "/blog/hello", "documentId": "uuid", "document": }, … ] } ``` ### Get a node and its children ``` GET /api/v1/tree/{name}/{path} GET /api/v1/tree/{name}/{path}?label=published ← document at the labeled version e.g. GET /api/v1/tree/main/blog/hello Response 200: { "path": "/blog/hello", "document": , "assignmentDocId": "uuid or null", "children": [ { "path": "/blog/hello/comments", "documentId": "uuid or null" } ] } Response 404: path not found ``` The `?label=` parameter resolves the document at a specific labeled version instead of `current_version`. Permission rules with a `labelFilter` apply this automatically for public reads — so a `principal='*'` rule with `labelFilter: "published"` means anonymous visitors always see the published version (on public URLs the rule's `labelFilter` always wins; `?label=` cannot override it). Authenticated users preview unpublished changes on the private route, e.g. `GET /api/v1/tree/{name}/{path}?label=preview` with a Bearer key. This enables a deploy → preview → promote workflow for static sites. Without a `labelFilter` on the public rule, visitors see `current_version` — the latest deploy — and labels have no effect on what is public. ### Assign a document to a path ``` PUT /api/v1/tree/{name}/{path} { "documentId": "uuid" } Response 200: { "path": "/blog/hello", "documentId": "uuid" } ``` ### Promote a tree (atomic) ``` POST /api/v1/tree/{name}/_promote { "label": "published", "from": "preview" } // both optional; label defaults to "published" Response 200: { "tree": "mysite", "label": "published", "from": "preview", "promoted": [{ "path": "/index.html", "documentId": "…", "collection": "mysite-assets", "version": 4 }] } Response 404: no documents in the tree (or none carry `from`) Response 403: no write access to one of the collections — nothing is changed ``` Points `label` at, for every document in the tree, the version carrying `from` (documents without it are left alone) or else the current version — in a single transaction, so readers never see a half-promoted site. `wren promote` uses this endpoint. ### Remove a path assignment ``` DELETE /api/v1/tree/{name}/{path} Response 200: { "path": "/blog/hello", "removed": true } ``` --- ## Binary assets Set `collectionType: "binary"` in the schema to store files. ### Upload a file ``` POST /api/v1/{collection} Content-Type: multipart/form-data file= (form field named "file") Response 201: ← data contains filename, contentType, size, etc. ``` ### Files by name (natural key "filename") ``` PUT /api/v1/assets/_schema { "collectionType": "binary", "naturalKey": "filename" } PUT /api/v1/assets/by-key/logo.svg multipart/form-data, field "file" → 201 created / 200 replaced / 200 unchanged GET /api/v1/assets/by-key/logo.svg → the file's metadata (id, version, sha256…) GET /api/v1/assets/by-key/logo.svg/raw → the bytes (?version= and ?label= work) DELETE /api/v1/assets/by-key/logo.svg ``` A file collection keyed by filename is addressed by name (publicly too, when the collection is public: `GET /orgs/{slug}/{collection}/by-key/{name}/raw`): a sync script uploads by key and never creates duplicates. The stored file is named by its key and its type follows that name. A plain `POST` of a name that already exists is refused with `409`. ### Replace / update a file ``` PUT /api/v1/{collection}/{id} Content-Type: multipart/form-data file= Response 200: ``` **Identical bytes are stored once.** File contents are stored by SHA-256, so the same bytes uploaded to many documents or versions take the space of one copy. Replacing a file with one whose bytes, filename and content type equal the current version creates **no** new version: the response is `200` with the current `version` and `"unchanged": true` (otherwise `unchanged` is absent). Re-deploying an unchanged site therefore adds no versions. ### Download raw file (authenticated) ``` GET /api/v1/{collection}/{id}/raw GET /api/v1/{collection}/{id}/raw?version=2 ← specific version GET /api/v1/{collection}/{id}/raw/filename.jpg ← optional filename suffix for better download naming Response 200: binary content with correct Content-Type and Content-Disposition: inline; filename="..." ``` ### Serve raw file publicly (no auth) Requires a `principal='*'` read permission on the collection. The org slug is required in the URL since there is no session to identify the tenant. Note the URL-shape split: **public (no-auth) reads** use the org-scoped prefix `/api/v1/orgs/{slug}/...` (or its GET-only alias `/orgs/{slug}/...`). **Authenticated endpoints** use `/api/v1/...` with the tenant inferred from credentials — no slug. Both families are versioned under `/api/v1`; see the URL map at the top. If the rule has a `labelFilter`, the raw route serves the labeled version and ignores `?version=`. ``` GET /api/v1/orgs/{slug}/{collection}/{id}/raw GET /api/v1/orgs/{slug}/{collection}/{id}/raw/filename.jpg ← recommended: append real filename for browsers/tools Response 200: binary content — same headers as authenticated endpoint Response 403: collection not publicly accessible ``` The filename-in-URL pattern is recommended for `` tags, CDN configs, and CLI tools. Example: ```html ``` --- ## MCP (AI agents) Built-in Model Context Protocol endpoint, one per server: `POST /mcp`. - Transport: Streamable HTTP, stateless JSON responses (no SSE stream, no session id). GET/DELETE → 405. - Protocol versions: 2025-06-18, 2025-03-26, 2024-11-05. - Auth: `Authorization: Bearer wren_…` (or a session cookie) for own-org tools. No credentials → public read-only mode (below). Invalid credentials → HTTP 401 with `WWW-Authenticate: Bearer`. - Org: from the key, like the REST API. Several orgs → configure the endpoint once per key. - Tools call the REST API in-process, so permissions, label filters and org isolation apply unchanged. - `/mcp?readonly=1` exposes only read tools. - `/mcp` without a key: public, read-only access to every org's public data — `list_public` (same content as /api/v1/projects) and `public_site_info`, `public_list_tree`, `public_read_file`, `public_query_documents`, `public_get_document` (each with an `org` slug argument). With a key, `/mcp` offers these `public_*` tools in addition to the own-org tools; they never send the caller's credentials. An invalid key → 401. - Browser sign-in: `POST /mcp/login` (same tools as /mcp with a key). Without a token → 401 with `WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp/login"`. Authorization server metadata: `/.well-known/oauth-authorization-server` (authorize `/api/auth/mcp/authorize`, token `/api/auth/mcp/token`, dynamic registration `/api/auth/mcp/register`); PKCE S256 required. WREN always shows its consent page (`/mcp/consent`), where the user picks the org; the connection then acts as that user in that org (their groups). Access token 1 h, refresh 30 days; the token is rejected by the REST API. - Connected apps (browser session only; API keys and impersonation get 403): `GET /api/v1/connected-apps` → `{ "apps": [{ "clientId", "name", "icon", "redirectHosts": ["localhost:8091"], "org": { "id", "name", "slug" }, "connectedAt", "lastRenewedAt", "expiresAt" }] }` — the signed-in user's apps across all orgs. `DELETE /api/v1/connected-apps/{clientId}` → `{ "clientId", "revoked": true, "tokensRevoked": n }`; the app's tokens stop working at once and its next sign-in shows the consent page again. 404 if you have no connection to that app. - Org-bound endpoint `/orgs/{slug}/mcp` (what a custom domain's /mcp maps to): - without a key: public, read-only tools site_info, list_tree, read_file, query_documents, get_document over the public routes (only what principal '*' rules allow, at their label) - `?tree=` scopes to one site (collection tools hidden unless `&collections=a,b`); site_info then describes only that scope - with a key: full tools below, but only for keys of {slug} (other orgs → 403); unknown slug → 404 | Tool | Maps to | Notes | |---|---|---| | whoami | GET /api/v1/me | adds public URL patterns for the slug | | list_collections | GET /api/v1/collections | | | query_documents | POST /api/v1/{collection}/_query | where, select, label, aggregate, limit ≤ 1000, cursor | | get_document | GET …/{id}, …/by-key/{key}, …/{id}/versions/{n} | optional label | | list_versions / diff_versions | …/{id}/versions, …/{id}/diff | | | list_tree / read_file | GET /api/v1/tree[/{name}?full=true], GET /api/v1/tree/{name}/{path} | text files ≤ 256 KB returned as text | | write_document | PUT …/by-key/{key}, PUT …/{id} or POST …/{collection} | then labels the version "preview" (label:"" for none) | | write_file | tree lookup → multipart PUT/POST + tree PUT | new files go to "-assets"; labels "preview" | | set_label | POST …/{id}/labels | destructiveHint | | promote_tree | POST /api/v1/tree/{name}/_promote | from "preview" → "published", one transaction; destructiveHint | Setup examples (Claude Code, Cursor, VS Code, mcp-remote): /guides/mcp --- ## Public access Nothing is public by default. A tree or collection becomes publicly readable only through a permission rule with `principal: "*"`. ### Recipe ``` # 1. Find your slug GET /api/v1/me → { "org": { "slug": "brave-fox-hill", … } } # 2. Allow anonymous reads (labelFilter keeps drafts private) POST /api/v1/permissions {"principal":"*","resource":"tree:mysite","access":"read","labelFilter":"published"} POST /api/v1/permissions {"principal":"*","resource":"collection:mydata","access":"read","labelFilter":"published"} # 3. Label what should be visible POST /api/v1/{collection}/{id}/labels {"label":"published"} (wren promote does this for a deployed tree) # 4. Share the public URLs https:///orgs/brave-fox-hill/tree/mysite/index.html site, for people https:///api/v1/orgs/brave-fox-hill/mydata?limit=50 data, for code ``` ### Public endpoints (no auth, read-only) ``` GET /api/v1/projects all orgs with public rules (collections, trees, entry URLs) GET /orgs/{slug}/llms.txt what this org exposes publicly GET /orgs/{slug}/tree/{tree}/{path} site file (GET-only alias) GET /api/v1/orgs/{slug}/tree/{tree}/{path} file bytes; Accept: application/json → node metadata GET /api/v1/orgs/{slug}/tree/{tree}?full=true full tree snapshot GET /api/v1/orgs/{slug}/{collection} list (?limit, ?offset, ?select, ?where) GET /api/v1/orgs/{slug}/{collection}/{id} get GET /api/v1/orgs/{slug}/{collection}/{id}/raw binary bytes GET /api/v1/orgs/{slug}/{collection}/by-key/{v} get by natural key POST /api/v1/orgs/{slug}/{collection}/_query query GET /api/v1/orgs/{slug}/{collection}/_materialized[/{name}] ``` ### Things that trip people up - **A tree rule is enough to serve a site.** `tree:mysite` makes every file under that tree readable; you only need a `collection:` rule if code also reads the collection directly. - **Granularity is the whole tree or collection.** To keep a page private, put it in another tree without a `*` rule — not in the public one. - **Everything public is discoverable** via `GET /api/v1/projects` and `/orgs/{slug}/llms.txt` (which lists tree paths and sample documents). - **Auth headers are ignored on public URLs.** To read private data, use the private `/api/v1/...` routes with a key. - **Writes on public URLs return 405.** Write via `/api/v1/{collection}` with a key. - **Browsers on other domains**: public routes allow any origin; private routes accept `Authorization: Bearer` from any origin but cookies only from the Wren site. Don't put API keys in public page code. - **CDN caching**: successful public responses are cacheable (`max-age=60, stale-while-revalidate`); errors are `no-store`. Add `?v=` to asset URLs after a deploy. - **Python `urllib`**: deployments behind Cloudflare may answer `403 error code: 1010` to the default `Python-urllib/3.x` User-Agent. Send your own `User-Agent` header (requests/httpx/curl are unaffected). --- ## API Keys ### List keys ``` GET /api/v1/keys Response 200: { "keys": [{ "id": "…", "name": "CI deploy", "keyPrefix": "wren_a3f4e5", "createdAt": "…", "lastUsedAt": "…", "revokedAt": null }] } ``` ### Create a key ``` POST /api/v1/keys { "name": "CI deploy" } Response 201: { …ApiKey fields…, "key": "wren_a3f4e5d2…" } ← full key shown ONCE ``` ### Revoke a key ``` DELETE /api/v1/keys/{id} Response 200: { "id": "…", "revoked": true } ``` --- ## Identity & scope ### Who am I A self-check endpoint designed for agents and clients holding an API key. Returns the caller's principal, the org this request is scoped to, the role held by the user, and the permission rules that apply. Intended to be called once on startup to confirm authentication and scope, avoiding 403-by-surprise on downstream requests. Works with both Bearer API keys and session cookies. ``` GET /api/v1/me Response 200: { "principal": "key:a3f4e5d2", "authMethod": "api_key", "user": { "id": "user-uuid", "name": "Jane", "email": "jane@example.com" }, "org": { "id": "org-uuid", "name": "Acme", "slug": "coral-tide-fox", "role": "owner" }, "apiKey": { "id": "key-uuid", "name": "CI deploy", "prefix": "wren_a3f4e5", "lastUsedAt": "2026-04-14T08:00:00Z" }, "permissions": [ { "id": "perm-uuid", "principal": "*", "resource": "collection:articles", "access": "read", "labelFilter": "published", "filterLang": null, "filterExpr": null } ] } ``` - `authMethod` is `"api_key"` or `"session"`. - `apiKey` is `null` for session-authenticated callers. - `org.role` is `"owner"` for the caller's own workspace or orgs they own, else the `org_members` role (`admin`, `member`, …), or `"none"`. Use `role` — there is no separate `own` flag. - `permissions` includes rules for this principal plus any `principal: "*"` rules. Use this to preflight "can I read collection X?" without making a speculative request. --- ## Organization ### Get current org context ``` GET /api/v1/org Response 200: { "current": { "id": "org-id", "name": "Acme", "email": "owner@example.com" }, "orgs": [ { "id": "org-id", "name": "Acme", "email": "owner@example.com", "own": true }, { "id": "other-id", "name": "Partner", "email": "partner@example.com", "own": false } ] } ``` ### Switch org (session-based only) ``` PUT /api/v1/org { "orgId": "other-id" } Response 200: { "current": { "id": "other-id", … } } ``` --- ## Members ### List members ``` GET /api/v1/members Response 200: { "members": [{ "userId": "…", "name": "Jane", "email": "jane@…", "role": "editor", "joinedAt": "…" }] } ``` ### Remove a member ``` DELETE /api/v1/members/{userId} Response 200: { "userId": "…", "removed": true } ``` --- ## Invites ### List sent invites ``` GET /api/v1/invites Response 200: { "invites": [{ "id": "…", "email": "jane@…", "role": "editor", "createdAt": "…", "expiresAt": "…", "acceptedAt": null, "revokedAt": null }] } ``` ### Create an invite ``` POST /api/v1/invites { "email": "jane@example.com", "role": "editor" } Response 201: { …Invite fields…, "token": "raw-token" } ← token shown ONCE ``` ### Accept an invite (by token) ``` POST /api/v1/invites/accept { "token": "raw-token" } Response 200: { "accepted": true, "orgId": "…" } ``` ### Accept an invite (by ID — for logged-in users) ``` POST /api/v1/invites/{inviteId}/accept Response 200: { "accepted": true, "orgId": "…" } ``` ### Revoke an invite ``` DELETE /api/v1/invites/{inviteId} Response 200: { "id": "…", "revoked": true } ``` ### List received invites ``` GET /api/v1/invites/received Response 200: { "invites": [{ "id": "…", "orgId": "…", "orgName": "Acme", "orgEmail": "…", "role": "editor", … }] } ``` --- ## Groups, roles and keys - **Role** (member | admin) decides what someone may *manage*: admins handle invites, members, keys, permissions and groups. The org owner can do everything and bypasses permission rules. - **Groups** decide which *data* people can see and change. Every org starts with `Editors` (write on `*`) and `Viewers` (read on `*`). Rules name a group as principal `group:`. - **Access** for a member = their own `member:` rules plus their groups' rules. The most specific resource wins (`collection:x` over `collection:*` over `*`); at equal specificity a personal rule beats a group rule; among groups the highest access wins. Non-members get nothing. - **API keys** act as their creator in the key's org. Any member may create keys. A key with `key:` rules of its own uses only those (narrowing). Members list and revoke their own keys; admins all. ``` GET /api/v1/groups groups with members and rules (admin) POST /api/v1/groups {name, description?, access?: read|write} → group (+ rule on *) PUT /api/v1/groups/{id} {name?, description?} DELETE /api/v1/groups/{id} also removes the group's rules PUT /api/v1/groups/{id}/members/{userId} add a member of the org DELETE /api/v1/groups/{id}/members/{userId} remove POST /api/v1/invites {email, role?: member|admin, groupIds?: [...]} → joins the groups on accept ``` Invite links only work for an account with the invited email; the in-app "received invites" list needs a confirmed email. ### Impersonation (org admins) ``` POST /api/v1/members/{userId}/impersonate admin/owner, browser session; not the owner; 1 hour GET /api/v1/impersonation current state (also in /api/v1/me as "impersonating") DELETE /api/v1/impersonation end it ``` While impersonating, the admin's session acts as the member in that org only; org management (keys, permissions, invites, members, groups, webhooks, org) returns 403. Every change is recorded with `impersonatedBy` (version history), every request is audit-logged with both identities. ## Permissions Fine-grained access control rules, managed by org owners and admins. Principals: `*` (anyone, public), `member:`, `group:`, `key:`. ### Permission rule shape ```json { "id": "perm-uuid", "principal": "member:", "resource": "collection:articles", "access": "read", "labelFilter": "published", "filterLang": "jmespath", "filterExpr": "category == 'news'", "auditReads": false, "auditWrites": true, "createdAt": "2026-04-01T10:00:00Z" } ``` Field values: - `principal` is one of `"member:"`, `"key:"`, or `"*"` (anonymous / public). - `resource` is one of `"collection:"`, `"collection:*"`, `"tree:"`, `"tree:*"`, or `"*"` (all resources). - `access` is one of `"none"`, `"read"`, `"write"`, or `"admin"`. - `labelFilter` (optional) restricts access to documents where this label is set — e.g. only `published` versions. Under such a rule, version history (`/versions`, `/versions/{n}`, `/diff`) returns 403: older versions and drafts stay hidden. Under a rule with a data filter, `/diff` returns 403. - `filterExpr` must compile in `filterLang` when the rule is saved (400 otherwise). An expression that fails at read time hides the document (`null`) instead of exposing it. - `filterLang` (optional) selects the data filter language: `"jq"`, `"jmespath"`, or `"jsonata"`. - `filterExpr` (optional) is the filter expression in the chosen language. - `auditReads` / `auditWrites` (boolean) turn on audit logging for matching requests. - `alias` (optional) short name for the public URL. When set on a `principal='*'` rule, the collection/tree is accessible at `/orgs/{slug}/{alias}` in addition to its real name. Lowercase alphanumeric + hyphens, unique per org, no reserved keywords. ### List rules ``` GET /api/v1/permissions Response 200: { "permissions": [ , … ] } ``` ### Create a rule ``` POST /api/v1/permissions { "principal": "*", "resource": "collection:articles", "access": "read", "labelFilter": "published" } Response 201: ``` ### Update a rule ``` PUT /api/v1/permissions/{id} { "access": "write", "labelFilter": null } Response 200: ``` ### Delete a rule ``` DELETE /api/v1/permissions/{id} Response 200: { "id": "…", "deleted": true } ``` --- ## TypeScript / JavaScript client ``` npm install @usewren/client # Node ≥18 or Bun ``` ```typescript import { WrenClient, WrenNotFoundError, WrenValidationError } from "@usewren/client"; const wren = new WrenClient({ baseUrl: "https://…", apiKey: "wren_…" }); // Documents const doc = await wren.documents.create("articles", { title: "Hello" }); const list = await wren.documents.list("articles", { label: "published", limit: 10 }); const item = await wren.documents.get("articles", doc.id); const v2 = await wren.documents.update("articles", doc.id, { title: "Updated" }); await wren.documents.delete("articles", doc.id); // Versions const hist = await wren.versions.list("articles", doc.id); const snap = await wren.versions.get("articles", doc.id, 1); await wren.versions.rollback("articles", doc.id, 1); // Labels & diff await wren.labels.set("articles", doc.id, "published"); await wren.labels.set("articles", doc.id, "staging", 2); // pin to v2 const diff = await wren.diff.compare("articles", doc.id, 1, 2); // Collections & schemas const cols = await wren.collections.list(); await wren.collections.setSchema("articles", { schema: { type: "object", required: ["title"], properties: { title: { type: "string" } } }, displayName: "{title}", }); // Trees const tree = await wren.trees.snapshot("main"); const node = await wren.trees.getNode("main", "/blog/hello"); await wren.trees.assign("main", "/blog/hello", doc.id); await wren.trees.unassign("main", "/blog/hello"); // Keys, members, invites, permissions const key = await wren.keys.create("CI deploy"); // key.key shown once const inv = await wren.invites.create("jane@…", "editor"); await wren.invites.accept(inv.token); await wren.permissions.create({ principal: "*", resource: "collection:articles", access: "read", labelFilter: "published" }); // Error handling try { await wren.documents.get("articles", "missing-id"); } catch (e) { if (e instanceof WrenNotFoundError) console.log("not found"); if (e instanceof WrenValidationError) console.log(e.details); } // Retention (org owner/admin): current and labeled versions are always kept const preview = await wren.retention.preview("*", { maxVersions: 20 }); // { collections, total: { versions, documents, bytes } } await wren.retention.set("*", { maxVersions: 20 }); await wren.retention.set("contracts", {}); // no rule = keep everything await wren.retention.apply(); ``` Resources available on `WrenClient`: `documents`, `versions`, `labels`, `diff`, `collections`, `trees`, `keys`, `members`, `invites`, `permissions`, `query`, `materialized`, `webhooks`, `retention`. --- ## Python client ``` pip install usewren-client ``` ```python from wren import WrenClient, AsyncWrenClient, WrenNotFoundError, WrenValidationError # ── Sync ────────────────────────────────────────────────────────────────────── with WrenClient("https://…", api_key="wren_…") as wren: doc = wren.documents.create("articles", {"title": "Hello"}) list_ = wren.documents.list("articles", label="published", limit=10) item = wren.documents.get("articles", doc.id) v2 = wren.documents.update("articles", doc.id, {"title": "Updated"}) wren.documents.delete("articles", doc.id) hist = wren.versions.list("articles", doc.id) snap = wren.versions.get("articles", doc.id, 1) wren.versions.rollback("articles", doc.id, 1) wren.labels.set("articles", doc.id, "published") diff = wren.diff.compare("articles", doc.id, 1, 2) cols = wren.collections.list() wren.collections.set_schema("articles", schema={ "type": "object", "required": ["title"], "properties": {"title": {"type": "string"}} }, display_name="{title}") tree = wren.trees.snapshot("main") node = wren.trees.get_node("main", "/blog/hello") wren.trees.assign("main", "/blog/hello", doc.id) wren.trees.unassign("main", "/blog/hello") key = wren.keys.create("CI deploy") # key.key shown once inv = wren.invites.create("jane@example.com", role="editor") wren.invites.accept(inv.token) wren.permissions.create( principal="*", resource="collection:articles", access="read", label_filter="published" ) try: wren.documents.get("articles", "missing-id") except WrenNotFoundError: print("not found") except WrenValidationError as e: print(e.details) # Retention (org owner/admin): current and labeled versions are always kept preview = wren.retention.preview("*", max_versions=20) # .total.versions / .documents / .bytes wren.retention.set("*", max_versions=20) wren.retention.set("contracts") # no rule = keep everything wren.retention.apply() # ── Async ───────────────────────────────────────────────────────────────────── import asyncio async def main(): async with AsyncWrenClient("https://…", api_key="wren_…") as wren: doc = await wren.documents.create("articles", {"title": "Hello"}) hist = await wren.versions.list("articles", doc.id) await wren.labels.set("articles", doc.id, "published") asyncio.run(main()) ``` Resources available on both clients: `documents`, `versions`, `labels`, `diff`, `collections`, `trees`, `keys`, `members`, `invites`, `permissions`, `query`, `materialized`, `webhooks`, `retention`. Response objects are Python dataclasses. Fields use `snake_case` (e.g. `created_at`, `user_id`, `label_filter`). --- ## Projects directory ### List public projects Public, no authentication required. Returns every org that has at least one `principal='*'` permission rule. For each tree, the response includes an `entryUrl` if the tree has an `/index.html` path — indicating it serves a browsable site or app. ``` GET /api/v1/projects Response 200: { "projects": [ { "name": "Ruben Reusser", "slug": "tkd", "url": "https://wren.example.com/api/v1/orgs/tkd", "collections": [ { "name": "media", "access": "read", "labelFilter": null, "url": "…/orgs/tkd/media" } ], "trees": [ { "name": "tkdbern", "url": "…/orgs/tkd/tree/tkdbern?full=true", "entryUrl": "…/orgs/tkd/tree/tkdbern/index.html" }, { "name": "events", "url": "…/orgs/tkd/tree/events?full=true", "entryUrl": null } ] } ] } ``` A human-friendly HTML view is available at `/projects`. --- ## wren.js — Client-side data binding A zero-dependency web components library served at `/wren.js`. Drop it into any HTML page to declaratively bind WREN data — no build step, no framework. ```html ``` Components: `wren-list`, `wren-query`, `wren-materialized`, `wren-doc`, `wren-tree`. **Configuration** (on the `