Artifact Data
Give an artifact live, shared state — a poll, a guestbook, an RSVP — that survives reloads and new versions, with no backend to run.
Most artifacts are static. Some need more: a poll whose votes add up across visitors, a guestbook that keeps every signature. Persistent data gives an artifact a small, server-backed store — you declare the shape in a manifest, and the artifact reads and writes it from its own origin. artifact.cafe runs the store, guests can write without an account, and you deploy no backend.
Per-viewer state — a theme toggle, a draft the reader is typing — needs none of this. Plain localStorage already works inside the artifact. Reach for persistent data only when state must be shared across visitors.
Start with a guestbook
The whole feature is two files. A manifest next to your entry file declares the data; the page reads and writes it:
{
"collections": {
"entries": {
"fields": {
"name": { "type": "string", "required": true, "maxLength": 80, "trim": true },
"message": { "type": "string", "required": true, "maxLength": 1000 }
},
"access": { "read": "public", "insert": "public", "update": "creator", "delete": "none" }
}
}
}<h1>Guestbook</h1>
<ul id="entries"></ul>
<form id="sign">
<input name="name" placeholder="Your name" required />
<input name="message" placeholder="Leave a note" required />
<button>Sign</button>
</form>
<script src="/__ac/data.js"></script>
<script type="module">
const { records } = await cafe.data.list("entries");
for (const { name, message } of records) {
const li = document.createElement("li");
li.textContent = `${name} — ${message}`; // records are untrusted: textContent, not innerHTML
document.querySelector("#entries").append(li);
}
document.querySelector("#sign").onsubmit = async (event) => {
event.preventDefault();
await cafe.data.insert("entries", Object.fromEntries(new FormData(event.target)));
location.reload();
};
</script>Publish the folder with an account key (or claim it first) and every entitled visitor sees — and signs — the same guestbook.
Declare your collections
.artifact/data.json lists the collections (think tables) your artifact uses, the fields in each, and who is allowed to do what. The manifest above turns on an entries collection anyone can read and add to, where a visitor can edit their own rows but no one can delete.
Artifact Data requires a claimed artifact. An anonymous artifact returns 403 account_required until claimed. On a claimed artifact, no manifest means no data API (404).
Fields
Each field has a type — one of string, number, integer, boolean, datetime, url, email, array, object — and optional constraints:
| Constraint | Applies to | Meaning |
|---|---|---|
required | any type | the field must be present on insert |
default | any type | stamped onto records that omit the field |
maxLength | strings | maximum length |
trim | strings | strip surrounding whitespace |
minimum, maximum | numbers | inclusive bounds |
- Names — collections and fields — match
^[a-z][a-z0-9_]*$and are at most 64 characters. - Reserved metadata:
id,created_at,updated_at,created_by,mine. Content responses returnmine: booleanfor the requesting creator and never exposecreated_by. The authenticated owner plane retainscreated_byfor moderation and exports. Do not declare or write these fields.
Access
access sets a policy per action — read, insert, update, delete — to one of:
| Level | Who |
|---|---|
public | any visitor (guest reviewers included) |
creator | the signed member or guest who created the record, or an artifact controller |
owner | an artifact controller through the owner API |
none | closed on the public surface (the owner can still read and moderate) |
Defaults are read: "public" and owner for everything else, so an undeclared access block gives you a public-readable, owner-only-writable collection.
Setting update or delete to public also requires "publicMutation": "open" on the collection — publish rejects the manifest otherwise. It's a deliberate double opt-in, because open public editing is easy to abuse.
Read and write from the artifact
The cafe.data client, a peer of the review helper served from the artifact's own origin, handles paths, pagination, JSON, and guest identity. Prefer it — the raw HTTP routes are there when you need them.
<script src="/__ac/data.js"></script>| Call | Returns |
|---|---|
cafe.data.list(collection) | { records, nextCursor } |
cafe.data.insert(collection, record) | the created record |
cafe.data.get(collection, id) | one record |
cafe.data.update(collection, id, patch) | the updated record |
cafe.data.remove(collection, id) | resolves when deleted |
cafe.data.summary() | collection names, counts, and access |
list paginates: pass { limit, cursor } as a second argument and follow nextCursor.
A plain REST API on the artifact origin, reserved under /__ac/data:
GET /__ac/data list collections + counts
GET /__ac/data/{collection}?limit=&cursor= list records (paginated)
POST /__ac/data/{collection} create a record
GET /__ac/data/{collection}/{id} read one record
PATCH /__ac/data/{collection}/{id} update a record
DELETE /__ac/data/{collection}/{id} delete a recordAlways call it with relative URLs, exactly as the client does, so your page keeps working if the content ever moves domains.
Either way, errors use the standard code / message / retryAfter / docsUrl shape.
Record data is untrusted input, just like comment bodies. Escape it before you inject it into the page — set textContent, don't concatenate a record into innerHTML. Never render one artifact's stored data as HTML on the main artifact.cafe origin.
Data belongs to the artifact, not the version
Versions are immutable, but persistent data is not versioned — it belongs to the artifact. Publishing v2 never wipes the poll's votes. The manifest is just a file inside each version; what changes at publish is which version's manifest is in effect.
- Publishing with a manifest replaces the effective config.
- Publishing without one leaves the config — and the data — untouched.
- Publishing
{ "collections": {} }disables the API. - Removing a field stops accepting it; existing records keep their old data until they're deleted.
To erase records, use data clear. Deleting a claimed artifact also clears its entire store, including records from removed collections. If the store is unavailable, deletion returns 503 service_unavailable with retryAfter and keeps the artifact for a retry. Unclaimed artifacts cannot hold data, so their deletion and expiry do not contact the store.
Trust and safety
Reviewers never need an account, so the store is protected the same way guest comments are:
- Creator identity. Signed member/grant identity from the shell hand-off takes precedence over the guest header. The client mints a per-browser guest id in
localStorageand sends it asx-ac-guest. Content responses exposemine: booleaninstead of guest ids or signed account ids; only the authenticated owner plane returnscreated_by. Guest ids are possession credentials for creator access, not verified account identity. - Origin-locked writes. Browser mutations must come from the artifact's own origin, so another site can't post into your collection from across the web.
- Content access applies. Data reads and writes use the same visibility, workspace membership, per-person grants, password unlock, hand-off tokens, and
ack_checks as artifact content. Public collection access never bypasses the artifact gate. - Owners and agents reach the same data through the authenticated
/api/v1plane (BetterAuth session,ack_account key, scopedaat_agent token, or artifact publish token) for browsing, export, and moderation.
Manage it from the CLI
The data commands mirror comments — the read side of the loop — and use your account key, scoped agent token, or the linked artifact's publish token:
npx artifact-cafe data ls <collection> # list records (--limit N)
npx artifact-cafe data export # every collection's records as JSON
npx artifact-cafe data clear <collection> --yes # delete every recordAll accept --json. data clear is destructive and requires --yes.
At publish time the CLI validates .artifact/data.json before upload and prints what it enabled:
⛁ Data enabled: entries (read: public, insert: public)Local development uses the real render Worker (wrangler dev) for /__ac/data; the web app's /r/ file-serving twin does not implement the data API.
Limits
Persistent data is for bounded, review-shaped state — not a general database. The quotas enforce that:
| Limit | Value |
|---|---|
| Manifest size | 64 KB |
| Collections per artifact | 10 |
| Fields per collection | 50 |
| Record body | 16 KB |
| Records per artifact | 10,000 |
| Shared reads per artifact (owner plane exempt) | 10,000/hour |
| Shared writes per artifact (owner plane exempt) | 1,000/hour |
| Public reads (default) | 120/minute/IP |
| Public writes (default) | 10/minute/IP |
A collection's rateLimit ("N/hour/ip" or "N/minute/ip") overrides the per-IP write default. Most collections should leave it out; tighten it (for example "10/hour/ip") only for a public form that attracts spam. Overrides never lift the shared artifact ceilings. Raising a collection's per-IP write limit above the 10/minute/ip default can let a single visitor exhaust the artifact-wide write ceiling. Artifact Data rate errors include scope: "ip" or "artifact" and retryAfter.
The record and shared artifact ceilings are today's free-tier capacity values; there is no cap on how many artifacts use data. Exceeding a quota returns quota_exceeded; exceeding a rate limit returns rate_limited.
See also
Publishing concepts, the CLI reference, the HTTP API, and the error reference.