@gemmein/sdk
Gemmein is the backend for web and mobile apps: sign-in, protected data, payments, AI tools and a dashboard to run your customers. No SQL, no security-rules language, no server to configure. Web, Expo/React Native, and Swift, on one contract.
Two doors, one house. Starting from an idea — nothing built yet? Run npx -y gemmein dev and build against a local backend: zero setup, no account, no key to fetch, and nothing leaves the machine. Already have an app? Take the app key below and let Gemmein replace what currently stubs or fakes sign-in, storage and payments; the frontend, framework and host stay where they are. Either door, Gemmein is the part that takes the app to real customers.
Step 0 — get your app key
Every call needs an app key (pk_...). Get one in 30 seconds: sign in at app.gemmein.com with an email code (free, no card), and copy the key from the Setup page. The Setup page also gives you a ready-made prompt that teaches this whole SDK to your AI tool.
Install
npm i @gemmein/sdk
No bundler? The SDK is dependency-free pure ESM — copy dist/index.js from the package next to your HTML and import { gemmein } from "./index.js" in a <script type="module">. Works on any static host.
Quick start
import { gemmein } from "@gemmein/sdk"
const g = gemmein("pk_test_...")
await g.auth.sendEmailCode("user@example.com")
await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
await g.collection("tasks").create({ title: "Buy milk", done: false })
const { records } = await g.collection("tasks").list()
That is the idea-stage order the product enforces: sign-in first, then the data rules, then what customers pay for. Against a local gemmein dev backend the same lines run unchanged on the pk_local_ key it prints; against the cloud they run on pk_test_ and then pk_live_.
The client has two layers: your collections (g.collection("tasks") — your app's own data model) and the business primitives Gemmein runs for you (g.auth, g.subscriptions, g.payments, g.purchases, g.credits, g.ai, g.files, g.account — self-service surfaces for the signed-in user; managing other people's users, subscriptions, and records happens in the owner's dashboard, on purpose).
Sessions persist across page reloads automatically (localStorage in browsers, memory elsewhere — override with tokenStore if you need custom persistence). One session per user: verifying a new code revokes that email's older sessions — a stale token throws auth_expired once, the SDK clears it, and a retry (or re-auth) recovers.
A store that cannot keep the session says so: a browser that refuses the localStorage write (private mode past its quota, site data blocked, a sandboxed iframe) makes verifyEmailCode() throw secure_store_unavailable rather than hand back a session the next reload will lose. Reads and clears stay lenient — an unreadable store means signed out. The session is real either way, so an app that would rather run than stop catches that one code and rebuilds its client with a MemoryTokenStore, which never throws.
Two reference documents ship beside this one: REFERENCE.md — every method, signature and return shape — and llms.txt, the guide to paste into an AI tool. Both are in the package; the same material is at docs.gemmein.com.
Auth
await g.auth.sendEmailCode("user@example.com")
const session = await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
const user = await g.auth.currentUser()
await g.auth.logout()
Deleting an account
await g.account.delete()
Self-service erasure — the "delete my account" screen. Every app it applies to needs one (GDPR right to erasure; Apple 5.1.1(v) for any app with account creation). Server-side it's the full cascade: sessions revoked, the user's records and files deleted, their subscription row removed. Irreversible — put a real confirm in front of it.
Mobile — Expo and Swift
A phone app is the same contract as a browser app: the public key identifies the app, the session authorises the person. Verified domains are a browser control — an app sends no Origin, so nothing is verified there; build as though any request could come from anywhere and let the session and the server's rules decide.
Expo / React Native. @gemmein/sdk/expo is this same package, a second entry point that fills in the four seams a phone has — where the session lives, how "is the app in front?" is answered, which fetch carries the bytes, and what the client tag says. Peers install in the app, not in your monorepo: npx expo install expo-secure-store expo-file-system.
import { createExpoGemmein } from "@gemmein/sdk/expo"
import { AppState, Platform } from "react-native"
export const g = createExpoGemmein({ appKey: "pk_live_..." }, { AppState, Platform })
Every module is optional — pass none and the entry resolves expo-secure-store, react-native and expo/fetch lazily on first use. The injectable keys are SecureStore, AppState, Platform, FileSystem and fetch; the client tag reads expo-ios / expo-android / expo-web when you pass Platform, and expo when you don't. apiUrl is an option on the client, so pointing the app at a local gemmein dev engine is one field: createExpoGemmein({ appKey, apiUrl: "http://127.0.0.1:4545" }, { AppState, Platform }).
Swift. GemmeinSwift — iOS 17+ / macOS 14+, no dependencies, the same method names in Swift idiom. One line in Package.swift:
.package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.10.0")
Store purchases, either way. currentUser() carries storeAccountToken — an opaque per-person id. Hand it to RevenueCat as the app user id, and a store webhook resolves to exactly one person through a relay, without your app key or the person's email ever entering a third party's ledger.
const me = await g.auth.currentUser()
if (me.authenticated && me.storeAccountToken) {
await Purchases.configure({ apiKey: RC_KEY, appUserID: me.storeAccountToken })
}
The whole mobile surface — the codes a phone meets, the store road, the two host facts for simulators — is at docs.gemmein.com/mobile.
Storage
Collections are created by the app owner in the dashboard (app.gemmein.com → data → "+ New collection") — the SDK can't create them. A 404 unknown_collection means it doesn't exist yet: ask the app owner to add it (one click) and pick its rule from the table below.
const tasks = g.collection("tasks")
const task = await tasks.create({ title: "Buy milk", done: false })
console.log(task.id, task.data.title)
const { records } = await tasks.list()
records.forEach(r => console.log(r.data.title))
const recent = await tasks.list({ limit: 10, sort: "newest" })
const filtered = await tasks.list({ where: { done: false } })
const one = await tasks.get("rec_abc123")
await tasks.update("rec_abc123", { done: true })
await tasks.delete("rec_abc123")
const file = await tasks.upload(imageBlob, { name: "avatar.png" })
await tasks.create({ title: "Profile", avatar: file.ref })
const { url } = await g.files.link(record.avatar)
Store the reference, never the URL. A reference doesn't expire and grants
nothing on its own; link() is where authorization happens, every time. That
also means the same app code keeps working if a collection later changes from
public to private.
The bound, honestly: revoking access stops Gemmein issuing new links
immediately. A link already in someone's hands works until it expires. Nothing
can take back a file they already downloaded — this is controlled delivery, not
DRM.
Collection rules
Each collection has one rule, set in the dashboard. The server enforces it — your app never implements authorization:
private | Owner only | Owner only | User's personal data (tasks, notes, settings) |
shared | All authenticated users | Each user: own records only | Feeds, communities, team boards |
public_read | Anyone (no login needed) | App owner only, from the dashboard, a relay or your server (secret key) | Catalogs, menus, marketing pages, single-author blogs |
community | Anyone (no login needed) | Each signed-in user: own records only — plain text | Multi-author blogs, public boards, profiles |
addressed | Each user: only records addressed to them | App owner only, from the dashboard, a relay or your server (secret key), naming a recipient | Notifications, invoices, order status |
direct | Author + the named recipient | Each signed-in user, naming a recipient | Messages, sharing, requests |
admin_write | All authenticated users | App owner only, from the dashboard, a relay or your server (secret key) | App settings, announcements |
Owner scoping is automatic. For private collections each user only sees their own records — the app's owner included: signing in to your own app with your Gemmein email makes you an ordinary customer, under every rule. Every customer's records are in the Gemmein dashboard, which is the admin dashboard; an admin screen inside the app goes through your own server with a read-only secret key (see "Admin views" in llms.txt). For shared collections everyone reads everything but update/delete only touch the caller's own records. Never filter by userId, never set userId/owner/role fields. The server derives them from the session and rejects reserved fields if you try to set them:
id, appId, app_id, environmentId, environment_id, userId, user_id, ownerId, ownerUserId, owner_user_id, tenantId, tenant_id, role, isAdmin, is_admin, createdAt, created_at, updatedAt, updated_at, deletedAt, deleted_at
User content is data, not markup. When one user's content renders in another user's session (community, shared), render it with text bindings — textContent, {} in React/Vue/Svelte — never innerHTML. community collections enforce this server-side: any string field containing HTML tags is refused with 400 html_not_allowed. Store plain text, or markdown written without raw tags.
Sending to people — inboxes and messages
addressed and direct records carry a recipient, named on the call you already make:
await messages.create({ text: "hey!" }, { for: otherUserId })
const { records } = await updates.list({ sort: "newest", limit: 20 })
The recipient is server-stamped (record.audienceUserId) — never a data field. User ids come from records' ownerUserId or the dashboard's Customers page. Rules of the road:
- Same message for everyone →
admin_write (one record all users read). A specific thing for a specific person → addressed (one record per recipient).
- Inboxes are poll-or-refresh:
list() on window focus plus a gentle ~60s interval — never a tight loop. Read-state ("seen") lives in the user's own private collection.
direct is messaging inside someone's app, and the owner's Gemmein dashboard can read it — never present it as private or encrypted chat.
- Errors teach the fix:
invalid_audience (recipient isn't a user of this app), reply_only (this collection only allows replying to people who wrote to you first — tell the user), sends_disabled (the owner turned direct messages off for this collection).
- Both rules store plain text like
community — render other users' content as text, never innerHTML.
Contention — when users race for the same thing
A permission model can't stop two people booking the same 3pm slot. Preconditions can, and each is just an argument on a call you already make. A 409 conflict from any of them is the mechanism working, not an error to retry away — catch it and tell the user the slot/stock/edit was taken.
await bookings.create({ who: email }, { key: "slot:2026-07-15T15:00" })
await orders.create({ item: 42 }, { key: "unit:item42:1" })
await products.update(id, { stock: { decrement: 1, floor: 0 } })
await pages.update(id, { body }, { ifVersion: page.version })
An object in a patch is treated as an atomic op ONLY when its keys are exactly increment|decrement (+ optional floor|ceiling), all numbers — anything else is stored as plain data. Keys are 1-120 chars of letters, numbers, and : _ . @ / -. Never find-then-create and never compute counters client-side: both race, and both fail only when real users collide.
Payments (Stripe) — you don't build the webhook
Gemmein hosts your Stripe webhook and manages subscriptions for you: exactly one per customer (case-insensitive on email), created by the payment itself, downgraded to your default plan on cancellation, out-of-order Stripe events resolved to the newest. The app owner names the plans and pastes one Stripe signing secret in the dashboard's Payments page — nothing to seed. Your app has exactly two jobs:
await g.subscriptions.checkout("pro")
const sub = await g.subscriptions.mine()
if (sub?.plan === "pro") { }
Do not write a webhook handler. Do not poll Stripe. Do not store plan/subscription state in your own collections — g.subscriptions.mine() is the single source of truth, and there is deliberately no client write path to it.
Plan limits (note counts, feature caps, seat numbers) are your app's logic — Gemmein only tells you who is on which plan; the dashboard's plan names carry no quotas.
A plan is sold one of three ways, set on the Payments page: via a Stripe Payment Link (checkout navigates there), via a relay — any provider whose webhook the owner maps (GoCardless, Lemon Squeezy, Paddle, a bank transfer) grants it with the grant_plan relay action and ends it with revoke_plan — or not yet. g.subscriptions.mine() answers the same { plan, status } whichever road wrote it; checkout on a plan sold via a relay or not yet answers "plan_not_sellable" (409).
Selling things (one-off purchases)
Plans are for subscriptions. To sell a thing — a poster, a beat, an ebook, a session — the owner adds products on the same Payments page and sets how each is sold: via a Stripe Payment Link, via a relay (any provider whose webhook they map — GoCardless, Lemon Squeezy, Paddle, an app store through RevenueCat, a bank transfer), or not yet (the product is defined, its grants and credits known, no road wired).
await g.payments.buy("premium license", { item: "beat_37" })
const purchases = await g.purchases.mine()
const paid = purchases.find(p => p.item === "premium license" && p.status === "paid")
if (paid?.delivery?.type === "gemmein_file") {
const { url } = await g.files.link(paid.delivery.file, { intent: "download" })
}
Each purchase is { item, kind: "purchase" | "subscription", status: "paid" | "part_refunded" | "refunded", amountMinor, currency, refundedMinor, grants, paidAt, delivery? }, newest first, refunds already applied — amounts are minor units (pence, cents) exactly as the provider reported them, so formatting is yours and nothing is rounded away. To sell a file, the owner attaches it on the product card: the purchase carries delivery: { type: "gemmein_file", file } and is the authorization, re-checked on every g.files.link() — a full refund cuts the download off the moment it lands. delivery: { type: "external_url", url } is a plain handover: Gemmein controls who is told, not who can use it.
A receipts collection (rule addressed) is now optional — proof records for your own screens, nothing more. Financial truth is g.purchases.mine(), and it stays true if you rename or delete that collection.
No carts, no quantities — one product per checkout, by design. A "cart" is N checkouts, or one bundled product the owner prices as a bundle. Don't build a cart UI that promises otherwise. Digital access only: physical goods, shipping and inventory are out of scope. Full detail: docs.gemmein.com/payments.
Drafts on public collections
On public_read and community collections, { published: false } saves a draft the public can't see — server-enforced, the author still sees their own (and the owner's dashboard sees every draft):
const post = await notes.create({ title: "wip" }, { published: false })
await notes.update(post.id, {}, { published: true })
Read the current state back as a top-level field — record.published (a boolean), NOT record.data.published (same place as id and updatedAt, not inside your fields). Non-authors only ever receive published records, so you'll only ever see false on your own drafts. That's how you render an "unreleased" badge on an author's own list.
Never fake drafts with a status field + client-side filtering on a public collection — the data still reaches every reader's network tab. published is an option, not a data field; the server rejects it inside data. The field comes back on every record, whatever the rule: a private record reads published: true, and the option is not writable there.
Credits
A credit is a quantity the signed-in person holds and your product spends — a pack they bought (a product with "Grants credits"), a plan they pay for ("Credits each period", granted on each paid period), a comp the owner gave them, a relay's grant. g.credits.balance() is the number right now, server-resolved, so your meter never does client-side arithmetic on a balance that refuses at zero; a spend from your own server is gemmeinServer(sk).spendCredits(personId, { amount, reason, key }), one conditional update floored at 0, and 402 credits_exhausted carries the balance. There is deliberately no client write path. Full detail: REFERENCE.md → Credits, and docs.gemmein.com/credits.
AI
g.ai.run(tool, inputs) calls a named AI tool the owner defined — the server composes the provider request from the tool's own instructions and template (never the browser), gates it, spends the tool's credits and streams the provider's own answer back; g.ai.runText(tool, inputs) is the same call collected to one string, and g.ai.calls() is the signed-in person's own history. The provider key lives with Gemmein and never reaches the browser. g.ai.chat(body) is the raw path — the provider's own request body, forwarded — and it is off by default (403 raw_calls_off) until the owner switches raw calls on for that key. Full detail: REFERENCE.md → AI, and docs.gemmein.com/ai.
const summary = await g.ai.runText("summarise", { text })
const { balance } = await g.credits.balance()
Relays
A relay is one trigger — another provider's webhook arriving, a clock, a record changing — and one to ten of Gemmein's own typed actions, run in order: grant or revoke access, fulfil or refund a product, grant credits, write a record, email a person, call a URL. The owner writes gemmein/relays/<name>.json (or picks a template in the console); Gemmein verifies the signature, maps the body to named fields, and runs the actions. Nothing computes inside one — compute lives on your host, behind call_url. That is the road an app store purchase travels, and the road a payment provider other than Stripe travels. It is configuration, not SDK surface — your app only ever reads the result (g.purchases.mine(), g.credits.balance(), a gated collection succeeding). Full detail: REFERENCE.md → Relays, and docs.gemmein.com/relays.
Server-side (API routes, cron jobs)
For trusted server code, use gemmeinServer with a scoped secret key (sk_...) — created in the dashboard, scoped per collection:
import { gemmeinServer } from "@gemmein/sdk"
const server = gemmeinServer(process.env.GEMMEIN_SECRET_KEY)
const tasks = await server.collection("tasks").list()
await server.collection("tasks").update("rec_abc123", { done: true })
await server.collection("tasks").create({ title: "Welcome" }, { for: personId })
await server.collection("tasks").delete("rec_abc123")
A secret key reads the collections it is scoped to, and — when it was minted "reads and writes" — creates and updates records there under any rule; it deletes only where "deletes too" was ticked, and a full key (minted as "every collection") reads, writes and deletes everywhere. Whose record a server create makes is named on the call: private · shared · community take { for: personId } (the owner), admin_write · public_read take no person (the app's record), addressed takes { for } (the recipient), direct takes { from, for } (author and recipient); a missing or extra person is 400 person_required / invalid_person. Every server write is a Logs row naming the key. Beyond that it opens the gate, for code of yours running on your own host: verifySession(token) turns a browser session into the person and what they hold, holdings(personId) answers that on its own, grantAccess / revokeAccess move one grant (a grant ends once — a second revoke is 409 already_revoked), spendCredits takes credits with a reason, invitePerson(email) creates or fetches a person by address, and notify(personId, …) emails one of your app's own people. Each is a capability ticked on the key when it is minted; an untick is 403 capability_required. No management access, ever. Never put an sk_ key in browser code (the SDK throws if you try).
Emailing your own people — notify. It emails one verified person of your app, by id, never by address (404 not_a_customer otherwise; the address is the server's own record). It sends only from your verified sender domain: until one is verified on the Domains page every call is refused 409 sender_domain_required and nothing leaves, nothing is recorded, no cap is spent, and the same key sends once you verify. It never falls back to a Gemmein address — sign-in codes are the one email Gemmein sends on your behalf before that, as (via Gemmein). kind: "event" (the default, 5 per person per day) leaves from your domain's news word; kind: "account" (security and account notices, no per-person cap) from its account word; both count toward 200 per app per hour. Every send lands in your Inbox as a conversation, and a reply comes back to it. Send the same thing to many people, or on a webhook, with a relay's email_person instead.
await server.notify("usr_abc123", {
subject: "Your order shipped",
text: "Order #142 left the warehouse today.",
key: "order-142-shipped",
})
Errors
Every method throws GemmeinError:
import { GemmeinError } from "@gemmein/sdk"
try {
await g.collection("tasks").create({ title: "Test" })
} catch (err) {
if (err instanceof GemmeinError) {
err.code
err.status
err.message
err.resetAt
err.requires
}
}
missing_app_key | 401 | No app key — get one at app.gemmein.com |
invalid_app_key | 403 | Key not recognized (typo, or wrong environment) |
auth_expired | 401 | Session expired — SDK auto-clears the token |
unknown_collection | 404 | Collection doesn't exist — create it in the dashboard |
scope_denied | 403 | Secret key not scoped for this collection/action |
html_not_allowed | 400 | Community collections store plain text — remove HTML tags |
invalid_file_content | 400 | Uploaded bytes aren't the image type they claimed — upload the actual image, not a renamed file |
unknown_product | 404 | No product by that name — the message lists what the app sells |
invalid_publish | 400 | published is an option on public collections only — not a data field, not for scoped rules |
forbidden | 403 | The rules refused this — a permission your user doesn't have. Never retry: the same call will always be refused. Fix the approach (wrong collection rule, non-admin writing to admin_write, secret key out of scope) or show err.message. |
entitlement_required | 403 | Signed in, but not on a plan (or holding a product) this collection is unlocked by. err.requires carries that plan's key (access:pro for a plan named pro). The one 403 that succeeds later: show your upgrade screen, send them to checkout, retry after they hold it. |
denied | 429 / 401 | The generic refusal for everything retriable or fixable: a rate limit (429 — carries resetAt, wait and retry) or a missing sign-in (401 — sign in first via g.auth.sendEmailCode). Distinguish by HTTP status; show err.message, which reads correctly for each. |
invalid_collection_name | 0 (client-side) | g.collection(name) was given a name outside the grammar — lowercase letters, numbers and underscores, starting with a letter, 2-63 characters. Thrown before a request exists. |
secure_store_unavailable | 0 (client-side) | The token store could not KEEP the session — a browser refusing the localStorage write, expo-secure-store absent or refused, a Keychain that said no. err.cause carries the store's own error. Pass a tokenStore (a MemoryTokenStore keeps the session for the life of the process). |
network_unreachable | 0 (client-side) | The request never reached Gemmein — no connection, a host that doesn't resolve, an apiUrl pointing at nothing. The message names the host; err.cause carries the transport's error. Branch on it separately from a refusal. |
Branching on error codes: switch on the specific named codes above. The one rule that matters: forbidden means stop — retrying can never succeed; denied means the request could work later (wait for resetAt on 429, sign in on 401). Only rate-limit denied carries resetAt — that's the reliable signal for a retry-after.
Keys & environments
pk_test_... | Development | Frontend code — safe to expose |
pk_live_... | Production | Frontend code — safe to expose |
sk_dev_... / sk_live_... | Dev / Prod | Server env vars only — runs your app; never links or syncs |
sk_cli_... | Development | The CLI key, shown on Setup: what gemmein sync and gemmein go-live act with. Lives in gemmein/.data/, never in git |
sk_sync_... | Production | A one-hour sync key (Live → Secret keys): gemmein sync --live carries relays and AI tools into production. Pasted, used, never saved |
Environments are fully isolated: different data, different users, different collections.
Management is dashboard-only
Collections, domains, keys, payments config, logs, and usage live in the dashboard — deliberately not in the SDK, so management credentials can never leak from app code.
Gemmein is an early release: the core above is live, security-audited, and safe to build on; the wider feature set rolls out in stages. gemmein.com · hello@gemmein.com