# SDK reference

Canonical: https://docs.gemmein.com/reference
Markdown: https://docs.gemmein.com/reference.md

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](https://docs.gemmein.com/mobile.md).

## 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.10.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,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](https://docs.gemmein.com/mobile.md).

```
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,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 |
| `SecureStoreTokenStore` | `new 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_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,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`

| 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` |
| `signInWithGoogle` | `({ returnUrl? }?)` | `Promise<void>` — **web only**: redirects the browser to Google; `returnUrl` defaults to `location.href` and must be one of this app's verified domains. On return, call `completeGoogleRedirect()` once — `location.hash` never carries a session, only a short-lived login code. Throws `no_browser` with no `window`; a native app calls `exchangeIdToken` instead |
| `completeGoogleRedirect` | `()` | `Promise<AuthSession \| null>`. Call once on the page `signInWithGoogle`'s `returnUrl` lands on. `null` when the address carries no login code — safe to call on every page load |
| `getGoogleNonce` | `()` | `Promise<string>` — **native/Expo only**: call this FIRST, before asking Google's own sign-in widget for an ID token, and pass the nonce it returns as the `nonce` Google signs into the token. Single-use, lasts 10 minutes — one per attempt |
| `exchangeIdToken` | `(provider: "google", idToken: string)` | `Promise<AuthSession>` — a client that already holds a Google ID token (native/Expo's own Google sign-in widget, or GemmeinSwift) exchanges it directly. The token's nonce must be one `getGoogleNonce()` made for THIS app; unrecognized or already used is refused `google_nonce_required` |

### Swift: `g.auth`

The same eight methods, with Swift's argument labels, plus Google's two native methods below (Swift has no `signInWithGoogle` or `completeGoogleRedirect`: iOS has no browser redirect to carry). 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,code: String) async throws -> AuthSession` | `AuthSession { token: String, expiresAt: String,user: AuthUser }` |
| `currentUser()` | `func currentUser() async throws -> CurrentUser` | `CurrentUser { authenticated: Bool, userId: String?,email: String?, storeAccountToken: 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` |
| `getGoogleNonce()` | `func getGoogleNonce() async throws -> String` | `String` — call FIRST, before asking Google's own sign-in SDK for an ID token; pass it as the `nonce` Google signs into the token. Single-use, lasts 10 minutes |
| `exchangeIdToken(provider:idToken:)` | `func exchangeIdToken(provider: String,idToken: String) async throws -> AuthSession` | `AuthSession` — a token this app already holds (your own Google sign-in SDK) for a Gemmein session, its nonce one `getGoogleNonce()` made for THIS app |

```
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](https://docs.gemmein.com/mobile.md).

`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,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)`

| 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?,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 -> 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",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 `Date`s 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](https://docs.gemmein.com/data/collections.md#ranked-lists) (`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](https://docs.gemmein.com/data/collections.md#show-hide)). `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](https://docs.gemmein.com/data/collections.md#filters)). `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](https://docs.gemmein.com/data.md).

## 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,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`

| 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](https://docs.gemmein.com/credits.md) |

### 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,amount: Int? = nil, reason: String,key: String? = nil) async throws -> SpendCreditsResult` | `SpendCreditsResult { spent: Int, deduped: Bool,balanceBefore: Int, balanceAfter: Int, event: Event }` — two flat `Int`s 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](https://docs.gemmein.com/ai.md) |
| `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](https://docs.gemmein.com/ai.md) |
| `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,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](https://docs.gemmein.com/ai/runs.md#runs). 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,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 -> Run` | `Run` |
| `list(since:limit:)` | `func list(since: String? = nil,limit: Int? = nil) async throws -> [Run]` | `[Run]` |
| `list(since:limit:)` with a `Date` | `func 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 -> 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](https://docs.gemmein.com/relays/triggers.md#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](https://docs.gemmein.com/relays/triggers.md#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](https://docs.gemmein.com/relays.md). `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](https://docs.gemmein.com/credits.md) |
| `testSession` | `(email)` | `Promise<{ token, expiresAt, user }>`. Development only; `sk_live` throws `test_session_forbidden_live`. Used by [Testing in CI](https://docs.gemmein.com/reaffirm.md). |
| `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](https://docs.gemmein.com/errors.md#messages) |

### 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,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 -> InviteResult` | `InviteResult { 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 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,limit: Int? = nil) async throws -> InboxThreadList` | `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,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. Keys 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](https://docs.gemmein.com/errors.md).

## 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 |
| `pk_local_...` / `sk_local_...` | Local (`npx gemmein dev`) | Minted only by the local runtime; refused outright by the cloud (`403 local_key`) — never used past go-live |

Environments are fully isolated: different data, different people, different collections. Errors: see the [full code table](https://docs.gemmein.com/errors.md). 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 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](https://downloads.gemmein.com/engine/LICENSE).
