Skip to content

SDK reference

<script>
document.addEventListener("DOMContentLoaded", async () => {
await homespun.ready;
const row = await homespun.collections.create("todos", { text: "buy milk" });
console.log("created", row.key);
homespun.collections.on("todos", (delta) => {
console.log(delta.kind, delta.row);
});
});
</script>

Homespun injects this API into every deployed app automatically, there is nothing to import. Read “Script ordering” below before writing anything that touches window.homespun at the top level of your own script.

This page is generated at build time from the SDK’s own surface (packages/relay/src/sdk/sdk-catalog.ts), and a relay test boots the real API and asserts the two match in both directions. A method cannot ship undocumented, and this page cannot describe one that does not exist.

The SDK is injected as <script src="/_hs/sdk.<hash>.js" defer>. A defer script runs after the page has parsed but before DOMContentLoaded, and, importantly, AFTER any plain, non-deferred inline <script> on the page. If your own script references homespun.* at its top level (not inside a callback), it runs before window.homespun is defined and throws ReferenceError: homespun is not defined, silently, with no visible error unless you check the console.

Fix it one of two ways. Wrap anything touching window.homespun in a DOMContentLoaded listener, which is the safer default regardless of where your <script> tag sits. Or mark your own script defer too and place it after the SDK’s tag; deferred scripts run in document order, so a later defer script always sees a defined window.homespun.

Never reference window.homespun at the top level of a plain inline <script>: it is not defined yet when that line runs.

Every collection read returns rows of one shape:

{
key: string,
data: unknown,
version: number,
author: { kind: "agent" | "human", id: string },
createdAt: string,
updatedAt: string,
}

Pass row.author straight to homespun.members.nameFor() to render a name.

A Promise that resolves once the session is resolved and every declared collection has been snapshotted into the local mirror.

homespun.ready: Promise<void>

Await it before your first synchronous read.

Rejects after a 20-second connection timeout if the realtime connection never establishes.

Read-only, manifest-derived facts about the app itself, kept live.

homespun.app.slug: string

The app’s slug, which is the subdomain it serves on.

homespun.app.name: string

The app’s display name from its manifest.

homespun.app.description: string | null

The app’s description, or null when the manifest omits one.

homespun.app.icon: string | null

The app’s icon, or null when the manifest omits one.

homespun.app.visibility: "private" | "link" | "public"

Who can open the app.

homespun.app.collections: string[]

Names of every collection the manifest declares.

Who is looking at the page right now, and how to change that.

homespun.session.kind: "human" | "anon"

Whether the viewer is signed in.

homespun.session.humanId: string | null

The signed-in viewer’s stable id, or null when anonymous.

homespun.session.displayName: string | null

The signed-in viewer’s display name, or null when anonymous.

homespun.session.visitorId: string | null

This BROWSER’s anonymous-visitor id on this app, or null. Identifies a browser, not a person: a cleared cookie or another device is a different visitor. Use it to fill a relation field naming yourself; never as proof of identity.

homespun.session.person: { id: string } | null

The opaque id of a signed-in Homespun account who is not a member of this app, or null. kind stays anonymous for this viewer on purpose, use person to recognise a returning signed-in visitor instead.

Opaque id only, never a name or email. Rows this person writes are already stamped with the same id, so it discloses nothing merely browsing would not already reveal once they act.

Null both for a true anonymous visitor and for an owner or member, who are already fully identified by humanId and displayName.

homespun.session.login(): void

Sends the browser to sign in and back.

homespun.session.logout(): Promise<void>

Ends the viewer’s session on this app.

The app’s stored rows. Reads marked synchronous run against an in-memory mirror kept in sync by the realtime connection; the rest are network calls.

homespun.collections.snapshot(name); // HomespunRow[], synchronous, [] before ready
homespun.collections.get(name, key); // HomespunRow | undefined, synchronous
homespun.collections.on(name, handler); // returns an unsubscribe function
homespun.collections.create(name, data); // Promise<HomespunRow>, server-generated key
homespun.collections.upsert(name, key, data); // Promise<HomespunRow>
homespun.collections.update(name, key, data, { ifMatch }); // Promise<HomespunRow>
homespun.collections.delete(name, key, { ifMatch }); // Promise<void>
snapshot(name: string): HomespunRow[]

Returns every row of a collection from the local mirror, synchronously.

Returns [] until homespun.ready resolves.

get(name: string, key: string): HomespunRow | undefined

Returns one row from the local mirror by key, synchronously.

status(name: string): { serverBacked: boolean, mirroredRows: number, mirroredBytes: number }

Reports whether a collection’s local mirror has hit its per-account cap, synchronously.

Every declared collection mirrors fully in the browser up to a per-account row and byte cap. Below the cap this reports serverBacked: false and snapshot() has everything. Past it, snapshot() keeps returning its mirrored window (the newest rows) rather than throwing or growing further, and serverBacked flips to true, including mid-session if a collection grows past the cap while the app is open. Use list() or count() to reach the rest once serverBacked is true.

list(name: string, opts?: { where?, sort?, limit? }): Promise<ListPage>

Runs a filtered and sorted NETWORK read of a collection, returning one page.

Distinct from snapshot, which reads the local mirror. Resolves to { rows, next_cursor, has_more }, so pass the cursor back to page through a collection larger than the mirror.

count(name: string): Promise<number>

Returns the live row count of a collection from the server.

Works even where the caller cannot read the rows, as long as the manifest opted the collection in with a countRead role list. That is the “3 spots left” shape: a public visitor sees the count without the rows.

Rejects with collection_count_forbidden when the collection did not opt in, or the caller lacks a listed role. This is a network read, so it is a Promise where snapshot is synchronous.

on(name: string, handler: (delta: RowDelta) => void): () => void

Subscribes to row changes in one collection.

Delivers { kind: “upsert”, collection, row }, { kind: “delete”, collection, row: { key, deletedAt } }, or { kind: “status”, collection, serverBacked: true } the moment the collection hits its mirror cap (see status()). Call the returned function to unsubscribe.

create(name: string, data: unknown, opts?: { turnstileToken?: string }): Promise<HomespunRow>

Creates a row with a server-generated key.

opts.turnstileToken is required only when requiresTurnstile(name) is true: attach a token from a Turnstile widget you render yourself, or the write is refused with turnstile_required.

upsert(name: string, key: string, data: unknown, opts?: { turnstileToken?: string }): Promise<HomespunRow>

Creates a row at a caller-chosen key, or returns the row already there.

The one call that accepts a caller-chosen key. A collision on a row you may read returns that row unchanged; a collision on a row the collection’s read list hides from you throws row_not_found, the same answer get() gives for that key, so upsert can never read past read.

opts.turnstileToken: same contract as create().

requiresTurnstile(name: string): boolean

Whether a collection currently declares antiAbuse: “turnstile”, so create()/upsert() on it needs a turnstileToken.

Reflects the most recent hello. False for a collection with no such declaration, and false before homespun.ready resolves.

update(name: string, key: string, data: unknown, opts?: { ifMatch?: number }): Promise<HomespunRow>

Replaces a row’s data, optionally guarded by a version check.

A stale ifMatch rejects with a conflict error whose details.current is the winning row, already folded into your local mirror by the time the promise rejects, so your next read is correct without waiting on a realtime message.

delete(name: string, key: string, opts?: { ifMatch?: number }): Promise<void>

Deletes a row, optionally guarded by a version check.

The raw, unfiltered change feed: every create, update and delete across the app, in order.

const unsubscribe = homespun.feed.on(
(entry) => {
console.log(entry.op, entry.collection, entry.key);
},
{ collection: "todos" }, // optional
);
homespun.feed.cursor; // highest feed sequence number applied locally so far
on(handler: (entry: FeedEntry) => void, opts?: { collection?: string }): () => void

Subscribes to the raw feed, optionally narrowed to one collection.

homespun.feed.cursor: number

The highest feed sequence number applied locally so far.

A FeedEntry carries { seq, op, collection, key, data, author, ts }. Note the field is op, not kind: homespun.feed.on and homespun.collections.on deliberately carry different shapes, because feed is the raw log and collections.on is already folded into row deltas.

The server-stamped directory of the app’s members and agents, so a page can render a name instead of an id.

list(): MemberDirectoryEntry[]

Returns the directory as of the last realtime hello.

nameFor(author: { kind: string; id: string }): string

Resolves an author to a display name, never throwing.

Accepts exactly the { kind, id } shape a row’s or feed entry’s author already carries, so a page can write homespun.members.nameFor(row.author) with no lookup and no key composition.

Falls back rather than failing: an unknown human is “a member”, an unknown agent is “an agent”, and anything else, including the anonymous-writer sentinel, is “a visitor”.

Write-only storage for a manifest-declared webhook Connection’s credential, so your own settings screen can collect a third-party API token without ever putting it in a collection row.

// Your settings screen, after the owner pastes their key:
await homespun.connections.set("hubspot", apiKeyInput.value);
// Elsewhere, to render "configured" without knowing the value:
const status = await homespun.connections.status("hubspot");
if (status.set) show("Connected " + status.setAt);
set(name: string, value: string): Promise<ConnectionStatus>

Stores or replaces the named connection’s credential. The old value is unrecoverable once this resolves.

Owner-only, enforced by the relay: a member, a custom-role grant-link holder, and an anonymous visitor are all refused with connection_owner_only, whatever the app’s collection permissions say.

name must already be one this app’s own manifest references from a webhooks, schedules, or fetchers rule’s connection field, and that connection must already exist as a static connection created from the console or the CLI. This call replaces its value; it cannot mint a new connection, because the allowedHost that binds a credential to a destination stays a console/CLI decision.

ConnectionStatus is { set: boolean; fingerprint: string | null; setAt: string | null }. It never carries the value you just sent.

status(name: string): Promise<ConnectionStatus>

Whether the named connection currently has a value, without revealing it.

Answers { set: false, fingerprint: null, setAt: null } for a declared connection that has never been set; it does not throw for that case. It throws the same owner-only / undeclared-name errors set does.

There is no getter anywhere on this namespace, and there never will be: a Connection’s value is encrypted at rest and is not returned by any relay call, console page, or SDK method. This exists to move a credential OUT of an ordinary collection row (readable by anyone the collection permits) and into that same protected store.

Calls a manifest-declared fetchers entry: the relay makes the outbound request, attaching a Connection’s credential server-side, so the credential and the target URL never reach the browser.

// A fetcher named "courierStatus" declared with params: { orderId: {...} }
const result = await homespun.fetchers.call("courierStatus", { orderId: "NCM-4821" });
if (result.ok) {
show(result.data);
} else {
// result.error is the target's own raw error body, e.g. {"detail":"Invalid token."}
show("Could not check status: " + result.error);
}
call<T = unknown>(name: string, params?: Record<string, string | number | boolean>): Promise<FetcherCallResult<T>>

Invokes the named fetcher, supplying values for whichever of its declared params this call needs.

Sends ONLY params. There is no way to pass a url, host, or header: the target, the credential, and the request shape all come from the app’s own deployed manifest.

FetcherCallResult is { ok: boolean; status: number; data?: T; error?: string }. ok mirrors whether the TARGET returned a 2xx, never whether the relay call itself succeeded.

Throws a HomespunError for a relay-level refusal: unknown fetcher name, this principal’s role is not in the fetcher’s declared allow, a param fails its declared type or bound, the session or app call budget is exhausted, or no usable connection could be attached.

Resolves - never throws - for a non-2xx from the target itself. Read result.status and result.error (the target’s own raw error body, capped) to tell one failure reason from another, the same distinction a hand-rolled fetch() against the target would give you.

An omitted param is not an error: a query/header/body template referencing it simply renders that placeholder as empty.

A fetcher’s allow defaults to [‘owner’] when the manifest omits it, so a fetcher with no allow declared is never reachable by a member, a grant-link holder, or an anonymous visitor - only the app owner (and their agent).

A fetcher may declare cache: { seconds, scope }. The relay caches the target’s response server-side, scoped per-viewer by default so one viewer can never be served another viewer’s cached, credentialed response.

A real device haptic on a tap, opted in per element with one attribute.

// The whole author-facing API is one attribute:
// <button data-haptic>Save</button>
homespun.haptics.supported; // is anything available on this device
homespun.haptics.enabled; // the viewer's setting, on by default
homespun.haptics.setEnabled(false); // your own settings screen calls this
homespun.haptics.supported: boolean

Whether this device offers a haptic the SDK can reach.

True on a touch device where the Vibration API exists (Android), and on iOS 17.4+ where the switch control does. False everywhere else, including every desktop browser: some of them expose one of those two APIs, but the machine has nothing to buzz, so the check also requires a coarse pointer. Use it to decide whether to render a haptics toggle in your settings screen at all.

homespun.haptics.enabled: boolean

The viewer’s setting, on by default.

Persisted in this app’s localStorage, so it is per app and per browser. It falls back to memory for the life of the page where storage is unavailable, such as private browsing.

setEnabled(value: boolean): void

Turns haptics on or off for this viewer, effective at once.

There is no built-in settings UI. Render your own toggle and call this from it. A pleasing detail: make that toggle an ordinary checkbox with data-haptic on its label, and it buzzes as the viewer turns it off, which is the right last thing to feel.

Add data-haptic to any element you want to buzz under a finger, as in the example above. Elements you render later are picked up automatically, so it works with any framework or none. That attribute is the whole author-facing API.

There is deliberately no play() function. iOS ships no Vibration API, and a haptic cannot be synthesised there: clicking a hidden control from script produces nothing, measured on a real device. Only the viewer’s own finger landing on a real control fires one, so a callable play() would work on Android and silently do nothing on iPhone. The attribute is the honest shape of the feature.

There is also only one haptic, with no tap / success / error variants. iOS offers exactly one, with no intensity or pattern control, so variants would feel materially different per platform.

Keyboard activation never buzzes. A focused button still fires normally on Enter and Space, which is the right outcome on a device with a keyboard.

The element you mark has to be able to hold a child, because on iOS the SDK puts a real control inside it. A void element cannot, so data-haptic on an <img> or an <input type=“button”> is skipped with a console warning naming it, rather than failing silently. Put the attribute on a wrapper instead.

Web push notifications for this browser, opted into by the viewer.

// Never on load. Call enable() from something the viewer did.
notifyButton.onclick = async () => {
const res = await homespun.push.enable();
if (!res.ok) console.log("no push:", res.reason);
};
await homespun.push.status(); // "prompt" | "enabled" | "denied" | ...
await homespun.push.disable(); // stop this browser receiving them
enable(): Promise<{ ok: true; endpoint: string } | { ok: false; reason: "unsupported" | "unavailable" | "denied" | "dismissed" | "failed" }>

Asks the viewer for notification permission and registers this browser.

The only call in the SDK that shows a browser prompt, and the platform never makes it for you. Call it from a user gesture, in response to something that makes it obvious why you are asking: a notify-me toggle, a watch-this button. A prompt the viewer denies blocks the whole origin permanently in most browsers, so an app gets one chance and it should be spent at a moment the viewer understands.

Returns { ok: false } rather than throwing for every way it can decline. “denied” means the viewer refused, now or previously, and calling again will not re-prompt. “unavailable” means the app is not set up for push (no `push` channel in the manifest, or this relay has no VAPID keys). “unsupported” means this browser or this app has no service worker to receive with.

Needs `offline: true` in the manifest, because a push message is only ever delivered to a service worker and the relay only serves one to an offline-capable app. Deploy refuses a `push` notify channel without it, so this is settled before an app ships.

Calling it again on a browser that already said yes re-registers silently and shows nothing.

disable(): Promise<boolean>

Stops this browser receiving push, at the relay and locally.

Cannot revoke the permission itself: only the viewer can do that, in browser settings. What it does is remove the registration, so nothing is sent to this browser any more and enable() would start it again without a new prompt.

status(): Promise<"unsupported" | "unavailable" | "denied" | "prompt" | "enabled">

Where this browser stands, without prompting for anything.

Reads Notification.permission, never requests it, so it is safe to call on load. Use it to decide what your toggle should look like: render an off-state for “prompt”, an on-state for “enabled”, and hide the control entirely for “unsupported” or “unavailable”.

What arrives on the device is deliberately thin: the app’s name, a line naming the collection that changed, and the rule’s `link`. It never contains row data. A push payload travels through Apple’s, Google’s or Mozilla’s push service, and homespun authorizes notification content per recipient against the row that triggered it, so handing that same content to a third party would undo the check. The viewer taps, your app opens, and your normal reads serve the content.

Which rules push is declared on is a manifest question, not an SDK one: a notify rule opts in with `channels: [“push”]` and points at a screen with `link`. An app that declares no push channel has no push routes at all and enable() answers “unavailable”.

On iOS, web push requires the viewer to add the app to their home screen first. That is an Apple constraint with no way around it, and it is why the in-app notification store carries most of the value and push is the escalation.

The viewer’s own notification preferences, so they can turn a channel off from inside your app.

const prefs = await homespun.notifications.preferences.get();
if (prefs?.notifiable) {
emailToggle.checked = !prefs.muted.email;
emailToggle.onchange = () =>
homespun.notifications.preferences.set({
channel: "email",
muted: !emailToggle.checked,
});
}
homespun.notifications.preferences: { get(): Promise<{ notifiable: boolean; muted: { email: boolean; inapp: boolean; push: boolean } } | null>; set(input: { channel: "email" | "inapp" | "push"; muted: boolean }): Promise<boolean> }

Reads and writes which notify channels this viewer has muted for this app.

get() returns null when the app declares no notify channel at all, because there is no such route for it. A viewer who has never expressed an opinion reads every channel as unmuted: absence of a stored preference means not muted, so nothing needs setting up before notifications work.

set() writes one channel and is idempotent. There is no recipient argument, and none on the wire either, so a viewer can only ever change their own preference. A muted channel then produces nothing for them: no email, no in-app row, no push.

Check notifiable before you render a control. It is false for an anonymous visitor, whose only identity is a cookie the SDK transport does not send, and set() will not succeed for them. A signed-in member and a grant-link holder both carry their credential in a header, so both come back notifiable.

There is deliberately no platform-rendered settings page and no unsubscribe link. Much of an app’s audience holds a grant link or is an anonymous visitor, and neither has a console to visit, so the control belongs in your app, next to whatever made them want notifications.

Muting is per app and per channel. It is not per rule: that would need the viewer to be shown a list of your notify rules, which nothing gives a readable name to.

A mute is the viewer’s own choice, so it leaves no delivery record at all, which is different from the platform suppressing a notification because the recipient may not read the row that fired it.

Only meaningful when this app is framed on another site through the manifest’s embedAncestors. Gives the app the visitor context the framing page can see and the framed document cannot, and lets it tell that page a submission completed.

await homespun.embed.ready;
const ctx = homespun.embed.context; // null when not framed, or when no homespun-aware parent answered
await homespun.collections.leads.insert({
email,
source: ctx?.params.utm_source ?? null, // copy explicitly, nothing is stored for you
landedOn: ctx?.pageUrl ?? null,
});
homespun.embed.notifySubmitted({ id: leadId }); // the framing page fires its own analytics
homespun.embed.framed: boolean

Whether this document is running inside a frame at all.

homespun.embed.ready: Promise<void>

Resolves once the framing page has answered or the wait has been given up on. Always resolves, never rejects, and is already resolved when the app is not framed.

Separate from homespun.ready, and neither waits on the other. Await this one before reading context.

homespun.embed.context: EmbedContext | null

Visitor context from the framing page: pageUrl, referrer, params (campaign parameters), and custom (strings the embedder chose to pass). Null until ready resolves.

Every byte of it is chosen by whoever embedded your app, so treat it as untrusted. It is length-capped and allowlisted before you see it, and an over-cap context is dropped whole rather than trimmed, so a value that is present is complete.

params only ever carries utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid and msclkid. The rest of the framing page’s query string is never forwarded.

Nothing is written to a collection for you. Copy the fields you want onto your own row, which is also what stops an embedder pushing keys that collide with your real fields.

homespun.embed.notifySubmitted(detail?: { id?: string; value?: number; currency?: string }): void

Tell the framing page a submission completed, so it can fire its own conversion tracking. A no-op when the app is not framed.

Call it yourself at the point you consider a conversion done. It does not fire automatically on insert, because apps write rows for reasons that are not conversions and firing on every write would both double-count and tell the embedder the shape of your writes.

Only id, value and currency are forwarded and everything else is dropped, so a submitted row cannot be leaked to the embedder by passing it through. The embedder is not always the same party as the app owner.

The framing page receives it as a homespun:submitted DOM event on the iframe element.

Binary uploads and downloads, hanging directly off homespun.

homespun.uploadBlob(file, { filename, mime }); // Promise<AttachmentRef>
homespun.uploadImage(file, { maxEdge, targetBytes, mime }); // Promise<AttachmentRef>, downscales + recompresses first
homespun.downloadBlob(attachmentId); // Promise<Blob>
homespun.saveBlob(attachmentId, filename); // Promise<void>, triggers a browser save
uploadBlob(file: Blob, opts?: { filename?: string; mime?: string }): Promise<AttachmentRef>

Uploads a file and returns a reference to it.

uploadImage(file: Blob, opts?: { maxEdge?: number; targetBytes?: number; mime?: "auto" | "image/webp" | "image/jpeg" }): Promise<AttachmentRef>

Downscales and recompresses an image client-side, then uploads it.

Decodes the file, downscales so its longest edge is at most maxEdge (default 1600, never upscales), and encodes preferring WebP where this browser can actually produce one, JPEG otherwise. Feature-detected, not read from the user agent.

If the first attempt lands over targetBytes (default 150000), steps quality down through a short fixed ladder and re-encodes. Rejects with invalid_args, rather than uploading something the server’s per-file cap will reject, if no attempt gets under target.

Returns exactly what uploadBlob returns: an AttachmentRef.

downloadBlob(attachmentId: string): Promise<Blob>

Fetches an attachment’s bytes.

saveBlob(attachmentId: string, filename: string): Promise<void>

Triggers a browser save of an attachment.

AttachmentRef is { id, mime, size, filename }. These three names are kept from an earlier version of the SDK for continuity; they are not renamed to collections-style verbs.

Every rejected promise is one shape:

{ code: string, message: string, status?: number, details?: unknown, retryable: boolean }

SDK-local codes are network_error, invalid_args, timeout and ws_unavailable. Anything else, such as conflict from a stale ifMatch, passes the relay’s own error code straight through; those are listed in the error reference.