Skip to content

Manifest reference

An app’s manifest is a JSON Schema document with one Homespun extension key. It declares the app’s data, who may touch it, and what the app may reach.

This page is generated at build time from the validator’s own rules (packages/relay/src/core/manifest-catalog.ts). The relay imports the same constants to validate a deploy, and a test runs the real validator over probe manifests to confirm that what this page claims is what it actually enforces, so a permission rule here cannot be one the system does not keep.

Rejections come back as the codes listed in the error reference, most often manifest_invalid or permission_role_invalid.

The manifest is a JSON Schema document carrying one Homespun extension key. Unknown top-level keys are rejected.

Path: (document root)

FieldRequiredTypeWhat it is
x-homespun-manifestyesobjectThe Homespun extension block, described in the sections below.
$defsnoobjectJSON Schema definitions that collection schemas reference with $ref.
$schemanostringThe JSON Schema dialect the document is written against.
$idnostringAn identifier for the schema document.
$commentnostringA free-text comment, ignored by the validator.

Keys allowed here: $comment, $defs, $id, $schema, x-homespun-manifest. Anything else is rejected.

Everything Homespun-specific lives under this one key.

Path: x-homespun-manifest

FieldRequiredTypeWhat it is
appyesobjectHow the app presents itself.
collectionsnoobject of collection name to specThe app’s stored data, one entry per collection.
rolesnoobject of role name to definitionCustom roles the app declares, beyond the built-in ones.
externalHostsnostring[]Hosts the app may reach, which becomes its connect-src allowance.
cdnnostring[]CDN hosts the app loads static assets from.
offlinenobooleanWhether the app installs a service worker so it opens without a network connection.
livenostringWhether the browser SDK opens the realtime WebSocket, one of on or off.
capabilitiesnoobjectOptional platform capabilities the app opts into.
embedsnoarrayThird-party embeds the app declares.
embedAncestorsnostring[]Origins that will be allowed to frame this app in an <iframe>, the inverse of embeds, which declares what the app itself may frame.
notifynoarray of rulesWhen the app should notify people about a data change.
webhooksnoarray of rulesOutbound HTTP calls fired on a data change.
fetchersnoobject of fetcher name to specOutbound HTTP calls the app can ask the relay to make on demand, with a Connection’s credential attached server-side.
agentTasksnoarray of rulesWork the app hands back to its owner’s own agent, described in words rather than code.
ingestnoarray of rulesInbound catch-hooks that let external systems push data into a collection.
schedulesnoarray of rulesTime-based rules, for reminders and recurring work.
settingsCollectionnostringNames the one collection that install-time config answers are written into.
routesnoarray of route specsPer-row URLs the app publishes: a real page per product, article or listing.

collections. An app with no collections is valid: omit the key, or pass an empty object.

externalHosts. The app’s own origin is always allowed and is rejected if listed, so this stays exactly the external surface a human consents to.

offline. Defaults to false. When true the browser SDK registers the relay’s own service worker, which caches the app document, the SDK bundle, the icons and the deployed asset bundle, so an installed app opens and paints with no connection. It caches the SHELL only: collection rows, boot, sessions, grants and attachments always go to the network, so nothing this worker does can widen who may read data. Only honoured on a PUBLIC app. A private or link app’s document is a capability the relay decides per request from a cookie, and a cached copy would answer that question on the device instead. On any other visibility the worker is not served and the SDK removes one it installed earlier. Cannot be combined with cdn: an app that loads its scripts and styles from another origin cannot paint offline, because those requests fail with no connection and this worker never caches another origin’s bytes. Declaring both is a deploy error. Inline what the app needs instead. A redeploy replaces the installed worker and drops the previous deploy’s cache, so a returning viewer never runs HTML older than the collections it talks to. While online the app still revalidates on every load, so a redeploy is visible immediately rather than one visit later.

live. Allowed values: off, on. Defaults to off: the browser SDK does not open the realtime WebSocket, so a page sees other people’s changes only when it reloads or regains focus. Set to on to have the browser SDK open the WebSocket like an agent does. Never affects agents: an agent always uses the WebSocket, regardless of this key.

embedAncestors. Capped by the operator’s MAX_EMBED_ANCESTORS setting (10 entries by default), the same posture MAX_EMBEDS takes for embeds. EXACT origins only. Unlike embeds/externalHosts, no leading ’*.’ wildcard is ever accepted: a wildcard here would let an entire zone frame the app, a materially bigger grant than the one origin the owner actually reviewed. https://host is accepted; http://localhost:PORT and http://127.0.0.1:PORT are also accepted, but only those two hosts, as a development-only exception. Plain http for any other host is refused, as is any path, query, fragment, or userinfo. Declaring origins here is currently INERT: it validates and is stored on the app’s manifest, but does not yet affect any response header. No app can be framed by a declared origin until a later stage wires this list into frame-ancestors. Once wired, framing will only be granted when, at the moment of the request, the app is PUBLIC and its manifest needs no visitor identity (no collection that both admits anonymous create and scopes read/update/delete to a row subject like creator or own). Declaring origins on any other app will silently do nothing, because those apps need cookies that a cross-site frame cannot carry. A manifest declaring a non-empty embedAncestors cannot be published as a community template: the published template’s origins would let the template’s AUTHOR decide who may frame every installed copy of the app, a cross-tenant hazard none of the app’s other grants create.

fetchers. Grammar and deploy-time validation only for now: there is no invoke route yet, so a validated fetcher cannot actually be called. See x-homespun-manifest.fetchers. below for the per-entry grammar.

agentTasks. Use it for work that is easier to describe than to implement: reading a photographed receipt, summarising a long note, classifying a free-text entry. The relay queues the task and runs nothing itself.

ingest. A rule may add an optional verify: { scheme: "github" } to require a valid GitHub HMAC-SHA256 signature (X-Hub-Signature-256) over the raw body. This is strictly opt-in: without it the URL secret is the only authenticator. A verify-enabled hook stays fail-closed (401) until its signing secret is provisioned with homespun ingest signing-secret set.

settingsCollection. It must name a declared collection whose write list is restricted to owner, so an installer’s config cannot be overwritten by a member. Config and upload setup steps target a field of this collection by key.

routes. At most 8 routes. Each one publishes a collection’s rows to the open web, so it must break the redeploy consent gate the way a widened externalHosts does, and it only ever renders on a public app.

Keys allowed here: agentTasks, app, capabilities, cdn, collections, embedAncestors, embeds, externalHosts, fetchers, ingest, live, notify, offline, roles, routes, schedules, settingsCollection, webhooks. Anything else is rejected.

One declared path pattern bound to a collection, resolved per request into a real page for the matching row.

FieldRequiredTypeWhat it is
pathyesstringA URL path pattern, literal segments plus at most one :param.
collectionyesstringThe declared collection this route resolves rows from.
matchOnyesstringWhich value in the URL’s :param resolves the row: the row key, or a field in the collection’s unique list.
titleyesstringThe page <title>, with {field} placeholders filled from the matched row.
descriptionnostringThe meta description, same placeholder grammar as title.
imagenoobjectWhich row field holds the page’s share-preview image, and a width hint.
summarynostring[]Top-level scalar fields rendered as a bounded, escaped summary block in the page body.
schemaTypenostringThe structured-data (JSON-LD) type to emit for this route, if any.
indexablenobooleanWhether this route’s rows are listed in the app’s own sitemap.

path. No wildcards, no regex, no nested params. A reserved prefix (/_hs/, /b/) or one of the relay’s own fixed paths (/robots.txt, /favicon.ico, /.well-known/security.txt, /sitemap.xml) is rejected, because each already has a fixed meaning on every app host. Two routes may not declare the same shape (the same literal segments with a :param in the same position), since a request could then match either one.

collection. That collection’s read list must include “anyone”: a route server-renders row content into HTML, which is the one path in the relay that has never checked a collection’s read permissions, so this is enforced at deploy rather than left to be discovered as a leak.

matchOn. The literal value “key” matches the row’s own key. Any other value must name a field the collection declares in its own unique list, so resolution is always an indexed lookup, never a scan.

title. Limit: at most 200 characters. A placeholder names a top-level scalar field of the row, or the fixed app.name / app.slug tokens. Anything else is rejected at deploy.

description. Limit: at most 400 characters.

summary. Limit: at most 12 fields. This is what makes the page genuinely crawlable rather than only the meta tags: the block is real server-rendered HTML, present before any script runs, inside a container the page’s own JS may replace on hydration.

schemaType. Allowed values: Article, Event, Offer, Product.

indexable. Defaults to false. The app-level app.indexable flag governs the app’s own root page and is unaffected by this key.

Keys allowed here: collection, description, image, indexable, matchOn, path, schemaType, summary, title. Anything else is rejected.

How the app presents itself on its page, in the console, and on a share card.

Path: x-homespun-manifest.app

FieldRequiredTypeWhat it is
nameyesstringThe app’s display name.
descriptionnostringA short description of what the app does.
iconnostringA single emoji used as the app’s icon.
iconAssetnostringAn app-relative path into this deploy’s assets[] bundle, used as the app’s icon image in place of an emoji.
indexablenobooleanWhether search engines may index a public app.
ogImagenostringAn absolute https URL used as the share-card image.
signInnostringWho besides the owner and invited members may sign in and use this app.

name. Limit: at most 80 characters. Single line and printable: control characters are rejected, because the name is rendered on the consent screen.

description. Limit: at most 280 characters.

icon. Ignored wherever iconAsset is also set: an image always wins over an emoji, the same precedence the in-app tile and the installed home-screen icon already use for a raster identity versus an emoji one.

iconAsset. Wins over icon when both are set. Must name a path this same deploy actually ships, either sent in assets[] or carried forward from the app’s current version on a redeploy that omits assets. A path the app does not ship is refused at deploy with manifest_invalid, rather than left to break the icon later. Composited onto the same brand accent tile the emoji is. Only raster image bytes render usefully: a non-image asset falls back to the emoji or the brand robot rather than erroring, the same never-404 posture the rest of the icon endpoint follows.

ogImage. Limit: at most 2048 characters.

signIn. Allowed values: anyone, members. Defaults to members: today’s behaviour, unchanged. anyone admits any signed-in Homespun account on a link or public app (ignored on a private app, which already admits every viewer through a real session); their rows are authored under their real, opaque account id, never a name or email.

Keys allowed here: description, icon, iconAsset, indexable, name, ogImage, signIn. Anything else is rejected.

Custom roles, which are what a grant link can carry, what a member can be given, and what a permission list can name as a subject.

Path: x-homespun-manifest.roles

FieldRequiredTypeWhat it is
(role name)nostring keyThe role’s identifier, used everywhere the role is referenced.
labelnostringA human-readable name for the role.
descriptionnostringWhat the role is for, shown where the role is offered.
includesnostring[]Other declared roles this role also carries, so a permission granted to the included role is granted to this one too.

(role name). Limit: must match ^[a-z][a-z0-9_]{0,63}$. A built-in name cannot be redeclared. The built-ins are anyone, author, creator, editor, member, owner.

label. Limit: at most 80 characters.

description. Limit: at most 280 characters.

includes. Limit: at most 16 entries per role, and the longest chain of includes may be at most 8 roles long counting the role itself. Composition is TRANSITIVE and resolved once, when the caller’s roles are worked out. admin including contributor, and contributor including viewer, means someone holding admin also holds contributor and viewer everywhere a permission list names them. Write the shared parts once, in the base role, rather than repeating them in every list. Every entry must name a role declared in this same roles block. A built-in name is rejected: the built-ins are anyone, author, creator, editor, member, owner, their meaning is fixed by the platform, and letting a declared role include one would let a manifest hand out owner powers. agent is retired (#1381) and is rejected here too, though it is no longer a built-in at all. The graph must be ACYCLIC. A role that includes itself, directly or through a chain, is rejected at deploy with the cycle named. Composition only ever ADDS. There is no way to say a role does NOT carry something an included role carries, because a subtraction cannot be summarized on the install screen without the reader having to work out an ordering.

Keys allowed here: description, includes, label. Anything else is rejected.

A person may hold SEVERAL of these roles at once, and holds the union of what each one grants, plus everything those roles include. A grant link is different: one link carries exactly one role, because a link is a handout of that role.

A role name is usable as a permission subject anywhere a built-in one is, including in the <role>:own and <role>:creator narrowing forms. Declaring a role does not by itself grant anything; a collection’s permission list naming it is what does.

One entry per collection. The permission lists are the app’s access-control model, so they are worth reading closely.

Path: x-homespun-manifest.collections.<name>

FieldRequiredTypeWhat it is
schemanoJSON SchemaThe shape of one row, usually a $ref into $defs.
relationsnoobject of relation name to definitionRow scopes this collection names for itself, each bound to a field of the row that holds a principal id.
readyesstring[]Who may read rows.
writeyesstring[]Who may create rows, and who may update them when `update` is not declared.
updatenostring[]Who may change an existing row. Omitted means the `write` list governs updates too, and is REQUIRED whenever `write` includes “anyone”.
deleteyesstring[]Who may delete rows.
countReadnostring[]Who may read the row COUNT without reading the rows.
keyClaimnostringWhich row keys a caller may claim when creating a row, one of free, server, or caller.
immutablenostring[]Top-level fields that can be set when a row is created and never changed afterwards.
serverSetnoobject of field name to definitionFields the relay fills in itself when a row is created. The caller can never supply one, on either data-plane door, and never by any principal including the owner.
appendOnlynoboolean or objectWhen true, rows can be added but not updated or deleted. The object form names the roles that stay exempt.
seedOnInstallnobooleanWhether this collection’s rows are captured when the app is published as a template, and copied into each install.
uniquenostring[]Fields that must be unique across the collection.
retentionnoobjectBounds how much data the collection keeps, so old rows do not accumulate without limit.
mirrornostringHow much of the collection the browser mirrors, one of auto, eager, or server.
storagenostringWhether a row is a queryable record or one opaque document, one of queryable or document.
anonWriteBudgetnoobjectA daily ceiling on how many rows anonymous visitors may add to this collection, in total and per network.
antiAbusenostringRequires a Cloudflare Turnstile verification token on every anonymous write to this collection.

schema. Omitting it is allowed and leaves the collection untyped: rows are accepted without a shape check. Declare one unless you specifically want that.

relations. Limit: at most 8 relations per collection; each name must match ^[a-z][a-z0-9_]{0,63}$ and each `field` must match ^[A-Za-z0-9_]{1,64}$. A relation is a name for “the person this row points at”. "relations": { "assignee": { "field": "assignedTo" } } makes assignee usable as a permission subject on this collection, and it admits exactly the caller whose own principal id is the value stored in the row’s assignedTo field. Usable in update, delete and read, bare or as <role>:<relation>. Refused in write and countRead, for the same reasons creator and editor are: a create has no pre-existing row to compare against, and a count is an aggregate over the whole collection rather than a view of one person’s rows. Write the create rule with roles (“who may add a row”) and the row rule with the relation (“whose row it then is”). Each definition takes field (required) and set, whose values are "caller" and "writer". With set: "caller" the SERVER stamps the field with the caller’s own principal id when the row is created and refuses every later change to it. With set: "writer" the create sets the field freely and afterwards only the row’s CREATOR may move it, which is how an agent creates a row that a named human owns without every later editor being able to reassign it. A relation that any permission list references MUST declare set, on a fresh deploy and on stored-manifest re-validation alike. A relation with no set was a rule keyed on a field every writer controls: whoever write admitted chose whom the row belonged to and could keep choosing on every later update, so a writer could name any principal id it learned from the member directory and hand that person access nothing in the app decided to give them. A relation nothing references is unaffected, since it authorizes nobody. The value compared is the caller’s principal id: their human id when a person is signed in, the agent id when an agent is calling, or the grant-claim id for a grant-link holder. A page reads its own from the session, and other people’s from the member directory. A field holding an email address or a display name matches nobody and grants nothing. A relation name may not be a built-in subject name, may not be own, and may not be a role declared under roles. Those names already mean something in a permission list, and a relation allowed to shadow one would change what an existing list admits. If the collection declares a row schema, the named field must be a declared top-level property of it that can hold a string. A relation pointed at a field the schema does not admit could never match a row, and with set: "caller" the server’s own stamp would fail schema validation on every write, so it is a deploy error rather than a scope that silently matches nothing.

read. Allowed values: anyone, author, creator, editor, member, owner. Required on every deploy, and it is the one list where an empty array and an absent key mean opposite things. read: [] means nobody may read the rows. An ABSENT read means everyone who can open the app may read them, which is why the key is no longer allowed to be absent: a collection whose author never thought about reading used to get the widest possible answer by default. Say which you mean. read: ["anyone"] is the affirmative form of that old default and is perfectly legal when the data really is public. A manifest that was already stored before the key became mandatory is re-validated leniently when it is published to the community, installed from a Snapshot or trialled as a template, so an app published under the older rule keeps installing. Only a new deploy is held to it. Adding the key to a live collection is never gated by the compat check, unlike adding update. Silence already meant everyone, so any list you write is at most as wide, and a narrowing needs no re-consent.

write. Allowed values: anyone, member, owner.

update. Allowed values: anyone, author, creator, editor, member, owner. Splitting update out of write is what makes a per-user collection expressible: write: ["anyone"] with update: ["creator"] lets anyone signed in add a row while only the person who created it may change it. Without the split, everyone admitted to write can overwrite everyone else’s rows. REQUIRED when write includes “anyone”: a deploy is refused permission_role_invalid if you leave it off, because an omitted update would let any anonymous caller overwrite rows it did not create. Every affirmative answer stays legal, including update: ["anyone"], which is how you keep the inherit-from-write behaviour deliberately. Optional everywhere else, where write is already narrow. An empty array is legal and means nobody may update, the same absent-versus-empty distinction read draws. Adding the key to a live collection only ever NARROWS who may update, so it is never a compat break and never needs force: a redeploy that adds one goes through clean.

delete. Allowed values: anyone, author, creator, editor, member, owner.

countRead. Allowed values: anyone, author, creator, editor, member, owner. This is what makes a “3 spots left” display possible for someone who cannot see the entries. It stays independent of read, so a collection can publish how many rows it holds while the rows themselves stay owner-only. A row-scoped subject returns the caller’s OWN count instead of the whole collection’s. countRead: ["creator"] answers “how many have I added”, and a declared relation answers “how many name me”. The filter is the same one a list applies under the same subject, so a count can never include a row the caller could not have listed. The subjects that describe a person’s relationship to a row need an identity to compare against, so a caller with none is refused rather than handed a count of zero.

keyClaim. Allowed values: caller, free, server. Defaults to free, which is the behaviour every collection has always had: a create is gated by the write list alone and never looks at the key, so on a guessable key (profile, a username, today’s date) the first caller to write it owns that slot for good. Deleting the row does not free it either, because the next create over the tombstone stamps a fresh creator. server refuses a caller-supplied key outright: every key is minted by the server, so there is no key to race for. Use it for a collection whose rows are found by listing or by a unique field rather than by a name the app composes. caller requires the key to BE the caller’s own principal id. That makes a one-row-per-person collection squat-proof by construction, because the only key anybody can claim is the one nobody else can hold. A create that omits the key gets that id (it is the only legal value), and one that supplies a different key is refused. A caller with no identity at all cannot create under it, which is the fail-closed direction. Never loosens anything: it is only ever an extra refusal on top of the write list, so a collection that declares it admits a subset of the creates it admitted before.

immutable. Limit: at most 16 fields; each must match ^[A-Za-z0-9_]{1,64}$. An update that omits a frozen field carries the stored value forward, and one that sends a DIFFERENT value is refused outright. Sending the value back unchanged is fine, because that is what an honest client echoing a row does. This is what closes the hole in any rule keyed on a field the caller controls: whoever write admits chooses the value, and without a freeze they can keep choosing after the fact. Listing that field here pins the answer to whatever the create said. When the collection declares a row schema, every name must be a declared top-level property of it: a frozen field the schema does not admit could never appear on a row. A set: "caller" relation field is already frozen by the relation itself, so listing it here is allowed but adds nothing. A set: "writer" field is frozen for everyone except the row’s creator; listing it here freezes it for the creator too, which is the right choice when the assignment must never change after the create.

serverSet. Limit: at most 8 fields per collection; each name must match ^[A-Za-z0-9_]{1,64}$ and must be a declared top-level property of the collection’s row schema. This COMPUTES a value, it does not VALIDATE one. There is no way to express “reject this write if the total does not match” or any other condition on a row’s value or state: conditional permission rules are deliberately out of scope (issue #1238), because an expression language over row contents cannot be summarized on the install screen the way a fixed grammar can, and because LLM-authored authorization measurably gets WORSE, not better, when handed that kind of escape hatch. A field either computes cleanly from data the app already has, or serverSet is the wrong tool and the write should be modeled some other way. Exactly two forms, and only one may be declared per field. LOOKUP, { "from": "<collection>", "keyField": "<field>", "take": "<field>" }: keyField names a field on the row being written whose value is the ROW KEY of a row in the from collection, and take names the field to copy off that row. The match is by row key, never by an arbitrary field value. ARITHMETIC, { "product": ["a", "b"] } or { "sum": ["a", "b"] }: each entry in the list is either a serverSet field declared EARLIER in the same collection or a plain field on the row being written. A caller-supplied value for a serverSet field is REJECTED, never silently overwritten. On create, the field’s mere presence in the payload is refused, whatever value it carries, even a value that happens to match what the relay would have computed anyway. On update, an honest echo of the row’s existing value is accepted (the same courtesy every other frozen field extends to a client that reads a row and PATCHes it back unchanged), but a payload that tries to CHANGE it is refused. A lookup’s keyField is implicitly frozen too, exactly like a field listed under immutable. Freezing only the computed OUTPUT and leaving the INPUT open would let a later update retarget an already-computed value at a different source row without ever touching the field that carries it, which defeats the whole point. A missing (or deleted) source row fails the WRITE: the create is refused rather than landing with a blank or stale value. This is a feature, not a validation gap. An order line cannot reference a product that does not exist. Depth is exactly one. A lookup’s take may not itself name a field that is serverSet on the SOURCE collection: no chaining across collections, and evaluation is a single pass in declaration order within one collection. Evaluation happens once, on create. A frozen keyField means the computed value can never go stale, so it does not need to be, and is not, recomputed later: a product’s price changing tomorrow correctly leaves an order line already written at the price it was sold for. Worked example: a shop’s order_lines collection takes its price and product name from its own products collection, and computes a line total from them. "order_lines": { "serverSet": { "unitPrice": { "from": "products", "keyField": "productKey", "take": "sellPrice" }, "productName": { "from": "products", "keyField": "productKey", "take": "name" }, "lineTotal": { "product": ["unitPrice", "quantity"] } } }. A caller creates a row with productKey and quantity only; the relay looks up the named product, fills in unitPrice and productName, and multiplies unitPrice by the caller’s own quantity into lineTotal. Sending unitPrice (or productName, or lineTotal) in the payload is refused.

appendOnly. true means exactly what it says, for EVERY role including the owner, and the check runs before any permission list is consulted so a violation reports append_only rather than a misleading forbidden. Declaring it beside an update list is a deploy error: there would be nobody left for the list to admit. The object form { "except": [...] } keeps all of that for everyone the list does not name, and lets the named roles update and delete under the collection’s ordinary rules. The list may name owner, and nothing else: that is the one role that can already remove a row from an append-only collection with a purge, so this hands out no reach it did not have, it just lets it CORRECT a row instead of only destroying it. Declare it when a stuck test row would otherwise be unremovable for good. agent is retired (#1381) and is rejected outright wherever a role name may appear, this list included; it always resolved to the owner’s own authority, so use owner. An excepted role still has to satisfy the collection’s own update / delete list, so the exception opens the gate rather than granting the verb. Everybody else is still refused before their roles are looked at, so they keep getting append_only.

seedOnInstall. Defaults to false. It is a FLAG, not a list: you cannot write starter rows inline in the manifest. The rows come from the published app’s own collection, read at publish time, and are copied into every app installed from that template. So the way to ship starter content is to deploy the app, write the rows you want everyone to start with, then publish it as a template. Whatever is live in a seedOnInstall collection at that moment becomes the seed. Bounded by the relay’s total seed row count and byte size, so a large collection cannot produce an oversized template.

unique. Applies to TOP-LEVEL fields of a row’s data only, never a nested or pointer path. When the collection declares a schema, every name here must already be one of its declared top-level properties, the same rule immutable and a relation’s field carry, because a name the schema does not admit could never appear on a row in the first place. Row data is JSONB, so an arbitrary declared field cannot be indexed on the row table itself. Instead each field’s value is normalized (strings trimmed and case-folded, numbers and booleans compared by their canonical value, and an absent, null, or empty-after-trim value claiming no slot at all, so many rows may share “no value”) and mirrored into a RowUniqueKey side table carrying a real database UNIQUE constraint. That mirror row is written inside the same transaction as the row write, so a colliding create, upsert, or update is decided atomically by the database with no separate check-then-write step to race. A collision on write returns 409 unique_conflict, naming the field that collided, and steers the caller to use a different value or to upsert on that field to update the existing row instead of creating a duplicate. Deleting a row frees the values it held so a later row may reuse them, which is also why a restore can lose a race it did not know it was in: 409 restore_conflict fires when another live row claimed one of those values while this row sat tombstoned, and the row stays recoverable so the caller can resolve the collision and restore again. An ingest rule with mode: "upsert" must set upsertOn to a field already declared in this same collection’s unique list, rejected at deploy otherwise: the merge key an ingest hook writes on has to be enforceably unique, or two concurrent deliveries would race to create duplicate rows instead of merging into one. Adding a field here is never a compat break the redeploy gate refuses on its own. On a collection that already holds live rows, the deploy backfills a RowUniqueKey for every one of them under the new field, inside the same transaction as the rest of the redeploy, so the guarantee holds retroactively rather than only for writes going forward. If two or more of those live rows already share a value for the field being added, the backfill collides and the WHOLE deploy is rejected, 409 unique_backfill_conflict naming the field, rather than landing the constraint over data it cannot actually hold; resolve the duplicates first, then redeploy. Dropping a field from unique stops it being maintained on writes immediately, but its side-table keys are left in place rather than cleared, so re-adding the same field later backfills cleanly from the collection’s current data rather than trusting anything stale.

retention. Its keys are maxAgeDays and maxRows, each an optional positive integer, and at least one must be present. maxRows keeps only the newest N rows; maxAgeDays keeps only rows younger than N days. maxRows may not exceed the per-app row cap. Not accepted on the settings collection (settingsCollection), whose single install-config row must never be pruned.

mirror. Its value is one of auto, eager, server, and it defaults to auto. auto mirrors up to the per-account cap, then flags the collection serverBacked. eager always mirrors every row, ignoring the cap. server never mirrors: the SDK marks the collection serverBacked immediately and the app reads it with list() and count().

storage. Its value is one of document, queryable, and it defaults to queryable. Leave it alone unless a single row genuinely IS one document the browser reads and writes whole: a mind map, a diagram, a graph of nodes and edges. queryable is the ordinary mode. The row payload is indexed, so an equality filter on one of its fields stays cheap as the collection grows, and the row is bounded by the ordinary per-row byte cap. document raises that cap substantially, in exchange for the payload no longer being indexed. Filtering still WORKS, it just stops being something the platform keeps fast, so reach for it when the querying happens in the browser rather than on the server. Keep the fields you do filter or sort on (an owner id, a title, a timestamp) as small top-level fields; only the bulky part of the row is the document. It is not a way to store files. Images and other binary content belong in attachments, with only the attachment id in the row, whichever mode the collection declares.

anonWriteBudget. Limit: keys are exactly `perAppPerDay` and `perIpPerDay`, both required, each a non-negative integer. perIpPerDay bounds one visitor’s network: at most that many rows from the same truncated IP address per day. perAppPerDay bounds the collection as a whole: at most that many anonymous rows from ALL visitors combined per day. 0 means unlimited for that dimension, the same convention every other daily cap in this platform uses. Only meaningful, and only accepted, on a collection whose write list includes "anyone": a budget on a collection anonymous visitors cannot write to at all would be a promise the manifest can never keep, so it is rejected at deploy rather than silently doing nothing. Never applies to an owner or member write, whatever it is set to. It exists to stop a slow drip of anonymous rows from filling the app’s own row quota (MAX_ROWS_PER_APP) before the owner ever gets to use it, not to limit the app’s own people. Sits ALONGSIDE the platform’s existing per-IP rate limiter (which bounds burst speed, not total), not instead of it. A collection can decline every write once its daily total is spent even though each individual request was well within the rate limit.

antiAbuse. Allowed values: turnstile. The app fetches a token from a Turnstile widget (configured with the relay’s TURNSTILE_SITE_KEY) and attaches it to the write; the relay verifies it with Cloudflare before the row is created. A missing token is refused (turnstile_required); a token Cloudflare rejects is refused (turnstile_verification_failed); a verified token is never stored. FAILS OPEN. If Cloudflare cannot be reached in time, the write proceeds rather than being blocked: an operator’s Cloudflare outage must not be able to take down every public form on the platform at once. The anonWriteBudget cap above is what makes that acceptable: even for the duration of a real outage, an attacker’s total is still bounded by the daily budget. Same audience restriction as anonWriteBudget: only meaningful, and only accepted, on a collection whose write list includes "anyone". Requires the relay operator to have configured both TURNSTILE_SECRET_KEY and TURNSTILE_SITE_KEY; declaring this on a relay where either is unset is rejected at deploy, the same posture externalHosts and embeds take toward a capability the operator has not turned on.

Keys allowed here: anonWriteBudget, antiAbuse, appendOnly, countRead, delete, immutable, keyClaim, mirror, read, relations, retention, schema, seedOnInstall, serverSet, storage, unique, update, write. Anything else is rejected.

Three of the built-in subjects name a person by their relationship to one ROW rather than to the app, and the difference between them is load-bearing. creator is whoever created the row; it is stamped once and never moves, whoever writes the row next. editor is whoever wrote the row LAST, so any later write, including one by the app’s own agent, transfers it. author is a legacy alias of editor and carries the same last-writer meaning; it is accepted forever, but it reads as the creator and is not, so reach for creator in anything new and use editor when you really do mean the last writer.

A role name may carry the :own or :creator suffix, which narrows the permission to a subset of the collection’s rows. :own is bound to the last-writer meaning, the one it has always had, so members:own on delete lets a member delete rows they wrote and nobody else’s. :creator narrows to rows the caller CREATED, so members:creator survives a later write by someone else.

A custom role must be declared under roles before a permission list can name it, and that includes the base of a :own, :creator or :<relation> subject.

A collection’s own relations add to that vocabulary. A declared relation name is a subject in update, delete and read, and <role>:<relation> narrows a role to the rows that relation points at, the same shape as :own and :creator. Those two stay predeclared rather than becoming ordinary relations: they compare the row’s identity columns, which every row carries and no author declares, while a relation compares a field of the row’s own data.

read, write and delete are REQUIRED on every collection. write and delete must each be a non-empty array; read may be empty, which is how you say nobody reads it. update and countRead are optional. Declaring who may see data, and who may change it, is not something an app gets to leave implicit.

Rules that turn a data change into a notification.

Path: x-homespun-manifest.notify[]

FieldRequiredTypeWhat it is
tonostring[]Who receives the notification.

to. Allowed values: members, owner, submitter.

Keys allowed here: body, channels, collection, excludeActor, link, on, subject, submitterEmailField, to, when. Anything else is rejected.

Each rule’s condition object is validated against its own key allowlist: authorKindIn, authorKindNotIn, changedTo, equals, field, gt, gte, in, lt, lte, notEquals, notIn.

authorKindIn/authorKindNotIn test the WRITE’s author kind instead of a row-data field, so a condition using either carries no field and no other key. Accepted values: human, agent, service, hook, grant, visitor, anon, system.

link is optional and is where the recipient’s client sends them: a static same-origin path such as /orders, starting with /. It is deliberately NOT a template, because the link travels in a web push payload handed to a third-party push service and must never carry row data. The app reads /_hs/notifications through its normal authorization path to learn which row.

channels: ["push"] additionally needs x-homespun-manifest.offline: true, because web push is delivered by the service worker and the relay only serves one to an app that declares offline.

Time-based rules, for reminders and recurring work.

Path: x-homespun-manifest.schedules[]

FieldRequiredTypeWhat it is
tonostring[]Who the scheduled notification reaches.

to. Allowed values: members, owner.

Keys allowed here: body, bodyTemplate, collection, connection, dateField, machineAuthorKinds, offsetDays, subject, to, url, urlFromSetting, when. Anything else is rejected.

An app that declares schedules should also set a timezone, or reminders fire at 08:00 UTC. Set it with homespun apps update <app> --timezone <IANA zone>.

when shares notify’s condition grammar (authorKindIn, authorKindNotIn, changedTo, equals, field, gt, gte, in, lt, lte, notEquals, notIn), LEVEL forms only - a periodic scan has no before-state, so changedTo is rejected. authorKindIn/authorKindNotIn test the row’s OWN stored author kind.

A webhook-shaped rule (one that carries url/urlFromSetting rather than to) does NOT fire for a row whose stored author kind is a MACHINE kind, the same set the webhooks[] section above default-suppresses on, even when when would otherwise match. A rule opts back in by writing a when that itself tests author kind - authorKindIn/authorKindNotIn - which replaces the default suppression entirely for that rule. An email-shaped rule (one that carries to) is unaffected and fires on any author kind.

Outbound HTTP calls fired when data changes.

Path: x-homespun-manifest.webhooks[]

FieldRequiredTypeWhat it is
urlnostringA hardcoded https endpoint the signed payload is POSTed to.
urlFromSettingnostringNames a top-level string field of the settingsCollection whose value is the target URL, resolved at send time.
responseIntonoobjectAfter a successful delivery, write the target’s parsed JSON response onto a row.

url. https only, no userinfo, and a DNS host (no IP literal or wildcard); runtime SSRF is re-checked at send time. Exactly one of url or urlFromSetting is set. A bare url is rejected by community publish: a published template must use urlFromSetting so the endpoint belongs to the installer, not the publisher.

urlFromSetting. Requires x-homespun-manifest.settingsCollection, and the named field must be a declared string field of it. Exactly one of url or urlFromSetting is set. The worker reads the current install-config row each delivery pass, so an owner editing the field takes effect without a redeploy; an unset or invalid value fails the delivery closed. Pair it with a config setup step so the installer supplies the endpoint.

responseInto. { collection, matchOn, matchFrom, map }. collection must be declared; matchOn must name one of ITS declared unique fields, so the write-back is always an indexed lookup, never a scan. matchFrom and each value in map are placeholder templates - {{row.<field>}} reads the row that triggered the delivery, {{response.<field>}} reads the target’s parsed JSON response body - rendered the same JSON-safe way bodyTemplate is, then JSON.parsed back into the typed value. The write is attributed to the platform-mediated hook author kind, the same one core/ingest’s catch-hook writes use, which is a MACHINE kind. That is what stops the write-back from re-triggering the rule that produced it (or any other rule that has not opted in via machineAuthorKinds), even when responseInto.collection is the rule’s own trigger collection. Best-effort: a non-JSON response body, no row matching matchFrom, or a rejected write (schema, append-only, quota) is logged and skipped, never retried - the outbound delivery is already delivered by the time this runs.

Keys allowed here: bodyTemplate, collection, connection, machineAuthorKinds, on, responseInto, url, urlFromSetting, when. Anything else is rejected.

The delivery log is readable through GET /v1/apps/:id/webhooks/deliveries, filterable by collection and status, and through homespun connections deliveries. Each entry carries the request body that was sent alongside the target’s status and response, which is what tells you whether a 4xx was your payload or their endpoint. The app owner sees the same rows, and can re-send one, in the Recent webhook deliveries panel on the app’s page in the console.

when shares notify’s condition grammar: authorKindIn, authorKindNotIn, changedTo, equals, field, gt, gte, in, lt, lte, notEquals, notIn.

A rule does NOT fire for a write whose author kind is a MACHINE kind (hook, the ingest catch-hook writer, and service, a scoped app credential such as an external backend or an agent task’s lease), even when when would otherwise match. This is what stops a webhook rule from re-firing on its own write-back and looping. A rule opts back in by writing a when that itself tests author kind - { authorKindIn: [...] } or { authorKindNotIn: [...] } - which replaces the default suppression entirely for that rule.

One entry per fetcher, keyed by the name app code calls it by. Grammar and deploy-time validation only for now: there is no invoke route yet.

Path: x-homespun-manifest.fetchers.<name>

FieldRequiredTypeWhat it is
(fetcher name)nostring keyThe call name app code will use to invoke this fetcher.
methodnostringThe HTTP method the relay issues.
urlyesstringThe https endpoint the relay calls.
connectionnostringNames a stored Connection whose credential the relay attaches server-side.
allownostring[]Who may invoke this fetcher.
paramsnoobject of param name to specDeclared, typed inputs a caller may supply, each { type, and per-type bounds }.
querynoobject of query-string field to templateQuery-string fields to add to the request.
headersnoobject of header name to templateRequest headers to add.
bodynoobject of body field to templateRequest body fields to send.
picknostring[]Response field paths to keep; when present, only these are returned.
cachenoobjectHow long a response may be reused, and for whom.
budgetyesobjectThe spend ceiling this fetcher may never exceed.

(fetcher name). Limit: must match ^[A-Za-z][A-Za-z0-9_]{0,63}$.

method. Allowed values: DELETE, GET, PATCH, POST, PUT. Defaults to GET when omitted. Any method is permitted.

url. https only, no userinfo, and a DNS host (no IP literal or wildcard) - the identical rule a webhooks[] rule’s own url follows, and enforced by reusing that SAME validator. Unlike webhooks, there is no urlFromSetting variant: a fetcher is called from app code that already knows which endpoint it means, not fired blind on a data change.

connection. Optional: a fetcher may target a public API that needs no credential. Only the name grammar is checked here (the same one webhooks[]‘s own connection key uses) - the named Connection need not exist yet, since it can be created after this deploy.

allow. An OMITTED allow resolves to [‘owner’], not the widest role available. This is deliberate: a fetcher spends the OWNER’s own connection credential and quota, so on a public app a missing line would otherwise make the app an open proxy for anyone who can load it. Write allow: [‘anyone’] explicitly to get that. Accepts ‘owner’, ‘member’, ‘anyone’, or a declared custom role - not the row-scoped subjects (‘creator’, ‘editor’, ‘author’) a collection’s own read/write lists admit, since a fetcher call has no existing row to scope against.

params. Limit: at most 12 params. type is one of boolean, integer, number, string. A string param may add maxLength (at most 4096) and/or pattern (checked for length, compilability and nested-quantifier ReDoS the same way any other regex anywhere in the manifest document is, since a collection schema’s own pattern keyword is checked by the SAME whole-document scan); a number/integer param may add min and/or max, each within +/-1000000000000000. This is the WHOLE declared surface a caller can shape a request with: query/headers/body may reference only a param declared here.

query. Limit: at most 20 fields, each at most 500 characters with at most 10 placeholders. Each value may reference {{params.<name>}} for a name declared under params. An unknown placeholder, or one naming an undeclared param, is a deploy error - this is what stops a client shaping the request beyond its declared holes.

headers. Limit: at most 20 fields, each at most 500 characters with at most 10 placeholders. Same {{params.<name>}} grammar as query. Names must be real HTTP header tokens, and headers carrying authority or steering routing (authorization, cookie, host, x-forwarded-*, content-length, …) are refused at deploy: that channel is connection alone. A plain Accept or X-Api-Version is the intended use.

body. Limit: at most 20 fields, each at most 500 characters with at most 10 placeholders. Same {{params.<name>}} grammar as query and headers.

pick. Limit: at most 20 entries, each a dot-path of at most 256 characters. Data minimisation: a credentialed endpoint may return far more than the app should see. Same dot-path grammar as an ingest rule’s own map values.

cache. { seconds, scope? }. seconds is a positive integer, at most 3600. scope is ‘viewer’ or ‘app’, defaulting to ‘viewer’: a credentialed response can be viewer-specific, and an app-wide cache would serve one viewer’s response to another.

budget. { perSessionPerMinute, perAppPerDay }, both required positive integers (at most 120 and 100000 respectively). Mandatory on every fetcher: outbound spend lands on a THIRD PARTY’s bill, which needs a ceiling more than an anonymous write does, so there is no default to fall back to.

Keys allowed here: allow, body, budget, cache, connection, headers, method, params, pick, query, url. Anything else is rejected.

This PR validates the grammar only. There is no invoke route, no relay-side cache, and no budget enforcement yet - a follow-up adds the runtime that actually calls a validated fetcher.

A fetcher exists so an app with no server-side code can reach a third-party API without putting the credential in the browser, where collection-level permissions cannot hide it per field: the relay attaches the named connection’s credential server-side instead.

Rules that turn a data change into a unit of work for the app owner’s own agent.

Path: x-homespun-manifest.agentTasks[]

FieldRequiredTypeWhat it is
taskTypeyesstringA routing label for the kind of work, such as parse-receipt. Not interpreted by the relay.
promptyesstringWhat the work is, in words.
readsnostring[]Collections the task is allowed to read, by declared collection name.
writesnostring[]Collections the task is allowed to write.
ttlSecondsnointegerHow long the task stays claimable before it is expired unclaimed.
leaseSecondsnointegerHow long one worker holds the task before it returns to the queue.

taskType. Limit: matches ^[a-z0-9][a-z0-9_-]{0,63}$. It exists so a worker can send different kinds of task to different handlers without reading the prompt, and so a queue is legible to the person who owns it.

prompt. Limit: at most 4000 characters. This is the one part of a task that is trusted: it comes from the manifest the owner consented to, not from row data. The row that triggered the task travels in a separate context field, and a worker must treat that as data to be examined, never as further instructions. Write it as you would a brief for a capable colleague who cannot ask you a follow-up question. Say what to produce and where it goes. Control characters are rejected; newlines and tabs are fine.

reads. Limit: at most 16 entries. Every name must be a collection this same manifest declares, so a task can never be granted reach the app itself does not have. Defaults to empty, which grants nothing beyond the triggering row’s own context.

writes. Limit: at most 16 entries. Same rule as reads, and this is the one to be spare with: it decides what the work can change. Name only the collection the result belongs in. Defaults to empty.

ttlSeconds. Limit: at most 604800 seconds.

leaseSeconds. Limit: at most 3600 seconds. A worker that needs longer extends its lease rather than declaring a large value here: a long lease is also how long a crashed worker’s task sits idle before anyone else can pick it up.

Keys allowed here: collection, leaseSeconds, machineAuthorKinds, on, prompt, reads, taskType, ttlSeconds, when, writes. Anything else is rejected.

The relay executes nothing. It queues the task, hands it to exactly one worker at a time under a lease, and records what came back. The work runs on the owner’s machine, with whatever agent they use.

when shares notify’s condition grammar: authorKindIn, authorKindNotIn, changedTo, equals, field, gt, gte, in, lt, lte, notEquals, notIn.

A rule does NOT fire for a write whose author kind is a MACHINE kind, the same set the webhooks[] section default-suppresses on, which includes the service kind a task’s own lease credential writes as. That is what stops a task from re-triggering itself on the result it just wrote. A rule opts back in with a when that itself tests author kind, and a rule that does so on a collection it also writes to is asking for an endless cascade.

A rule may not appear in a published community template. A template’s task would carry the publisher’s prompt and the installer’s data, which is not a thing an installer can meaningfully consent to, so community publish rejects it outright.

These bound what a manifest may declare. They are resolved at build time from packages/relay/src/config.ts, and an operator running their own relay can override any of them.

Env varDefaultMeaning
MAX_MANIFEST_BYTES65,536Whole manifest document
MAX_SCHEMA_BYTES65,536One row-schema $defs entry
MAX_SCHEMA_DEPTH32Nesting depth, manifest and row schemas
MAX_COLLECTIONS_PER_APP32Collections per manifest
MAX_EXTERNAL_HOSTS10externalHosts entries
MAX_EMBEDS10embeds entries
MAX_RECORDS_PER_COLLECTION50,000Rows per (app, collection); 0 means unlimited
MAX_RECORD_DATA_BYTES65,536One row’s serialized data
MAX_RECORDS_PER_PAGE200Hard pagination ceiling
MAX_ROWS_PER_APP100,000Whole-app row count
MAX_STORAGE_BYTES_PER_APP1,073,741,824Whole-app storage
MAX_BLOB_BYTES5,000,000Per-attachment upload
MAX_BLOBS_PER_APP_BYTES262,144,000Per-app attachment aggregate
MAX_BLOBS_PER_AGENT_BYTES500,000,000Per-agent attachment aggregate
BLOB_PRESIGN_TTL_SECONDS600Presigned upload URL lifetime
BLOB_TOKEN_TTL_APP_SECONDS2,592,000/b/<token> app-scope capability URL
BLOB_TOKEN_TTL_AGENT_SECONDS86,400/b/<token> agent-scope capability URL
RATE_LIMIT120General per-IP limiter on /v1/* and /s/*; 0 disables
RATE_LIMIT_WINDOW_SECONDS60Window for RATE_LIMIT
REGISTER_RATE_LIMIT5POST /v1/register per IP
REGISTER_RATE_WINDOW_SECONDS3,600Window for REGISTER_RATE_LIMIT
MAGIC_LINK_RATE_LIMIT3Keyed on (IP, email)
MAGIC_LINK_RATE_WINDOW_SECONDS900Window for MAGIC_LINK_RATE_LIMIT
MAX_APPS_PER_AGENT50Open apps per agent; 0 means unlimited
MAX_PARTICIPANTS_PER_APP32Members per app
MAX_WS_CONNECTIONS_PER_APP16Concurrent WebSocket connections per app
DEFAULT_TTL_SECONDS15,768,000Default app lifetime
MAX_TTL_SECONDS31,536,000Maximum app lifetime
APP_GRANT_TTL_SECONDS60One-time main-to-usercontent handoff credential
APP_SESSION_TTL_SECONDS2,592,000App session token
INVITE_TOKEN_TTL_SECONDS604,800Member-invite link
FEED_PAGE_MAX500Change-feed catch-up pagination ceiling
ROW_PAGE_MAX1,000Full row-snapshot pagination ceiling