The JavaScript SDK
The Bailey SDK lets a static page store data and collect submissions without a backend of your own and without any secret key in your code. You add one script tag and call two or three functions.
Add the SDK
Section titled “Add the SDK”Put this in your page (typically before </body>):
<script src="/recon/store.js"></script>It’s served from your own site’s origin, so the path is just /recon/store.js, no domain to hard-code. Once loaded, it exposes a global:
window.recon.store// → { kv: { get, set }, counter, collect, list, counters, mine }There are no keys to paste, no tokens to manage. Behind the scenes the SDK fetches a fresh, short-lived token scoped to your site and attaches it to every request for you.
Key-value storage: recon.store.kv
Section titled “Key-value storage: recon.store.kv”A tiny store for small, JSON-serializable values: a published setting, a theme, a small config blob.
const { store } = window.recon;
// Read a value (returns null if the key doesn't exist)const theme = await store.kv.get('theme');
// Write a value (any JSON: number, string, object, array)await store.kv.set('theme', { mode: 'dark', accent: '#e91e8c' });kv.get(key)→ the stored value, ornullif absent.kv.set(key, value)→ saves it.valueis anythingJSON.stringifyaccepts.
Counting things: recon.store.counter
Section titled “Counting things: recon.store.counter”For anything you tally: votes, likes, page views, a live attendee count. The increment happens atomically on the server, so no update is ever lost, no matter how many visitors hit it at once. And visitors can only increment or read (never set an arbitrary value), so the count can’t be trivially faked the way kv.set('votes', 9999) could.
const votes = window.recon.store.counter('votes');
// Add 1 and get the new total backconst total = await votes.incr();
// Add more than one at a timeawait votes.incr(5);
// Just read the current value (0 if it was never incremented)const current = await votes.get();counter(name)→ a handle for the counter calledname..incr(by = 1)→ addsby(an integer ≥ 1, default1) atomically and returns the new value..get()→ the current value (0if it has never been incremented).
A like button, in full:
<button id="like">♥ <span id="count">0</span></button><script src="/recon/store.js"></script><script> const likes = window.recon.store.counter('likes'); const out = document.getElementById('count');
out.textContent = await likes.get(); // show the current total document.getElementById('like').onclick = async () => { out.textContent = await likes.incr(); // +1, atomic, returns the new total };</script>Collecting submissions: recon.store.collect
Section titled “Collecting submissions: recon.store.collect”For forms and anything visitors send you. Submissions go into a named bucket and are append-only from the browser: the page can write, but it can never read them back. Only you, the owner, can read submissions; see Reading & erasing collected data.
const { store } = window.recon;
form.addEventListener('submit', async (e) => { e.preventDefault(); await store.collect('contact', { email: form.email.value, message: form.message.value, }); form.reset(); alert('Thanks, we got your message!');});collect(bucket, data)→ appendsdata(any JSON object) to the bucket.
This write-only design is deliberate: even if someone reads your page source and lifts the token, they cannot exfiltrate what others submitted. That’s the safe default for a contact form on a fully public page.
No-code auto-binding (recommended for forms)
Section titled “No-code auto-binding (recommended for forms)”You usually don’t need to write the submit handler at all. Add
data-bailey-collect="<bucket>" to a <form> and the SDK wires it for you:
it prevents the default reload, sends the form’s named fields as the payload,
resets the form on success, and toggles form.dataset.baileyState
(sending → sent / error / paused / duplicate).
<form data-bailey-collect="contact"> <input name="email" type="email" required> <textarea name="message"></textarea> <button type="submit">Send</button> <p data-bailey-success hidden>Thanks, we'll be in touch.</p> <p data-bailey-error hidden>Couldn't send, please retry.</p></form>An element marked data-bailey-success / data-bailey-error /
data-bailey-paused (collection paused, or awaiting a governance review —
an honest state, not a failure) inside the form is revealed on the matching
outcome, and the form emits bubbling recon:collected / recon:error events
if you want custom handling. The bucket still has to be
declared (below); auto-binding never bypasses governance. Hand-wiring with
store.collect(...) remains the escape hatch when you need full control.
Reading a bucket back publicly: store.list
Section titled “Reading a bucket back publicly: store.list”By default collect is write-only. To build something everyone can see (a
public feature board, a wall of submissions, a leaderboard), declare the bucket
visibility: "public" and mark the fields you want readable public: true
(a field can be public only if it’s not personal). Then:
const items = await store.list('features', { limit: 50 }); // newest first// → [ { id, at, data: { title, detail } }, ... ] (ONLY the fields marked public)list is paginated ({ limit, offset }, default 50 / max 200) and each record
carries only the public fields; a personal field never appears in a public
read.
Reading back a member’s own private entries: store.mine
Section titled “Reading back a member’s own private entries: store.mine”Sometimes data belongs to one person and only that person — a private
journal, a saved draft, a personal goal someone sets and revisits next time.
That’s the owned visibility. A member writes and reads back only their
own records — no other member, and not even you, the site owner, can read
them. That privacy is enforced deep in Bailey’s authorization layer, not by a UI
choice.
await store.collect('journal', { goal: 'ship the MVP' }); // saved under the member's identityconst mine = await store.mine('journal', { limit: 20 }); // ONLY this member's own entries// → [ { id, at, data: { goal } }, ... ] newest-firstawait store.forget('journal'); // the member erases their own entriesTwo members of the same organization never see or affect each other’s rows, and
the response never contains the member’s identifier — just their data. A member
can erase their own entries at any time with store.forget(bucket) (their
right to erasure); it only ever touches their own rows. Under the hood each member
is keyed by an app-scoped pseudonym, never their global account, so nothing on
the page can identify them across sites.
Batch-reading counters: store.counters
Section titled “Batch-reading counters: store.counters”To order a public board by votes without one request per card, use the naming
convention counter key = record id and read all counts in one call:
const items = await store.list('features', { limit: 200 }); // 1 requestconst votes = await store.counters(items.map((i) => i.id)); // 1 request → { id: count }items.forEach((i) => (i.votes = votes[i.id] || 0));items.sort((a, b) => b.votes - a.votes);// upvote on click: await store.counter(id).incr();counters(keys) is capped at 200 keys per call.
One project, many occurrences: put the session in the link
Section titled “One project, many occurrences: put the session in the link”A page used again and again — a monthly meetup, a daily lunch poll, one talk per session — should not become one project per occurrence. Keep one project and let the URL carry the occurrence:
yoursite.sites.trybailey.app/#/meetup-juneyoursite.sites.trybailey.app/#/meetup-julyTwo rules make it work:
- Stamp every record with the session. Declare a
sessionfield as{ "personal": false, "public": true, "queryable": true }, read it from the URL, and filter reads withwhere. - Prefix every counter key with the session —
meetup-june|too_technical, nevertoo_technical. Counters are global to the project.
const session = (location.hash.slice(2) || 'default').toLowerCase();await store.collect('ratings', { session, verdict });await store.counter(`${session}|${verdict}`).incr();const shown = await store.list('ratings', { where: { session } });Opening a new occurrence is then just inventing a slug and sharing it — no new project, no republish.
Before it works: declare the bucket
Section titled “Before it works: declare the bucket”A bucket must exist before you can write to it; Bailey never silently creates storage from anonymous traffic. You declare it once, in any of these ways:
- From the dashboard: Settings → Data & privacy → Declare data (also where you set its purpose and retention). See Data & privacy.
- At deploy time: ship a
bailey.manifest.jsonwith your files, or pass--collect <bucket>/--public <bucket>to the CLI. - From an AI agent: pass the same
bailey.manifest.jsondeclaration as themanifestargument to the Bailey MCP’screate_site/publish_pagetools.
A collect bucket has one of three visibilities: insert_only (the default — write-only, you read submissions from your control room), public (readable back by anyone via store.list), or owned (a private per-member journal, readable back only by its author via store.mine, on organization pages). kv is a public bucket that’s always available.
Declaring is also where privacy is enforced, not just described:
- every
collectbucket declares its fields, each classifiedpersonal: true|false; - each field may also declare what it holds —
type: "email" | "phone" | "url" | "date" | "number" | "text". Always optional, and worth writing: it’s what lets the site owner wire an automatic reply without guessing which column holds the address, and what fills the variable chips in that editor. A typed field is held to it —emailandphoneare personal data by nature and are refused withpersonal: false, whatever the field is named; - any personal field ⇒
purpose,legal_basisand aretentionare required (that’s the register line from Data & privacy); - a public bucket holding personal data also requires a
notice, and the SDK enforces it at runtime: the notice is shown and the submission is blocked until the visitor acknowledges it (an inline checkbox ondata-bailey-collectforms, a dialog for manualstore.collect), with the acknowledgement recorded server-side as consent proof. Want to place it yourself? Putdata-bailey-consentinside the form — on a checkbox (or radio), the SDK uses yours; on any container element (a div, a slot), the SDK renders its notice block inside it.
Errors
Section titled “Errors”Every call returns a promise. On failure it throws an Error whose message includes the HTTP status and the server’s reason; wrap calls in try/catch and show the user something friendly:
try { await store.collect('contact', data);} catch (err) { console.error(err); // e.g. "recon: 429 rate limited" showMessage("Couldn't send, please try again in a moment.");}Common statuses you might see: 429 (too many requests too fast), 413 (payload over 1 MB), 403 (bucket not declared, reserved name, or wrong origin), 404 (site/key not found).
What the SDK does for you
Section titled “What the SDK does for you”So you understand what’s happening (and why it’s safe):
- On first use it fetches
/recon.config.json: a fresh token minted for this exact page load, valid ~10 minutes, bound to your site’s origin. - It attaches that token as a
Bearercredential automatically. - For each write it generates a single-use nonce (replay protection).
- If the token has expired, it transparently fetches a new one and retries once.
There is also an alias, window.createStore(), which returns the same store object; use whichever reads better.
Analytics & event tagging: recon.analytics
Section titled “Analytics & event tagging: recon.analytics”Traffic analytics (a paid feature) is automatic: once /recon/store.js is on
the page it counts pageviews and SPA navigations on its own: cookieless, no
consent banner, nothing to declare in the manifest. The referrer host and utm_*
params are captured on entry.
To count clicks / custom events, tag elements declaratively, no JS:
<button data-bailey-event="signup_click">Sign up</button>Or fire one programmatically:
window.recon.analytics.event('checkout_started');window.recon.analytics.pageview(); // manual pageview for custom routersEvent names are free-form labels, aggregated by name (no personal data, capped in
cardinality). To add tracking to an existing page, add the attributes and
re-publish; the SDK is already there. Read the figures in the dashboard’s
Analytics tab or via the read_analytics MCP tool.
Opt a page out entirely with <script src="/recon/store.js" data-analytics="off">.
Prefer raw HTTP, or building from another language? See the Data API reference. Curious how the token/origin/nonce chain holds up? See the Security model.