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.
| Swift | Signature | Returns |
|---|---|---|
Gemmein(appKey:…) | init(appKey: String, apiURL: URL = defaultAPIURL, | 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, | CollectionClient. g.storage.collection(_:intent:) is the same factory, the same signature |
GemmeinServer(secretKey:…) | init(secretKey: String, apiURL: URL = defaultAPIURL, | 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 bareInt, where JavaScript returns a{ balance }wrapper.g.files.link(_:)returns aFileLinkwhoseurlis aString.- 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 })
| Export | Signature | What it is |
|---|---|---|
createExpoGemmein | (options: GemmeinOptions, | 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 |
SecureStoreTokenStore | new SecureStoreTokenStore( | 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) | 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) | 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) | The platform string on x-client-info: expo-ios, expo-android, expo-web, or expo when no Platform was passed |
expoUploadPart | (file: 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_LIMIT | 2048 | The 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, | 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
| Method | Signature | Returns |
|---|---|---|
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.
| Swift | Signature | Returns |
|---|---|---|
sendEmailCode(_:) | func sendEmailCode(_ email: String) async throws | Void |
verifyEmailCode(email:code:) | func verifyEmailCode(email: String, | AuthSession { token: String, expiresAt: String, |
currentUser() | func currentUser() async throws -> CurrentUser | CurrentUser { authenticated: Bool, userId: String?,. One struct with optionals where JS has a union: read authenticated first |
logout() | func logout() async throws | Void. Idempotent |
onSignedOut(_:) | @discardableResult func onSignedOut(_ listener: @escaping @Sendable (SignedOutEvent) -> Void) -> @Sendable () -> Void | A 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 throws | Void |
signOutEverywhere() | func signOutEverywhere() async throws | Void |
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
| Method | Signature | Returns |
|---|---|---|
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
| Swift | Signature | Returns |
|---|---|---|
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 -> AccountExport | AccountExport { exportVersion, exportedAt, app, person,. 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)
| Method | Signature | Returns |
|---|---|---|
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.
| Swift | Signature | Returns |
|---|---|---|
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 -> GemmeinRecord | GemmeinRecord — visibility: .followers makes it followers-only, .circle(id) a circle post (community, shared) |
list(_:) | func list(_ options: ListOptions = ListOptions()) async throws -> ListResult | ListResult { records: [GemmeinRecord], cursor: String?, |
count(where:search:) | func count(where filter: [String: JSONValue]? = nil, | Int |
stats(_:where:search:) | func stats(_ field: String, | RecordStats { count: Int, sum: Double?, avg: Double?, |
watch(every:where:search:limit:onChange:) | func watch(every: TimeInterval? = nil, | Watcher. Not async; watcher.stop() ends it. WatchDelta { records, deleted, initial } |
get(_:expand:) | func get(_ id: String, | 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 -> GemmeinRecord | GemmeinRecord — .everyone opens a followers-only record back to the rule |
delete(_:) | func delete(_ id: String) async throws | Void |
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 -> GemmeinRecord | GemmeinRecord — one direct record every named person (up to 20) reads; recipients lists them |
open(_:) | func open(_ id: String) -> OpenFieldsClient | OpenFieldsClient — the open fields of one record |
OpenFieldsClient.count(_:_:) | @discardableResult func count(_ field: String, _ step: Int) async throws -> GemmeinRecord | GemmeinRecord — 1 adds your one, -1 takes it back |
OpenFieldsClient.set(_:_:) | @discardableResult func set(_ field: String, _ value: String) async throws -> GemmeinRecord | GemmeinRecord — a choice from the field's list |
OpenFieldsClient.choose(_:_:) | @discardableResult func choose(_ field: String, _ choice: String?) async throws -> GemmeinRecord | GemmeinRecord — 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 -> GemmeinRecord | GemmeinRecord — 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 -> UploadedFile | UploadedFile { 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:) — many | func upload(_ data: Data, name: String = "upload", | 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, | FileLink { ref: String, url: String, expiresAt: 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).
| Surface | Signature | Returns |
|---|---|---|
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 answer | resync: true | this person's follows or blocks changed since since — re-list and replace (watch() does it: initial: true) |
Swift: g.people
| Swift | Signature | Returns |
|---|---|---|
g.people.follow(_:) | @discardableResult func follow(_ userId: String) async throws -> PersonRelationship | PersonRelationship { userId, following, followsYou, friends, blocking } |
g.people.unfollow(_:) | @discardableResult func unfollow(_ userId: String) async throws -> PersonRelationship | PersonRelationship |
g.people.block(_:) | @discardableResult func block(_ userId: String) async throws -> PersonRelationship | PersonRelationship |
g.people.unblock(_:) | @discardableResult func unblock(_ userId: String) async throws -> PersonRelationship | PersonRelationship |
g.people.relationship(_:) | func relationship(_ userId: String) async throws -> PersonRelationship | PersonRelationship |
g.people.following(limit:cursor:) | func following(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePage | PeoplePage { people: [PersonEdge], cursor: String?, hasMore: Bool } |
g.people.followers(limit:cursor:) | func followers(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePage | PeoplePage |
g.people.friends(limit:cursor:) | func friends(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePage | PeoplePage |
g.people.blocked(limit:cursor:) | func blocked(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePage | PeoplePage |
g.people.counts() | func counts() async throws -> PeopleCounts | PeopleCounts { 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 -> PeoplePage | PeoplePage |
g.people.followingOf(_:limit:cursor:) | func followingOf(_ userId: String, limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePage | PeoplePage |
g.people.requests(limit:cursor:) | func requests(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePage | PeoplePage |
g.people.requested(limit:cursor:) | func requested(limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePage | PeoplePage |
g.people.accept(_:) | @discardableResult func accept(_ userId: String) async throws -> PersonRelationship | PersonRelationship { …, requested, requestedYou, private, counts } |
g.people.decline(_:) | @discardableResult func decline(_ userId: String) async throws -> PersonRelationship | PersonRelationship |
g.people.setPrivate(_:) | @discardableResult func setPrivate(_ on: Bool) async throws -> PrivacySetting | PrivacySetting { 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.
| Surface | Signature | Returns |
|---|---|---|
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
| Swift | Signature | Returns |
|---|---|---|
g.circles.create(_:) | func create(_ name: String) async throws -> CircleInfo | CircleInfo { id, name, createdBy, members, you, createdAt } |
g.circles.list(limit:cursor:) | func list(limit: Int? = nil, cursor: String? = nil) async throws -> CirclesPage | CirclesPage { circles: [CircleInfo], cursor: String?, hasMore: Bool } |
g.circles.get(_:) | func get(_ circleId: String) async throws -> CircleInfo | CircleInfo |
g.circles.members(_:limit:cursor:) | func members(_ circleId: String, limit: Int? = nil, cursor: String? = nil) async throws -> PeoplePage | PeoplePage |
g.circles.add(_:_:) | @discardableResult func add(_ circleId: String, _ userId: String) async throws -> CircleInfo | CircleInfo |
g.circles.remove(_:_:) | @discardableResult func remove(_ circleId: String, _ userId: String) async throws -> CircleInfo | CircleInfo |
g.circles.leave(_:transferTo:) | @discardableResult func leave(_ circleId: String, transferTo: String? = nil) async throws -> CircleLeft | CircleLeft { 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
| Method | Signature | Returns |
|---|---|---|
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).
| Swift | Signature | Returns |
|---|---|---|
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 -> CheckoutSession | CheckoutSession { url: String, plan: String } |
g.subscriptions.manage() | func manage() async throws -> ManageLink | ManageLink { url: String }: a Stripe customer portal session for this member; open it yourself |
g.payments.buy(_:item:) | func buy(_ product: String, | PaymentSession { url: String, product: 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
| Method | Signature | Returns |
|---|---|---|
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
| Swift | Signature | Returns |
|---|---|---|
g.credits.balance() | func balance() async throws -> Int | Int — the balance itself, where JS answers { balance }. Read it directly |
spendCredits(_:amount:reason:key:) | @discardableResult func spendCredits(_ personId: String, | SpendCreditsResult { spent: Int, deduped: Bool, — two flat Ints where JS nests balance: { before, after }. On GemmeinServer |
AI: g.ai
| Method | Signature | Returns |
|---|---|---|
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)]).
| Swift | Signature | Returns |
|---|---|---|
run(_:inputs:stream:) | func run(_ tool: String, | AiResponse. stream is a plain Bool argument, not an options object |
runText(_:inputs:) | func runText(_ tool: String, | String. The label is inputs: |
calls(limit:before:) | func calls(limit: Int? = nil, | AiCallPage { calls: [AiCallRecord], nextCursor: String? }. AiCallRecord { id, tool, kind, provider, model,; never the prompt |
upload(_:_:name:contentType:) | func upload(_ tool: String, _ data: Data, | 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], | AiResponse |
text(_:provider:tool:) | func text(_ body: [String: JSONValue], | 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.
| Method | Signature | Returns |
|---|---|---|
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.
| Swift | Signature | Returns |
|---|---|---|
start(_:inputs:key:) | func start(_ tool: String, | Run. The label is inputs:, as on g.ai.run |
get(_:) | func get(_ id: String) async throws -> Run | Run |
list(since:limit:) | func list(since: String? = nil, | [Run] |
list(since:limit:) with a Date | func list(since: Date, | [Run]. Sends the same ISO 8601 instant JS's toISOString() writes |
watch(_:intervalMs:onUpdate:) | func watch(_ id: String, | Run, once ended. The same 2 s start, ×1.5 backoff and 10 s ceiling |
cancel(_:) | func cancel(_ id: String) async throws -> Run | Run |
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.
| Surface | Signature | Returns |
|---|---|---|
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
| Swift | Signature | Returns |
|---|---|---|
g.relays.call | func call(_ name: String, input: JSONValue = .null, key: String? = nil) async throws -> RelayCallResult | RelayCallResult { runId: String, spent: Int?, balance: Int?, records: [RelayCallRecord], deduped: Bool } — runId flat where JS nests run: { id }; a refusal throws GemmeinError |
Server: gemmeinServer(sk)
| Method | Signature | Notes |
|---|---|---|
collection(name).get / .list / .update / .create / .delete | as 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.
| Swift | Signature | Returns |
|---|---|---|
verifySession(_:) | func verifySession(_ token: String) async throws -> PersonHoldings | PersonHoldings { person: GatePerson, holdings: Holdings } |
holdings(_:) | func holdings(_ personId: String) async throws -> PersonHoldings | PersonHoldings |
grantAccess(_:entitlement:source:expiresAt:reason:) | @discardableResult func grantAccess(_ personId: String, | GrantResult { grant: Grant, holdings: Holdings }. ManualGrantSource is .manual, .trial, .promotion, .migration |
revokeAccess(_:_:reason:) | @discardableResult func revokeAccess(_ personId: String, | GrantResult |
invitePerson(_:) | func invitePerson(_ email: String) async throws -> InviteResult | InviteResult { person: GatePerson, created: Bool } |
notify(_:subject:text:kind:key:) | @discardableResult func notify(_ personId: String, | NotifyResult { sent: Bool, deduped: Bool?, id: String?,. subject and text are required labels, not an options object; NotifyKind is .event or .account |
spendCredits(_:amount:reason:key:) | see Credits above | SpendCreditsResult |
collection(_:) | func collection(_ name: String) throws -> ServerCollectionClient | ServerCollectionClient — 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 -> AuthSession | AuthSession. Development only |
listInboxThreads(unanswered:limit:) | func listInboxThreads(unanswered: Bool = false, | InboxThreadList { threads: [InboxThreadSummary], total: Int, unanswered: Int, capped: Bool } |
getInboxThread(_:) | func getInboxThread(_ threadId: String) async throws -> InboxThread | InboxThread { 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, | 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
| Prefix | Environment | Where it lives |
|---|---|---|
pk_test_... | Development | Frontend code. Safe to expose |
pk_live_... | Live | Frontend code. Safe to expose |
sk_dev_... / sk_live_... | Development / Live | Server env vars only. Runs your app; never links or syncs |
sk_cli_... | Development | The 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_... | Live | The 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 happens | What you do |
|---|---|---|
| You revoke a secret key | Its next call is refused with 403 invalid_app_key ("invalid secret key"), with no grace period | Put the replacement in your server's environment before you revoke |
| You rotate a secret key | The new key keeps the old one's name, collections and checkboxes, and is shown once. The old key is refused from that moment | Swap the environment variable straight away |
| A key reaches a collection it is not scoped to | 403 scope_denied | Use a key scoped to that collection, made on Settings → API keys |
| A key without the checkbox invites a person or grants access | 403 capability_required, naming the checkbox | Use a key with that checkbox ticked |
| The same refusal repeats | Every call is counted on the key's page. Logs keep one row for it each hour | Nothing |
A pk_ key's call is refused | It appears on no key's page, only in the request log | Nothing |
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 instant | Run npx gemmein login, or the Live command again; each asks for a fresh approval |
An sk_local_ key reaches the cloud | 403 local_key | Go 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.
| What | Limit | On breach |
|---|---|---|
| Sign-in codes sent | 3 per email · 20 per IP · per 15 min | 429 with resetAt |
| Code attempts | 3 per email · 30 per IP · per 15 min | 429 with resetAt |
| Code lifetime | 10 minutes | ask for a new one |
| Session lifetime | 30 days | auth_expired once, then re-auth |
list() page size | 25 by default · 100 maximum | 400 invalid_limit above 100. Check hasMore |
| Record writes | 60 per minute, per app per IP | 429 with resetAt |
| Record reads | 150 per minute, per app per IP | 429 with resetAt |
In-app sends (addressed, direct) | 100 per hour, per sender | 429 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 file | 200 MB each · image, audio or video, a wider allowance than a person's upload | 413 file_too_large |
Audio upload for a transcribe tool (g.ai.upload) | 25 MB · 14 MB on Google · mp3, m4a, wav, webm, ogg, flac only | 413 file_too_large · 400 unsupported_audio_type |
where filter | 5 fields, scalar values | invalid_filter |
| AI calls | 20 per person per minute · the credits set on the tool it names, one for an unnamed call | 429 ai_capped with resetAt · 402 credits_exhausted |
AI run inputs (g.ai.run) | 64 KB | 413 payload_too_large |
AI raw request body (g.ai.chat) | 256 KB, nested at most 32 levels | 413 payload_too_large · 400 invalid_body |
| AI call time | 170 s in all; a stream has 10 s to its first response headers | 502 provider_unreachable, the credit refunded |
| Server spend | 1 to 10,000 credits per call | 400 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.
Thanks for telling us.
Thanks for telling us.
Last updated