reference

SDK reference

Every method, signature and return shape in @gemmein/sdk, with the GemmeinSwift signature for each one and everything @gemmein/sdk/expo adds. The same reference is included in the package as REFERENCE.md.

Setup

import { gemmein, gemmeinServer, GemmeinError } from "@gemmein/sdk"

const g   = gemmein("pk_...")          // browser: public key, domain-locked, safe to ship
const srv = gemmeinServer("sk_...")    // server only — never in the browser (the SDK throws)

gemmein() accepts options { apiUrl?, tokenStore?, fetch?, visibility?, platform? }; gemmeinServer() takes { apiUrl? } only. gemmein() throws missing_app_key/invalid_app_key if the key is absent or an sk_.

The last three are what a non-browser runtime needs and a browser never passes. fetch is used for every request this client makes, in place of globalThis.fetch. visibility ({ isHidden(): boolean; onChange(cb: () => void): () => void }) is where watch() learns the app went to the background, in place of document.visibilityState; onChange returns its own unsubscribe. platform is appended to the x-client-info header (for example gemmein-sdk/<version> expo-ios), cleaned and cut to 64 characters, the length a key's usage records keep. On Expo, createExpoGemmein from @gemmein/sdk/expo fills all four in for a phone; the Swift package fills them in for Apple platforms. See Mobile.

Swift: GemmeinSwift

The same HTTP API and the same method names, in Swift idiom, for iOS 17+ and macOS 14+, with no dependencies: .package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.12.0").

import GemmeinSwift

let g = try Gemmein(appKey: "pk_live_...")   // KeychainTokenStore by default

try await g.auth.sendEmailCode(email)
_ = try await g.auth.verifyEmailCode(email: email, code: code)
let page = try await g.collection("notes").list(ListOptions(limit: 20))
let me = try await g.auth.currentUser()    // .storeAccountToken for RevenueCat

init(appKey:apiURL:tokenStore:platform:session:). apiURL points at a local gemmein dev server while you build; tokenStore defaults to KeychainTokenStore, and MemoryTokenStore is there for tests; platform defaults to this OS and is appended to x-client-info. An sk_ key is refused here, as in the browser; Swift that runs on a server uses GemmeinServer (below). The clients are the same names as the browser's: g.auth, g.collection(_:), g.files, g.subscriptions, g.payments, g.purchases, g.account, g.credits, g.ai, g.runs, and GemmeinServer. Everything it throws is a GemmeinError carrying the server's own code and sentence, plus resetAt and requires.

SwiftSignatureReturns
Gemmein(appKey:…)init(appKey: String, apiURL: URL = defaultAPIURL,
tokenStore: TokenStore? = nil,
platform: String? = nil,
session: URLSession = .shared) throws
Gemmein. Throws invalid_app_key on an sk_, missing_app_key on anything that is not a pk_
g.collection(_:intent:)func collection(_ name: String,
intent: String? = nil) throws -> CollectionClient
CollectionClient. g.storage.collection(_:intent:) is the same factory, the same signature
GemmeinServer(secretKey:…)init(secretKey: String, apiURL: URL = defaultAPIURL,
session: URLSession = .shared) throws
GemmeinServer. See Server below

Three shapes differ from JavaScript, and they are the ones a first build most often gets wrong:

  • g.credits.balance() returns a bare Int, where JavaScript returns a { balance } wrapper.
  • g.files.link(_:) returns a FileLink whose url is a String.
  • A record or AI body is [String: JSONValue]. A literal needs nothing (["title": "Ran", "done": false]); a variable needs its case (["title": .string(typed)]).

Each section below gives the JavaScript table first, then the Swift one.

TokenStore is a protocol — func get() async -> String?, func set(_ token: String) async throws, func clear() async. KeychainTokenStore(appKey:) is the default and MemoryTokenStore(token:) is there for tests and for the app that catches secure_store_unavailable. Only set throws.

Expo: @gemmein/sdk/expo

The same package, a second entry point. It re-exports the whole core surface, so an Expo app imports from one place; the table is everything the entry adds. Install the peers in the app itself (they do not go in your monorepo): npx expo install expo-secure-store expo-file-system. Nothing is imported from Expo or React Native at module load, so the entry resolves in Node too. See Mobile.

import { createExpoGemmein } from "@gemmein/sdk/expo"
import { AppState, Platform } from "react-native"

export const g = createExpoGemmein({ appKey: "pk_live_..." }, { AppState, Platform })
ExportSignatureWhat it is
createExpoGemmein(options: GemmeinOptions,
modules?: ExpoModules) => Gemmein
The client: a Gemmein client with fetch, tokenStore, visibility and platform set for a phone, and picker files converted before every upload(). Anything you pass in options wins over the defaults
SecureStoreTokenStorenew SecureStoreTokenStore(
appKey: string,
secureStore?: SecureStoreModule)
The TokenStore backed by expo-secure-store: get(), set(token), clear(). Keyed per app key, WHEN_UNLOCKED_THIS_DEVICE_ONLY, so the token never rides an iCloud backup. Reads and clears degrade to "signed out"; a refused write throws secure_store_unavailable
appStateVisibility(appState?: AppStateModule)
=> VisibilityHook
The visibility hook watch() sleeps on, answered by React Native's AppState instead of document.visibilityState. Only "active" counts as visible; before the module resolves the app is treated as visible
expoFetch(injected?: typeof fetch)
=> typeof fetch
expo/fetch, resolved on the first request, globalThis.fetch if it is not there. React Native's own fetch is XHR-backed and cannot give a streaming body, which g.ai.chat({ stream: true }) and g.ai.run(…, { stream: true }) return
expoPlatformTag(platform?: PlatformModule)
=> string
The platform string on x-client-info: expo-ios, expo-android, expo-web, or expo when no Platform was passed
expoUploadPart(file: UploadInput,
modules?: ExpoModules)
=> Promise<UploadInput>
A picker's { uri, name, type, size } turned into an expo-file-system File, because Expo's FormData refuses a bare picker part. Anything already carrying bytes passes through. Without expo-file-system it throws upload_input_unsupported
SECURE_STORE_VALUE_LIMIT2048The byte ceiling on a secure-store value. Over it, set throws token_too_large, so iOS never throws a native error the app cannot read
ExpoModules{ SecureStore?: SecureStoreModule,
AppState?: AppStateModule,
Platform?: PlatformModule,
FileSystem?: FileSystemModule,
fetch?: typeof fetch }
The second argument. Every key optional: pass none and the entry resolves each module lazily on the first call that needs it; pass your own (an app's static imports, a test's fakes) and nothing is resolved dynamically

SecureStoreModule, AppStateModule, PlatformModule and FileSystemModule are the exported types for the parts of those four modules this entry calls.

Auth: g.auth

MethodSignatureReturns
sendEmailCode(email)Promise<void>. Emails a sign-in code
verifyEmailCode({ email, code })Promise<AuthSession>
currentUser()Promise<CurrentUser>. Never throws for session state
logout()Promise<void>. Ends this device's session; the person's other devices stay signed in. The method is logout, not signOut
onSignedOut(listener: (event: { reason: "signed_out" | "revoked" | "expired" }) => void)() => void, which stops listening. Fires when the session ends, however it ended; the token is already cleared. Route to sign-in from here
devices()Promise<Device[]>. The person's signed-in devices, newest first; 401 session_required signed out
signOutDevice(id, { code? }?)Promise<void>. Signs one device out; this device signs the app out and fires onSignedOut. Another device needs a sign-in within 10 minutes, else 403 confirm_required: a confirm code is emailed, so call again with { code } (a wrong one is 400 invalid_code). 404 device_not_found, 400 invalid_device
signOutEverywhere()Promise<void>. Every device, this one included; fires onSignedOut

Swift: g.auth

The same eight methods, with Swift's argument labels. Everything that leaves the device is async throws and throws GemmeinError.

SwiftSignatureReturns
sendEmailCode(_:)func sendEmailCode(_ email: String) async throwsVoid
verifyEmailCode(email:code:)func verifyEmailCode(email: String,
code: String) async throws -> AuthSession
AuthSession { token: String, expiresAt: String,
user: AuthUser }
currentUser()func currentUser() async throws -> CurrentUserCurrentUser { authenticated: Bool, userId: String?,
email: String?, storeAccountToken: String? }
. One struct with optionals where JS has a union: read authenticated first
logout()func logout() async throwsVoid. Idempotent
onSignedOut(_:)@discardableResult func onSignedOut(_ listener: @escaping @Sendable (SignedOutEvent) -> Void) -> @Sendable () -> VoidA closure that stops listening. SignedOutEvent { reason: .signedOut | .revoked | .expired }
devices()func devices() async throws -> [Device][Device], with the same fields as JavaScript
signOutDevice(_:code:)func signOutDevice(_ id: String, code: String? = nil) async throwsVoid
signOutEverywhere()func signOutEverywhere() async throwsVoid
type AuthSession = { token, expiresAt, user: { id, email } }
type Device = { id, device, place, signedInAt, lastActiveAt, expiresAt, test, current }   // place: an approximate country or null, never an IP address or a city
type CurrentUser = { authenticated: true, userId, email, storeAccountToken? }   // note: userId, NOT id
                 | { authenticated: false, ended? }   // ended: "expired" | "revoked", only for a session that ran out or was ended

storeAccountToken is the opaque per-person name to give a store: RevenueCat's app user id, Apple's appAccountToken, Google's obfuscatedExternalAccountId. It is created on the first currentUser() call, stable for the life of the person, correlated to nothing, and gone when they are erased. Give the store this value. Never give it userId or the email address. See Mobile.

authenticated: false says why only through ended, and only for a session that ran out ("expired") or was ended ("revoked": signed out from this device or another, or by the owner, which is not said). Suspended by the owner and erased read like no session at all, so a moderated person's state never leaks to the client. One signed-out screen covers every case; ended only picks its sentence.

Account: g.account

MethodSignatureReturns
delete()Promise<unknown>. Erases the signed-in person, clears the stored token
export()Promise<AccountExport>. The signed-in person's data as one JSON document; once every 30 days, counted from the last export, then 429 export_rate_limited with resetAt

Swift: g.account

SwiftSignatureReturns
delete()@discardableResult func delete() async throws -> JSONValue?JSONValue?. The server's body, where JS has unknown; @discardableResult, so ignoring it is legal
export()func export() async throws -> AccountExportAccountExport { exportVersion, exportedAt, app, person,
records, recordCount, marks, files, purchases, subscription,
credits, access, aiCalls, runs, emails, support, sessions,
signIns, limits { caps, textBudgetBytes, truncated }, document }
. Every section is typed as in JS; a public file has no urlExpiresAt; limits.truncated names each section something was left out of, and is empty when the export is whole. document is the whole export; jsonData() gives its bytes to save

The "delete my account" screen. Every app it applies to needs one (GDPR right to erasure; Apple 5.1.1(v) for apps with account creation). Server-side it's the full cascade: sessions revoked, records and files deleted, subscription record removed, and a paid plan in Stripe set to cancel at the end of its period. Irreversible: put a confirmation step in front of it.

Data: g.collection<T>(name)

MethodSignatureReturns
create(data, { key?, for?, published?, showFrom?, showUntil?, visibility?: "followers" | `circle:${string}` | "everyone" }?)Promise<GemmeinRecord>
list({ limit?, sort?, where?, cursor?, search?, expand?, since? }?)Promise<ListResult>: since (the previous answer's watermark) returns only what changed or was deleted after it, oldest first; incompatible with sort
count({ where?, search? }?)Promise<number>: how many records this person could list, under the same safety rule, scope and filters
stats(field, { where?, search? }?)Promise<RecordStats>: { count, sum, avg, min, max } over the records whose field is a number; the four are null when none is
watch(onChange, { every?, where?, search?, limit? }?){ stop() }. Polls list({ since }) every 10 s (5 s floor); onChange gets { records, deleted, initial }, sleeps while the tab is hidden and resyncs on return
get(id, { expand? }?)Promise<GemmeinRecord>
update(id: string, data: Partial<T> | { field: { increment|decrement, floor?, ceiling? } }, options?: { ifVersion?: number; published?: boolean; showFrom?; showUntil?; visibility?: "followers" | `circle:${string}` | "everyone" | null })Promise<GemmeinRecord>
delete(id)Promise<void>
upload(file: Blob | File, options?: { name?: string; contentType?: string; for?: string | string[]; visibility?: "followers" | `circle:${string}` })Promise<{ id, ref, contentType, sizeBytes }>. Store ref in a record field, never a URL; for (addressed/direct only) hands the file to that person; on a direct collection, for: [ids] hands it to up to 20
g.files.link(ref, { intent? }?)Promise<{ ref, url, expiresAt?, contentType, sizeBytes?, name? }>. Call when you render; links expire in minutes unless the collection is public. intent: "download" for documents

Swift: g.collection(_:)

The same methods, with the record body described in Swift above. upload takes Data where JavaScript takes a Blob, and watch is not async.

SwiftSignatureReturns
create(_:key:for:published:showFrom:showUntil:visibility:)@discardableResult func create(_ data: [String: JSONValue], key: String? = nil, for recipient: String? = nil, published: Bool? = nil, showFrom: Date? = nil, showUntil: Date? = nil, visibility: RecordVisibility? = nil) async throws -> GemmeinRecordGemmeinRecord — visibility: .followers makes it followers-only, .circle(id) a circle post (community, shared)
list(_:)func list(_ options: ListOptions = ListOptions()) async throws -> ListResultListResult { records: [GemmeinRecord], cursor: String?,
hasMore: Bool, deleted: [String], watermark: String? }
count(where:search:)func count(where filter: [String: JSONValue]? = nil,
search: String? = nil) async throws -> Int
Int
stats(_:where:search:)func stats(_ field: String,
where filter: [String: JSONValue]? = nil,
search: String? = nil) async throws -> RecordStats
RecordStats { count: Int, sum: Double?, avg: Double?,
min: Double?, max: Double? }
watch(every:where:search:limit:onChange:)func watch(every: TimeInterval? = nil,
where filter: [String: JSONValue]? = nil,
search: String? = nil, limit: Int? = nil,
onChange: @escaping @Sendable (WatchDelta) -> Void) -> Watcher
Watcher. Not async; watcher.stop() ends it. WatchDelta { records, deleted, initial }
get(_:expand:)func get(_ id: String,
expand: [String] = []) async throws -> GemmeinRecord
GemmeinRecord
update(_:_:ifVersion:published:showFrom:showUntil:visibility:)@discardableResult func update(_ id: String, _ data: [String: JSONValue], ifVersion: Int? = nil, published: Bool? = nil, showFrom: ShowBound? = nil, showUntil: ShowBound? = nil, visibility: RecordVisibility? = nil) async throws -> GemmeinRecordGemmeinRecord — .everyone opens a followers-only record back to the rule
delete(_:)func delete(_ id: String) async throwsVoid
create(_:key:for:published:showFrom:showUntil:visibility:) — many@discardableResult func create(_ data: [String: JSONValue], key: String? = nil, for recipients: [String], published: Bool? = nil, showFrom: Date? = nil, showUntil: Date? = nil, visibility: RecordVisibility? = nil) async throws -> GemmeinRecordGemmeinRecord — one direct record every named person (up to 20) reads; recipients lists them
open(_:)func open(_ id: String) -> OpenFieldsClientOpenFieldsClient — the open fields of one record
OpenFieldsClient.count(_:_:)@discardableResult func count(_ field: String, _ step: Int) async throws -> GemmeinRecordGemmeinRecord — 1 adds your one, -1 takes it back
OpenFieldsClient.set(_:_:)@discardableResult func set(_ field: String, _ value: String) async throws -> GemmeinRecordGemmeinRecord — a choice from the field's list
OpenFieldsClient.choose(_:_:)@discardableResult func choose(_ field: String, _ choice: String?) async throws -> GemmeinRecordGemmeinRecord — your one choice from a "one per person of" list (nil withdraws it); the counts are in data[field], yours in chosen[field]
OpenFieldsClient.flag(_:_:)@discardableResult func flag(_ field: String, _ on: Bool) async throws -> GemmeinRecordGemmeinRecord — your own yes/no; mine says it
upload(_:name:contentType:for:visibility:)func upload(_ data: Data, name: String = "upload", contentType: String = "", for recipient: String? = nil, visibility: RecordVisibility? = nil) async throws -> UploadedFileUploadedFile { id: String, ref: String, contentType: String, sizeBytes: Int } — ref is file:<uuid>; visibility: .followers = a followers file (signed links for you, your current followers, the dashboard and the server key only)
upload(_:name:contentType:for:) — manyfunc upload(_ data: Data, name: String = "upload",
contentType: String = "",
for recipients: [String]) async throws -> UploadedFile
UploadedFile. A group attachment on a direct collection: every person named (up to 20) and you can link it
g.files.link(_:intent:)func link(_ ref: String,
intent: LinkIntent? = nil) async throws -> FileLink
FileLink { ref: String, url: String, expiresAt: String?,
contentType: String, sizeBytes: Int?, name: String? }
. url is a String, not a URL: pass it through URL(string:) before AsyncImage. intent is .inline or .download

ListOptions(limit:sort:where:cursor:search:expand:since:) — every argument optional, sort is ListSort (.newest, .oldest, .updated, or .by("score", order: .asc) for a rankable number field, when each record carries rank). create takes showFrom: / showUntil: as Dates and update as a ShowBound: .at(date) or .clear. where keeps the label where, binding a [String: JSONValue]. Your fields live under data, read as note.data["title"]?.string; everything else on a GemmeinRecord is server-derived and read-only.

type GemmeinRecord<T> = {
  id: string
  data: T                      // YOUR fields live here — record.data.title, never record.title
  createdAt: string
  updatedAt: string
  ownerUserId: string | null   // server-set; null for app-owned (receipts, dashboard-created)
  version: number              // +1 per update; pass as { ifVersion } to guard concurrent edits
  published: boolean           // top-level; only meaningful on public safety rules
  key?: string                 // the create-if-absent key, when one was used
  audienceUserId?: string      // recipient on addressed/direct (server-stamped)
  expand?: Record<string, GemmeinRecord | null>   // filled by { expand: [...] }
  existing?: true              // present when a keyed create returned YOUR existing record
  showFrom?: string            // hidden from other readers before this instant (top-level, like published)
  showUntil?: string           // hidden from other readers from this instant on
  rank?: number                // on a ranked list ({ sort: { by } }): 1 + how many records rank better; ties share
}

type ListResult<T> = { records: GemmeinRecord<T>[], cursor?, hasMore,
                       deleted?, watermark? }  // deleted rides `since` reads (your scope only); watermark: on a delta read, the FINAL page's; paging a full sync, adopt the FIRST page's

sort: "newest" | "oldest" | "updated", or { by, order } for a rankable number field (order is "desc", the default, or "asc"; each record then carries rank; not with since). showFrom / showUntil on create and update take an ISO 8601 instant with a zone, or a Date; null clears one on update (show and hide times). where: a literal per field is an exact match; an object of operators (eq ne gt gte lt lte in nin contains startsWith exists) narrows it, up to 5 fields and 3 operators each, all AND (the grammar). expand: up to 3 link fields, only on community/shared/direct. Atomic counters go in value position: update(id, { stock: { decrement: 1, floor: 0 } }).

When you add a field to a live collection (run npx gemmein go-live again, or use the dashboard's Go live page), you can give it a default. Records that never wrote the field read the default, and expand targets and where see it too. A written value wins, and writing null resets it to the default. search still matches stored text only. See Data & safety rules.

People: g.people

Follows and blocks, kept on the server for every collection at once. Follows are one-way: anyone follows anyone, and friends are people who follow each other, worked out when asked and never stored. A block works both ways: neither person reads the other’s records in any collection while signed in (list, get, count, stats, search, expand, open fields, watch), any follow between them ends, and neither can follow or message the other (403 person_unavailable, the same sentence whoever blocked). Blocking beats following.

The feed is list({ author: "following" }), on shared, community, public_read and direct. A followers-only post is create(data, { visibility: "followers" }) on community and shared: the author, the author’s followers right now, the dashboard and your server read it, and never anyone signed out. Photos on it are uploaded with upload(file, { visibility: "followers" }) (Swift visibility: .followers); a followers-only post carries only its author’s followers files, else 400 file_is_public. It never tells anyone who blocked them, and it cannot hide a public post from someone signed out.

Profiles, private accounts and requests. relationship(id) answers the profile numbers counts: { followers, following } to any signed-in person. followersOf(id) and followingOf(id) list another person’s; a private account’s lists show only to the people it accepted (403 person_private). setPrivate(true) makes an account private: a follow becomes a request (requested: true), answered by accept(id) or decline(id), and a private person’s posts and files on community, shared, public_read and admin_write reach only them, the people they accepted, your dashboard and your server; a circle post still reaches its circle and a direct message its people. setPrivate(false) accepts every request waiting. A person switches at most 10 times an hour (3 in Development).

SurfaceSignatureReturns
g.people.follow / unfollow(userId: string)Promise<PersonRelationship> — { userId, following, followsYou, friends, blocking, requested, requestedYou, private, counts }; a second follow changes nothing; 403 person_unavailable across a block; your own id 400 invalid_person; nobody 404 person_not_found
g.people.block / unblock(userId: string)Promise<PersonRelationship> — a block ends both follows in the same step; an unblock never brings them back
g.people.relationship(userId: string)Promise<PersonRelationship> — blocking is your own block only
g.people.following / followers / friends / blocked(options?: { limit?: number; cursor?: string })Promise<{ people: Array<{ userId: string; since: string }>; cursor?: string; hasMore: boolean }> — newest first; since is when the follow (block) began, for friends when they became mutual
g.people.counts()Promise<{ following: number; followers: number; blocked: number; requests: number; private: boolean }>
g.people.followersOf / followingOf(userId: string, options?: { limit?: number; cursor?: string })Promise<PeoplePage> — another person's list; 403 person_private on a private account you don't follow; 403 person_unavailable across a block
g.people.requests / requested(options?: { limit?: number; cursor?: string })Promise<PeoplePage> — follow requests waiting for you / yours still waiting
g.people.accept / decline(userId: string)Promise<PersonRelationship> — 404 request_not_found when none waits
g.people.setPrivate(on: boolean)Promise<{ private: boolean; accepted: number }> — off accepts every request waiting
create / update option{ visibility: "followers" | `circle:${string}` | "everyone" } (null on update = everyone)the record carries its visibility; "followers" or a circle off community and shared, or on a record that names no person, is 400 invalid_visibility; "everyone" is allowed on every rule
list option{ author: "following" }the records of the people this person follows now; 400 invalid_author without a person's session
delta answerresync: truethis person's follows or blocks changed since since — re-list and replace (watch() does it: initial: true)

Swift: g.people

SwiftSignatureReturns
g.people.follow(_:)@discardableResult func follow(_ userId: String) async throws -> PersonRelationshipPersonRelationship { userId, following, followsYou, friends, blocking }
g.people.unfollow(_:)@discardableResult func unfollow(_ userId: String) async throws -> PersonRelationshipPersonRelationship
g.people.block(_:)@discardableResult func block(_ userId: String) async throws -> PersonRelationshipPersonRelationship
g.people.unblock(_:)@discardableResult func unblock(_ userId: String) async throws -> PersonRelationshipPersonRelationship
g.people.relationship(_:)func relationship(_ userId: String) async throws -> PersonRelationshipPersonRelationship
g.people.following(limit:cursor:)func following(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePagePeoplePage { people: [PersonEdge], cursor: String?, hasMore: Bool }
g.people.followers(limit:cursor:)func followers(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePagePeoplePage
g.people.friends(limit:cursor:)func friends(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePagePeoplePage
g.people.blocked(limit:cursor:)func blocked(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePagePeoplePage
g.people.counts()func counts() async throws -> PeopleCountsPeopleCounts { following: Int, followers: Int, blocked: Int, requests: Int, private: Bool }
g.people.followersOf(_:limit:cursor:)func followersOf(_ userId: String, limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePagePeoplePage
g.people.followingOf(_:limit:cursor:)func followingOf(_ userId: String, limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePagePeoplePage
g.people.requests(limit:cursor:)func requests(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePagePeoplePage
g.people.requested(limit:cursor:)func requested(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePagePeoplePage
g.people.accept(_:)@discardableResult func accept(_ userId: String) async throws -> PersonRelationshipPersonRelationship { …, requested, requestedYou, private, counts }
g.people.decline(_:)@discardableResult func decline(_ userId: String) async throws -> PersonRelationshipPersonRelationship
g.people.setPrivate(_:)@discardableResult func setPrivate(_ on: Bool) async throws -> PrivacySettingPrivacySetting { private: Bool, accepted: Int }

Bounds. A person follows at most 5,000 and blocks 1,000 (429 follow_limit and block_limit); 200 follow changes and 60 block changes an hour (429 too_many_changes, with resetAt). Cloud Development: 200 follows and 50 blocks per person, 30 and 20 changes an hour, 2,000 follows and 500 blocks per app (429 development_capped); npx gemmein dev takes Live’s bounds.

What happens if… someone unfollows: their followers-only posts leave that person’s reads at once, and a watcher resyncs. Two people block: both follows end and their records leave each other’s app. They unblock: they see each other again, and nobody follows until they follow again. A person deletes their account: every follow and block naming them goes. You go live: Live starts with no follows or blocks, because a promote carries collections and never people.

Circles: g.circles

A person’s own small groups: close friends, a team, a family. The person who creates a circle adds and removes its members; every member reads the circle’s posts and member list, and may leave. A circle post is create(data, { visibility: "circle:" + circle.id }) on community and shared (Swift visibility: .circle(circle.id)); its files are uploaded with the same visibility. Only the circle’s current members read it, and removing someone ends it at once. A member never adds or removes anyone (403 circle_creator_only); a circle you are not in reads as not found (404 circle_not_found); posting to one you are not in is 403 not_in_circle.

SurfaceSignatureReturns
g.circles.create(name: string)Promise<Circle> — { id, name, createdBy, members, you: "creator" | "member", createdAt }; a name is 1–80 characters (400 invalid_circle_name)
g.circles.list(options?: { limit?: number; cursor?: string })Promise<{ circles: Circle[]; cursor?: string; hasMore: boolean }> — the circles you are in
g.circles.get(circleId: string)Promise<Circle>
g.circles.members(circleId: string, options?: { limit?: number; cursor?: string })Promise<PeoplePage> — never anyone blocked with you
g.circles.add / remove(circleId: string, userId: string)Promise<Circle> — the creator only; 429 circle_full past the plan's size; 403 person_unavailable across a block
g.circles.leave(circleId: string, options?: { transferTo?: string })Promise<{ left: true; circleId: string; transferredTo?: string }> — the creator names a member in transferTo or disbands, else 400 circle_creator_leaving
g.circles.disband(circleId: string)Promise<{ disbanded: true; circleId: string }> — the creator only; its posts are then read by their authors alone

Swift: g.circles

SwiftSignatureReturns
g.circles.create(_:)func create(_ name: String) async throws -> CircleInfoCircleInfo { id, name, createdBy, members, you, createdAt }
g.circles.list(limit:cursor:)func list(limit: Int? = nil, cursor: String? = nil) async throws -> CirclesPageCirclesPage { circles: [CircleInfo], cursor: String?, hasMore: Bool }
g.circles.get(_:)func get(_ circleId: String) async throws -> CircleInfoCircleInfo
g.circles.members(_:limit:cursor:)func members(_ circleId: String, limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePagePeoplePage
g.circles.add(_:_:)@discardableResult func add(_ circleId: String, _ userId: String) async throws -> CircleInfoCircleInfo
g.circles.remove(_:_:)@discardableResult func remove(_ circleId: String, _ userId: String) async throws -> CircleInfoCircleInfo
g.circles.leave(_:transferTo:)@discardableResult func leave(_ circleId: String, transferTo: String? = nil) async throws -> CircleLeftCircleLeft { circleId, transferredTo }
g.circles.disband(_:)func disband(_ circleId: String) async throws—

Bounds. Members per circle by the account’s plan: 50, 250 on Run a business, 1,000 on Grow (429 circle_full); 100 circles per person (429 circle_limit); 300 circle changes an hour (429 too_many_changes); 500 follow requests a person has sent waiting and 1,000 waiting for them (429 request_limit). Cloud Development: 50 members, 10 circles, 60 changes, 50 and 100 requests, and 200 circles and 2,000 memberships per app (429 development_capped).

Payments

MethodSignatureReturns
g.subscriptions.mine()Promise<{ plan, status, trialEndsAt, endsAt } | null>. status is trialing, active or cancelled
g.subscriptions.checkout(plan?)Promise<{ url, plan }>. Navigates the browser to Stripe
g.subscriptions.manage()Promise<{ url }>. Navigates to a Stripe customer portal session Gemmein opens for this member (switch plan, cancel at the end of the period, card). no_subscription when they have never paid for a plan; portal_unavailable when Stripe isn't connected or the key lacks Customer portal: Write
g.payments.buy(product, { item? }?)Promise<{ url, product, item? }>. Navigates the browser to Stripe
g.payments.products()Promise<CatalogProduct[]>: { name, price: { amountMinor, currency }, delivers, credits, unlocks }. What the app sells; no sign-in needed
g.subscriptions.plans()Promise<CatalogPlan[]>: { name, free, price: { amountMinor, currency, period }, credits, unlocks }. The free plan has price 0; no sign-in needed
g.purchases.mine()Promise<Purchase[]>. The array itself: { item, kind, amountMinor, currency, refundedMinor, status, grants, paidAt, delivery? }

Swift: g.subscriptions / g.payments / g.purchases

Nothing navigates: checkout and buy answer a url and the app opens it (openURL, SFSafariViewController).

SwiftSignatureReturns
g.subscriptions.mine()func mine() async throws -> Subscription?Subscription? { plan: String, status: String, trialEndsAt: String?, endsAt: String? }. nil when they never paid
g.subscriptions.checkout(plan:)func checkout(plan: String? = nil) async throws -> CheckoutSessionCheckoutSession { url: String, plan: String }
g.subscriptions.manage()func manage() async throws -> ManageLinkManageLink { url: String }: a Stripe customer portal session for this member; open it yourself
g.payments.buy(_:item:)func buy(_ product: String,
item: String? = nil) async throws -> PaymentSession
PaymentSession { url: String, product: String,
item: String? }
g.payments.products()func products() async throws -> [CatalogProduct][CatalogProduct]: name, price: CatalogPrice, delivers, credits: Int?, unlocks
g.subscriptions.plans()func plans() async throws -> [CatalogPlan][CatalogPlan]: name, free, price: CatalogPrice, creditsPerPeriod, creditsOnce, unlocks
g.purchases.mine()func mine() async throws -> [Purchase][Purchase]. item, kind, amountMinor: Int?, currency: String?, refundedMinor: Int, status, grants: [String], paidAt, delivery: Purchase.Delivery? — an enum (.gemmeinFile / .externalURL) where JS has a tagged object

Credits: g.credits

MethodSignatureReturns
g.credits.balance()Promise<{ balance }>. The signed-in person's balance now; 401 session_required without a session. The ledger, limits and codes are on Credits

Swift: g.credits

SwiftSignatureReturns
g.credits.balance()func balance() async throws -> IntInt — the balance itself, where JS answers { balance }. Read it directly
spendCredits(_:amount:reason:key:)@discardableResult func spendCredits(_ personId: String,
amount: Int? = nil, reason: String,
key: String? = nil) async throws -> SpendCreditsResult
SpendCreditsResult { spent: Int, deduped: Bool,
balanceBefore: Int, balanceAfter: Int, event: Event }
— two flat Ints where JS nests balance: { before, after }. On GemmeinServer

AI: g.ai

MethodSignatureReturns
g.ai.run(tool, inputs?, { stream?, signal? }?)Promise<Response>. The tool's provider's own answer, untouched (SSE when stream). A non-2xx from the provider is returned as-is; a Gemmein refusal throws GemmeinError. inputs is a flat Record<string, string | number | boolean>. See AI tools
g.ai.runText(tool, inputs?, { signal? }?)Promise<string>. A non-stream run collected to one string, whichever provider answered; a provider's non-2xx throws provider_error
g.ai.calls({ limit?, before? }?)Promise<{ calls: AiCallRecord[], nextCursor }>. The signed-in person's own calls, newest first; nextCursor is null on the last page
g.ai.upload(tool, file, { name?, contentType? }?)Promise<{ id, ref, contentType, sizeBytes }>. Uploads audio (mp3, m4a, wav, webm, ogg, flac) for a transcribe tool. Only the signed-in person can use the ref; pass it to g.ai.run(tool, { audio: ref }). Capped at the tool's provider's own limit (25 MB, 14 MB on Google); deleted after 24 h if no run used it, or right after the run when the owner switched that on. Browser sessions only; a secret key gets scope_denied
g.ai.chat(body, { tool?, provider?, signal? }?)Promise<Response>. The provider's own request body in, the fetch Response out, untouched: status, headers and bytes are the provider's, 2xx or not, and a stream stays a stream. One credit per call. Gemmein's own refusals throw GemmeinError. See AI tools
g.ai.text(body, options?)Promise<string>. A non-stream answer as one string, whichever provider answered; a provider's non-2xx throws provider_error with the provider's status and message

Swift: g.ai

AiResponse stands where JS returns the fetch Response, and run / runText take the label inputs:, as in runText("summary", inputs: ["text": .string(body)]).

SwiftSignatureReturns
run(_:inputs:stream:)func run(_ tool: String,
inputs: [String: JSONValue] = [:],
stream: Bool = false) async throws -> AiResponse
AiResponse. stream is a plain Bool argument, not an options object
runText(_:inputs:)func runText(_ tool: String,
inputs: [String: JSONValue] = [:]) async throws -> String
String. The label is inputs:
calls(limit:before:)func calls(limit: Int? = nil,
before: String? = nil) async throws -> AiCallPage
AiCallPage { calls: [AiCallRecord], nextCursor: String? }. AiCallRecord { id, tool, kind, provider, model,
tokensIn, tokensOut, credits, outcome, refusalCode, latencyMs,
answer, units, unitCount, createdAt }
; never the prompt
upload(_:_:name:contentType:)func upload(_ tool: String, _ data: Data,
name: String = "audio",
contentType: String = "") async throws -> UploadedFile
UploadedFile { id, ref, contentType, sizeBytes }. Audio (mp3, m4a, wav, webm, ogg, flac) for a transcribe tool only. Only the signed-in person can use ref; pass it to run(_:inputs:)
chat(_:provider:tool:)func chat(_ body: [String: JSONValue],
provider: AiProvider? = nil,
tool: String? = nil) async throws -> AiResponse
AiResponse
text(_:provider:tool:)func text(_ body: [String: JSONValue],
provider: AiProvider? = nil,
tool: String? = nil) async throws -> String
String

AiResponse carries status: Int, headers, ok: Bool, creditsRemaining: Int?, refunded: Bool, tool: String?, isFake: Bool, func data() async throws -> Data, and two streams, lines() and events(), both AsyncThrowingStream<String, Error>. AiProvider is .openai, .anthropic, .google.

Runs: g.runs

What an image, audio or video tool (kind generate or transcribe) answers with: a run that ends later, not an answer now. How runs reserve and settle credits is on AI tools. App sessions only: a secret key gets 403 scope_denied, no session 401 session_required.

MethodSignatureReturns
g.runs.start(tool, inputs?, { key?, signal? }?)Promise<Run>. Answers at once (202) with status queued and credits reserved at the tool's ceiling. key (1 to 80 characters) makes a second start with the same key answer the same run. Refused 429 runs_capped at 10 open runs per person, 402 credits_exhausted when the balance is under the ceiling, plus g.ai.run's own refusals
g.runs.get(id)Promise<Run>. The run as it is now. result is null until succeeded; its file URLs are minted for this answer, so call again when one lapses. 404 unknown_run when it is not this person's
g.runs.list({ since?, limit? }?)Promise<Run[]>. The person's own runs, newest first. since (a Date or an ISO string) narrows to runs updated after it; limit 1 to 200
g.runs.watch(id, { onUpdate?, signal?, intervalMs? }?)Promise<Run>. Polls get every 2 s, backing off ×1.5 to at most 10 s (or every intervalMs), calls onUpdate whenever updatedAt moves, and resolves once the run is succeeded, failed, cancelled or expired. An aborted signal rejects aborted (status 0)
g.runs.cancel(id)Promise<Run>. Ends an open run cancelled and releases the hold. 409 run_ended when it already ended; 404 unknown_run

Run carries id, tool, kind, status, progress (0 to 100, or null), key, reserved (the ceiling held at creation), held (what is still held now: reserved while open, 0 once ended), charged (null while open), units, unitCount, result: { files: [{ ref, contentType, sizeBytes, url, urlExpiresAt }], text }, data (your own pipeline's output, as sent), error, createdAt, updatedAt, endedAt and handoff (your own pipeline's delivery: { attempts, acknowledgedAt, lastError }, null for every other provider).

Swift: g.runs

The same five methods. watch takes a trailing onUpdate closure, and cancelling the calling Task stands where JS aborts a signal: it throws GemmeinError aborted with status 0.

SwiftSignatureReturns
start(_:inputs:key:)func start(_ tool: String,
inputs: [String: JSONValue] = [:],
key: String? = nil) async throws -> Run
Run. The label is inputs:, as on g.ai.run
get(_:)func get(_ id: String) async throws -> RunRun
list(since:limit:)func list(since: String? = nil,
limit: Int? = nil) async throws -> [Run]
[Run]
list(since:limit:) with a Datefunc list(since: Date,
limit: Int? = nil) async throws -> [Run]
[Run]. Sends the same ISO 8601 instant JS's toISOString() writes
watch(_:intervalMs:onUpdate:)func watch(_ id: String,
intervalMs: Int? = nil,
onUpdate: (@Sendable (Run) -> Void)? = nil) async throws -> Run
Run, once ended. The same 2 s start, ×1.5 backoff and 10 s ceiling
cancel(_:)func cancel(_ id: String) async throws -> RunRun

Run has the same fields. status is a RunStatus (.queued, .running, .succeeded, .failed, .cancelled, .expired, .other(String), with isEnded); result is a RunResult { files: [RunResultFile], text: String? }; data is a JSONValue?; handoff is a RunHandoff?. A status added in a later Gemmein version arrives as .other(String), so a switch on RunStatus needs a default.

Relays: g.relays

A relay with an app_call trigger is one your app calls by name. It checks who is calling, takes the caller’s own credits (spend_credits), then does the rest. The app holds no key. The relay file, the refusals and what happens if are on A call from your app.

SurfaceSignatureReturns
g.relays.call(name: string, input?: unknown, options?: { key?: string; signal?: AbortSignal })Promise<{ ok: true; run: { id: string }; spent?: number; balance?: number; records?: Array<{ collection: string; id: string }>; deduped?: boolean; followUps?: "retrying" | "failed" }> — a refusal throws GemmeinError with the code and status listed on A call from your app

Swift: g.relays

SwiftSignatureReturns
g.relays.callfunc call(_ name: String, input: JSONValue = .null, key: String? = nil) async throws -> RelayCallResultRelayCallResult { runId: String, spent: Int?, balance: Int?, records: [RelayCallRecord], deduped: Bool } — runId flat where JS nests run: { id }; a refusal throws GemmeinError

Server: gemmeinServer(sk)

MethodSignatureNotes
collection(name).get / .list / .update / .create / .deleteas above, plus create(data, { for?, from?, key?, published?, showFrom?, showUntil?, visibility? }) and delete(id)Scoped per collection in the dashboard: "reads only" or "reads and writes" (create + update), with "deletes too" its own tick. Or create it as a full key, which reads, writes and deletes in every collection, including ones added later. A call outside the key's scope is 403 scope_denied. No auth access. create's for/from names whose record it is, per safety rule (private · shared · community → for; addressed → for; direct → from + for; admin_write · public_read · app_only → neither); a record a secret key created reads back writtenBy: "server"
verifySession(token)Promise<{ ok, person: { id, email, role }, holdings }>. The browser's session token in, identity and holdings out, in one call per request. Needs no capability on the key (the caller already holds the person's token); never touches the session. No extension, no last-seen
holdings(personId)Promise<{ ok, person: { id, email, role, suspended }, holdings }>. For the paths with no token in hand. A suspended person is returned with suspended: true and their holdings; verifySession refuses them. Needs the key's "Look up a person's access by id" box
grantAccess(personId, { entitlement, source?, expiresAt?, reason? })Promise<{ ok, grant, holdings }>. Access by hand: a trial, a promotion, a migration. source ∈ manual | trial | promotion | migration (default manual): purchases and subscriptions come from the built-in Stripe path; another provider's signed webhook grants through a relay. reason ≤ 200 chars, never edited, and it is what the owner reads in their logs. holdings is the state AFTER. Needs "Grant and revoke access"
revokeAccess(personId, grantId, { reason? }?)Promise<{ ok, grant, holdings }>. A grant ends once: the returned grant carries revokedAt, and a second call is 409 already_revoked. A key may end a payment-made grant (as the dashboard's "end this access" does). The payment itself is untouched. Same capability as granting
invitePerson(email)Promise<{ person: { id, email, role, invited, suspended }, created }>. Create a person by email before they sign in (201, created: true), or find them (200, created: false); idempotent, case-insensitive, one id. invited stays true until their first sign-in; a suspended person is returned flagged. The one server call that takes an email: POST /server/people, 500 invite calls per app per day (429 invite_capped; resetAt says when the window ends). Needs the key's "Create a person by email before they sign in" box
notify(personId, { subject, text, kind?, key? })Promise<{ sent, deduped?, recorded?, id, threadId, replyRail? }>. Email one of YOUR verified people by id, never an address. kind: "event" (default, capped 5/person/day) or "account" (security and account notices; skips the per-person cap, while the 200/app/hour cap and the owner's off-switch still apply). key makes it at-most-once. Sends land as Inbox threads. Sends only from YOUR verified sender domain, from that domain's news word (kind: "account" sends from its account word). Until one is verified on the Domains page, the call is refused sender_domain_required (409) and nothing leaves. Sign-in codes are the one email Gemmein sends on your behalf before that, as <App name> (via Gemmein)
spendCredits(personId, { amount?, reason, key? })Promise<{ ok, spent, deduped, balance: { before, after }, event: { id, reason, actor } }>. One conditional decrement, floored at zero: refused whole with 402 credits_exhausted and the balance in the message. amount defaults to 1 (1 to 10,000); reason is what the owner reads on the ledger; key makes a retry answer the same event with spent: 0, scoped to the person. Needs "Spend a person's credits". See Credits
testSession(email)Promise<{ token, expiresAt, user }>. Development only; sk_live throws test_session_forbidden_live. Used by Testing in CI.
listInboxThreads(options?: { unanswered?, limit? })Promise<{ threads, total, unanswered, capped }>. GET /server/inbox/threads: your app's support conversations, unanswered first, then newest; 20 unless you ask for more, 50 at most. Each: { id, customer, subject, state, unanswered, unread, lastMessageAt, label }. The key needs "Read your customers' support messages" (--access inbox:read). A Live key sees Live's people's and strangers' conversations; a Development key only Development's people's
getInboxThread(threadId)Promise<{ thread, messages, earlierMessages, aiMayReply: { ok, code?, message? } }>. GET /server/inbox/threads/:id: plain text, oldest first, the last 50 messages (8,000 characters each); from is customer, you or your AI; automatic says why a customer message reads as automatic. Reading never marks it read. Unknown id: thread_not_found (404)
replyInboxThread(threadId, text)Promise<{ message }>, 201. POST /server/inbox/threads/:id/reply: exactly the dashboard's reply (same subject, threading and sender), to that conversation's customer only; the message carries sentBy: { via: "key", name, id }. The key needs "Reply to your customers' support messages" (--access inbox:reply). Refused with nothing sent: inbox_unauthenticated, inbox_auto_reply, inbox_reply_cap and inbox_unreadable (409); inbox_thread_limited, inbox_recipient_limited, inbox_reply_limited and development_capped (429); thread_not_found (404). See the error table

Swift: GemmeinServer

GemmeinServer is included in the package for Swift that runs on a server. It takes a secret key, and a secret key never belongs in an app bundle; Gemmein refuses one. These answers carry the result alone, with no ok field: a refusal throws.

SwiftSignatureReturns
verifySession(_:)func verifySession(_ token: String) async throws -> PersonHoldingsPersonHoldings { person: GatePerson, holdings: Holdings }
holdings(_:)func holdings(_ personId: String) async throws -> PersonHoldingsPersonHoldings
grantAccess(_:entitlement:source:expiresAt:reason:)@discardableResult func grantAccess(_ personId: String,
entitlement: String, source: ManualGrantSource? = nil,
expiresAt: String? = nil,
reason: String? = nil) async throws -> GrantResult
GrantResult { grant: Grant, holdings: Holdings }. ManualGrantSource is .manual, .trial, .promotion, .migration
revokeAccess(_:_:reason:)@discardableResult func revokeAccess(_ personId: String,
grantId: String,
reason: String? = nil) async throws -> GrantResult
GrantResult
invitePerson(_:)func invitePerson(_ email: String) async throws -> InviteResultInviteResult { person: GatePerson, created: Bool }
notify(_:subject:text:kind:key:)@discardableResult func notify(_ personId: String,
subject: String, text: String,
kind: NotifyKind? = nil,
key: String? = nil) async throws -> NotifyResult
NotifyResult { sent: Bool, deduped: Bool?, id: String?,
threadId: String?, replyRail: Bool?, recorded: Bool? }
. subject and text are required labels, not an options object; NotifyKind is .event or .account
spendCredits(_:amount:reason:key:)see Credits aboveSpendCreditsResult
collection(_:)func collection(_ name: String) throws -> ServerCollectionClientServerCollectionClient — func get(_ id: String) async throws -> GemmeinRecord, func list(_ options: ListOptions = ListOptions()) async throws -> ListResult, @discardableResult func update(_ id: String, _ data: [String: JSONValue], published: Bool? = nil, ifVersion: Int? = nil, showFrom: ShowBound? = nil, showUntil: ShowBound? = nil, visibility: RecordVisibility? = nil) async throws -> GemmeinRecord, @discardableResult func create(_ data: [String: JSONValue], for person: String? = nil, from author: String? = nil, key: String? = nil, published: Bool? = nil, showFrom: Date? = nil, showUntil: Date? = nil, visibility: RecordVisibility? = nil) async throws -> GemmeinRecord, @discardableResult func create(_ data: [String: JSONValue], for recipients: [String], from author: String? = nil, key: String? = nil, showFrom: Date? = nil, showUntil: Date? = nil) async throws -> GemmeinRecord (a direct record to up to 20 people), func delete(_ id: String) async throws. Scoped per collection in the dashboard, the same as the JS client above. A call outside the key's scope throws scope_denied
testSession(_:)func testSession(_ email: String) async throws -> AuthSessionAuthSession. Development only
listInboxThreads(unanswered:limit:)func listInboxThreads(unanswered: Bool = false,
limit: Int? = nil) async throws -> InboxThreadList
InboxThreadList { threads: [InboxThreadSummary], total: Int, unanswered: Int, capped: Bool }
getInboxThread(_:)func getInboxThread(_ threadId: String) async throws -> InboxThreadInboxThread { thread, messages: [InboxMessage], earlierMessages: Int, aiMayReply: Bool, aiRefusalCode: String?, aiRefusalMessage: String? } — flat fields where JS nests aiMayReply: { ok, code, message }
replyInboxThread(_:text:)@discardableResult func replyInboxThread(_ threadId: String,
text: String) async throws -> InboxReplyResult
InboxReplyResult { messageId: String?, recorded: Bool }

GatePerson { id: String, email: String, role: String, suspended: Bool?, invited: Bool? }; Holdings { access: [String], grants: [Grant], credits: Int? }, with a bare Int? where JS nests { balance }.

// Your own server code, on your own host (Vercel, a VPS, a cron box).
// It checks who the person is and what they hold, and can change their access.
const { person, holdings } = await g.verifySession(token)
if (!holdings.access.includes("access:pro")) return deny()

type Holdings = {
  access: string[]                // the keys they hold NOW — ["access:pro"]
  grants: Grant[]                 // the LIVE grants behind them (revoked/expired are gone)
  credits: { balance: number } | null   // the balance now (engine 0.8.0+); null from an older local engine — see /credits
}

type Grant = {
  id: string
  entitlement: string           // "access:<slug>" — the plan's or product's own key
  source: "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration"
  startsAt: string
  expiresAt: string | null
  revokedAt?: string | null       // present on the grant revokeAccess returns
}

holdings lists what a person has access to. It carries no subscription status, amounts or Stripe ids, and each grant shows its source kind only, never its sourceId. Check access against what they hold, and identify people by id, never by email address.

Looking up a person by id and granting access each need a checkbox you tick when you create the key. Keys created before these options existed have both off.

Every /server/* call a resolved secret key makes, allowed or refused, is recorded against that key. The API keys page shows the summary (last used · 7-day calls · refusals), and the key's own page has the full day × route × outcome table. A refusal from a publishable pk_ key can't be attributed to a secret key, so it appears only in the request log. Grants and revokes write a full before→after entry to the logs, attributed to the key by name.

These calls can be refused with session_invalid, session_expired, session_revoked, person_suspended, person_not_found, capability_required, invalid_source, invalid_entitlement, unknown_plan, grant_not_found, already_revoked, invalid_id and invalid_body. Each code's action is in the error table.

Keys & environments

PrefixEnvironmentWhere it lives
pk_test_...DevelopmentFrontend code. Safe to expose
pk_live_...LiveFrontend code. Safe to expose
sk_dev_... / sk_live_...Development / LiveServer env vars only. Runs your app; never links or syncs
sk_cli_...DevelopmentThe CLI's own key. npx gemmein login asks for your approval in the browser, and the CLI then holds the key in gemmein/.data/cloud.json; nothing is shown or pasted. What gemmein sync acts with in Development for 7 days; each Live action asks for its own approval. Never in git
sk_sync_...LiveThe Live push key. npx gemmein sync --live asks for its own browser approval, and the CLI receives this key for that one push, good for 10 minutes. It carries relays and AI tools into Live; nothing is shown or pasted, and it is never saved

Environments are fully isolated: different data, different people, different collections. Errors: see the full code table. Management (collections, domains, keys, payments config, logs) is dashboard-only and excluded from the SDK, so management credentials can never leak from app code.

Each key has its own address rule. The Development app key works from any secure (https) address, such as a browser builder’s preview, a host’s preview deploy or your own domain, and from localhost, before and after go-live, with nothing to list. The Live app key works only on the app’s verified domains: a live build on any other address fails every call with 403 origin_not_allowed. Add the domain you go live on from the start, on the dashboard’s Domains page or with npx gemmein domains add, and switch the live build to the Live key (npx gemmein keys app). This is a browser rule: a native mobile app sends no Origin header, so it is never checked against a domain.

What happens if…

If…What happensWhat you do
You revoke a secret keyIts next call is refused with 403 invalid_app_key ("invalid secret key"), with no grace periodPut the replacement in your server's environment before you revoke
You rotate a secret keyThe new key keeps the old one's name, collections and checkboxes, and is shown once. The old key is refused from that momentSwap the environment variable straight away
A key reaches a collection it is not scoped to403 scope_deniedUse a key scoped to that collection, made on Settings → API keys
A key without the checkbox invites a person or grants access403 capability_required, naming the checkboxUse a key with that checkbox ticked
The same refusal repeatsEvery call is counted on the key's page. Logs keep one row for it each hourNothing
A pk_ key's call is refusedIt appears on no key's page, only in the request logNothing
A key from a browser approval has ended (7 days for npx gemmein login, 10 minutes for a Live approval)403 secret_key_expired, naming the instantRun npx gemmein login, or the Live command again; each asks for a fresh approval
An sk_local_ key reaches the cloud403 local_keyGo live to mint cloud keys

Limits

Every limit below is enforced by the server and returns the listed error code. Where a limit counts per IP, an IPv6 address counts by its /64.

WhatLimitOn breach
Sign-in codes sent3 per email · 20 per IP · per 15 min429 with resetAt
Code attempts3 per email · 30 per IP · per 15 min429 with resetAt
Code lifetime10 minutesask for a new one
Session lifetime30 daysauth_expired once, then re-auth
list() page size25 by default · 100 maximum400 invalid_limit above 100. Check hasMore
Record writes60 per minute, per app per IP429 with resetAt
Record reads150 per minute, per app per IP429 with resetAt
In-app sends (addressed, direct)100 per hour, per sender429 with resetAt
File upload (a person's upload())25 MB · images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB); audio 100 MB, video 500 MB (Development 25 MB / 50 MB)413 file_too_large
AI run output file200 MB each · image, audio or video, a wider allowance than a person's upload413 file_too_large
Audio upload for a transcribe tool (g.ai.upload)25 MB · 14 MB on Google · mp3, m4a, wav, webm, ogg, flac only413 file_too_large · 400 unsupported_audio_type
where filter5 fields, scalar valuesinvalid_filter
AI calls20 per person per minute · the credits set on the tool it names, one for an unnamed call429 ai_capped with resetAt · 402 credits_exhausted
AI run inputs (g.ai.run)64 KB413 payload_too_large
AI raw request body (g.ai.chat)256 KB, nested at most 32 levels413 payload_too_large · 400 invalid_body
AI call time170 s in all; a stream has 10 s to its first response headers502 provider_unreachable, the credit refunded
Server spend1 to 10,000 credits per call400 invalid_amount

list() returns one page at a time. With no limit you get 25 records, and a limit above 100 is refused with invalid_limit ("limit must be between 1 and 100"). A list that looks short usually has more pages. Always read hasMore and page with cursor.

Licences

The SDK, the MCP server and the gemmein launcher are MIT. The local runtime the launcher downloads is proprietary: run and cache it freely for building against Gemmein, and do not redistribute it. Full text at downloads.gemmein.com/engine/LICENSE.

Was this page helpful?

Last updated