Skip to content

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.

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.

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, or null if absent.
  • kv.set(key, value) → saves it. value is anything JSON.stringify accepts.

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 back
const total = await votes.incr();
// Add more than one at a time
await 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 called name.
  • .incr(by = 1) → adds by (an integer ≥ 1, default 1) atomically and returns the new value.
  • .get() → the current value (0 if 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) → appends data (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.

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 (sendingsent / 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 identity
const mine = await store.mine('journal', { limit: 20 }); // ONLY this member's own entries
// → [ { id, at, data: { goal } }, ... ] newest-first
await store.forget('journal'); // the member erases their own entries

Two 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.

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 request
const 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.

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-june
yoursite.sites.trybailey.app/#/meetup-july

Two rules make it work:

  1. Stamp every record with the session. Declare a session field as { "personal": false, "public": true, "queryable": true }, read it from the URL, and filter reads with where.
  2. Prefix every counter key with the sessionmeetup-june|too_technical, never too_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.

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.json with your files, or pass --collect <bucket> / --public <bucket> to the CLI.
  • From an AI agent: pass the same bailey.manifest.json declaration as the manifest argument to the Bailey MCP’s create_site / publish_page tools.

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 collect bucket declares its fields, each classified personal: true|false;
  • each field may also declare what it holdstype: "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 — email and phone are personal data by nature and are refused with personal: false, whatever the field is named;
  • any personal field ⇒ purpose, legal_basis and a retention are 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 on data-bailey-collect forms, a dialog for manual store.collect), with the acknowledgement recorded server-side as consent proof. Want to place it yourself? Put data-bailey-consent inside 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.

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).

So you understand what’s happening (and why it’s safe):

  1. 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.
  2. It attaches that token as a Bearer credential automatically.
  3. For each write it generates a single-use nonce (replay protection).
  4. 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 routers

Event 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.

Hosted on Bailey