@gemmein/sdk 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/REFERENCE.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Gemmein SDK — API Reference
2
2
 
3
- Complete surface of `@gemmein/sdk`, generated from the type definitions.
3
+ Complete surface of `@gemmein/sdk`, generated from the type definitions
4
+ with, beside each JavaScript method, the `GemmeinSwift` signature and return
5
+ type for the same method, and everything `@gemmein/sdk/expo` adds.
4
6
  This is the **reference** (every method, signature, return shape, error);
5
7
  `llms.txt` is the **guide** (how the model works and the safe patterns). Both
6
8
  are needed. Draft — feeds docs.gemmein.com.
@@ -16,9 +18,45 @@ const g = gemmein("pk_..."); // browser: your public app key (domain-lock
16
18
 
17
19
  ### `gemmein(appKey, options?) → Gemmein`
18
20
  The browser client. `appKey` is your public `pk_...` key. `options` (optional):
19
- `{ apiUrl?: string, tokenStore?: TokenStore }`. Throws `GemmeinError`
21
+ `{ apiUrl?: string, tokenStore?: TokenStore, fetch?: typeof fetch, visibility?: VisibilityHook, platform?: string }`.
22
+ Throws `GemmeinError`
20
23
  (`missing_app_key` / `invalid_app_key`) if the key is absent or an `sk_`.
21
24
 
25
+ The last three are what a non-browser runtime needs and a browser never passes.
26
+ `fetch` is used for **every** request this client makes, in place of
27
+ `globalThis.fetch`. `visibility`
28
+ (`{ isHidden(): boolean; onChange(cb: () => void): () => void }`) is where
29
+ `watch()` learns the app went to the background, in place of
30
+ `document.visibilityState`; `onChange` returns its own unsubscribe. `platform`
31
+ is appended to the `x-client-info` header — `gemmein-sdk/<version> expo-ios` —
32
+ cleaned and capped to the 64 characters the usage ledger stores.
33
+
34
+ On Expo, `createExpoGemmein` from `@gemmein/sdk/expo` fills those three in for
35
+ a phone, and turns `upload()`'s picker shape into bytes Expo's fetch will
36
+ send. Every export that entry adds is in **Expo** below.
37
+
38
+ ### `Gemmein(appKey:) → GemmeinSwift.Gemmein`
39
+ The Swift package, for iOS 17+ and macOS 14+, with no dependencies:
40
+ `.package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.10.0")`.
41
+ The same HTTP contract and the same method names as `@gemmein/sdk`, in Swift
42
+ idiom — `g.auth`, `g.collection(_:)`, `g.files`, `g.subscriptions`,
43
+ `g.payments`, `g.purchases`, `g.account`, `g.credits`, `g.ai`, and
44
+ `GemmeinServer` for Swift that runs on a server.
45
+
46
+ ```swift
47
+ let g = try Gemmein(appKey: "pk_live_…") // KeychainTokenStore by default
48
+
49
+ try await g.auth.sendEmailCode(email)
50
+ _ = try await g.auth.verifyEmailCode(email: email, code: code)
51
+ let page = try await g.collection("notes").list(ListOptions(limit: 20))
52
+ let me = try await g.auth.currentUser() // .storeAccountToken for RevenueCat
53
+ ```
54
+
55
+ An `sk_` key is refused here, as in the browser. Every Swift signature sits
56
+ beside its JavaScript twin in the sections below, under **Swift**; the client's
57
+ own initialiser, the shapes that differ, and the session store are in **Swift —
58
+ the package**.
59
+
22
60
  ### `gemmeinServer(secretKey, options?) → GemmeinServer`
23
61
  Server-only client for a `sk_...` secret key — **never ship this to the
24
62
  browser.** `options` (optional): `{ apiUrl?: string }` — **the option is
@@ -49,12 +87,32 @@ happens in the owner's dashboard, deliberately not in this SDK.
49
87
  | `currentUser` | `()` | `Promise<CurrentUser>` — **never throws** for session state; safe on load |
50
88
  | `logout` | `()` | `Promise<void>` — revokes the server session **(the method is `logout`, not `signOut`)** |
51
89
 
90
+
91
+ **Swift.** `GemmeinSwift` names the same four methods, with Swift's argument
92
+ labels. Everything that leaves the device is `async throws` and throws
93
+ `GemmeinError`.
94
+
95
+ | Swift | Signature | Returns |
96
+ |---|---|---|
97
+ | `sendEmailCode(_:)` | `func sendEmailCode(_ email: String) async throws` | `Void` |
98
+ | `verifyEmailCode(email:code:)` | `func verifyEmailCode(email: String, code: String) async throws -> AuthSession` | `AuthSession { token: String, expiresAt: String, user: AuthUser }` |
99
+ | `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, then the rest |
100
+ | `logout()` | `func logout() async throws` | `Void` — idempotent |
101
+
52
102
  ```ts
53
103
  type AuthSession = { token: string; expiresAt: string; user: { id: string; email: string } };
54
104
  type CurrentUser =
55
- | { authenticated: true; userId: string; email: string } // note: userId, NOT id
56
- | { authenticated: false; userId?: undefined; email?: undefined };
105
+ | { authenticated: true; userId: string; email: string; storeAccountToken?: string | null } // note: userId, NOT id
106
+ | { authenticated: false; userId?: undefined; email?: undefined; storeAccountToken?: undefined };
57
107
  ```
108
+ `storeAccountToken` is the opaque per-person name to hand a store — RevenueCat's
109
+ app user id, Apple's `appAccountToken`, Google's `obfuscatedExternalAccountId`.
110
+ It is minted on the first `currentUser()` call, stable for the life of the
111
+ person, correlated to nothing, and gone when they are erased. Hand a store
112
+ that, never `userId` and never the address: a value inside a third party's
113
+ ledger is outside the erasure cascade. An engine that does not carry one leaves
114
+ the field absent — read that as "not carried", never as "no account". See
115
+ **Mobile** below.
58
116
  `verifyEmailCode` resolves the **session** (`user.id`, nested). `currentUser`
59
117
  resolves the **identity** (`userId`, flat). Use `currentUser()` for who's
60
118
  signed in. Sessions are one-per-user: verifying a new code revokes older ones.
@@ -72,6 +130,11 @@ screen covers all three.
72
130
  |--------|-----------|---------|
73
131
  | `delete` | `()` | `Promise<unknown>` — erases the signed-in user, clears the stored token |
74
132
 
133
+
134
+ | Swift | Signature | Returns |
135
+ |---|---|---|
136
+ | `delete()` | `@discardableResult func delete() async throws -> JSONValue?` | `JSONValue?` — the server's body as a `JSONValue?` where JS has `unknown`; `@discardableResult`, so ignoring it is legal |
137
+
75
138
  The "delete my account" screen. Every app it **applies** to needs one (GDPR
76
139
  right to erasure; Apple 5.1.1(v) for any app with account creation).
77
140
  Server-side it's the full cascade — sessions revoked, records and files
@@ -103,15 +166,46 @@ certain the collection exists yet.
103
166
  | `delete` | `(id: string)` | `Promise<void>` |
104
167
  | `upload` | `(file: Blob \| File, options?: { name?: string; contentType?: string; for?: string })` | `Promise<{ id: string; ref: FileRef; contentType: string; sizeBytes: number }>` |
105
168
 
169
+
170
+ **Swift.** The same seven methods. Three shapes differ on purpose: a record
171
+ body is `[String: JSONValue]` (a literal needs no ceremony —
172
+ `["title": "Ran", "done": false]` — a variable needs its case,
173
+ `["title": .string(typed)]`); `upload` takes `Data`, not a `Blob`; and `watch`
174
+ is not `async` — it hands back a `Watcher` you end with `stop()`.
175
+
176
+ | Swift | Signature | Returns |
177
+ |---|---|---|
178
+ | `create(_:key:for:published:)` | `@discardableResult func create(_ data: [String: JSONValue], key: String? = nil, for recipient: String? = nil, published: Bool? = nil) async throws -> GemmeinRecord` | `GemmeinRecord` |
179
+ | `list(_:)` | `func list(_ options: ListOptions = ListOptions()) async throws -> ListResult` | `ListResult { records: [GemmeinRecord], cursor: String?, hasMore: Bool, deleted: [String], watermark: String? }` |
180
+ | `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: [GemmeinRecord], deleted: [String], initial: Bool }` |
181
+ | `get(_:expand:)` | `func get(_ id: String, expand: [String] = []) async throws -> GemmeinRecord` | `GemmeinRecord` |
182
+ | `update(_:_:ifVersion:published:)` | `@discardableResult func update(_ id: String, _ data: [String: JSONValue], ifVersion: Int? = nil, published: Bool? = nil) async throws -> GemmeinRecord` | `GemmeinRecord` |
183
+ | `delete(_:)` | `func delete(_ id: String) async throws` | `Void` |
184
+ | `upload(_:name:contentType:for:)` | `func upload(_ data: Data, name: String = "upload", contentType: String = "", for recipient: String? = nil) async throws -> UploadedFile` | `UploadedFile { id: String, ref: String, contentType: String, sizeBytes: Int }` — `ref` is `file:<uuid>` |
185
+
186
+ `ListOptions(limit:sort:where:cursor:search:expand:since:)` — every argument
187
+ optional, `sort` is `ListSort` (`.newest`, `.oldest`, `.updated`), and `where`
188
+ keeps the label `where`, binding a `[String: JSONValue]`. `GemmeinRecord`
189
+ carries `id`, `data: [String: JSONValue]`, `createdAt`, `updatedAt`,
190
+ `ownerUserId: String?`, `collectionId`, `appId`, `environmentId`,
191
+ `version: Int`, `key: String?`, `audienceUserId: String?`, `published: Bool`,
192
+ `expand: [String: GemmeinRecord?]` and `existing: Bool?` — your fields live
193
+ under `data`, read as `note.data["title"]?.string`.
194
+
106
195
  ### Files
107
196
 
108
- `upload()` returns a **reference** (`file:01K…`), not a URL. Store the reference
197
+ `upload()` returns a **reference** (`file:<uuid>`), not a URL. Store the reference
109
198
  in your record — it never expires and grants nothing on its own.
110
199
 
111
200
  | Method | Signature | Returns |
112
201
  |--------|-----------|---------|
113
202
  | `g.files.link` | `(ref: FileRef \| string, options?: { intent?: "inline" \| "download" })` | `Promise<{ ref, url, expiresAt?, contentType, sizeBytes?, name? }>` |
114
203
 
204
+
205
+ | Swift | Signature | Returns |
206
+ |---|---|---|
207
+ | `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 handing it to `AsyncImage`. `intent` is `.inline` or `.download` |
208
+
115
209
  ```ts
116
210
  const { ref } = await g.collection("films").upload(file)
117
211
  await g.collection("films").create({ title, poster: ref })
@@ -172,7 +266,7 @@ type GemmeinRecord<T> = {
172
266
  version: number; // +1 per update; pass as { ifVersion } to guard concurrent edits
173
267
  key?: string; // the create-if-absent key, when one was used
174
268
  audienceUserId?: string; // recipient on addressed/direct collections (server-stamped)
175
- published: boolean; // top-level, not under .data; only meaningful on public rules
269
+ published: boolean; // top-level, not under .data; on EVERY record a private record reads true
176
270
  expand?: Record<string, GemmeinRecord | null>; // filled by list/get { expand: [...] }
177
271
  existing?: true; // present only when a keyed create returned YOUR existing record
178
272
  };
@@ -201,7 +295,9 @@ retry doubles as the lookup: there is no separate get-by-key call — re-issue
201
295
  the same `create()` with the same key to fetch your own record (it counts
202
296
  as a write; a re-fetch idiom, not a read path). `for` =
203
297
  recipient id on `addressed`/`direct`. `published: false` = draft on a public
204
- rule. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
298
+ rule; the OPTION is refused anywhere else (400 `invalid_publish`), while the
299
+ FIELD comes back on every record — a private record reads `published: true`,
300
+ and it is not writable there. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
205
301
  position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
206
302
  on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
207
303
  `since` fixes the order (oldest change first) — pairing it with `sort` is a 400
@@ -233,6 +329,17 @@ Never hand-roll a polling loop; this is the sanctioned one.
233
329
  | `payments.buy` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
234
330
  | `purchases.mine` | `()` | `Promise<Purchase[]>` — the array itself (not wrapped): `{ item, kind, amountMinor, currency, refundedMinor, status, grants, paidAt, delivery? }` — gate one-off fulfilment on this, never the redirect |
235
331
 
332
+
333
+ **Swift.** Nothing navigates: `checkout` and `buy` answer a url and the app
334
+ opens it (`openURL`, `SFSafariViewController`).
335
+
336
+ | Swift | Signature | Returns |
337
+ |---|---|---|
338
+ | `g.subscriptions.mine()` | `func mine() async throws -> Subscription?` | `Subscription? { plan: String, status: String }` — `nil` when they never paid |
339
+ | `g.subscriptions.checkout(plan:)` | `func checkout(plan: String? = nil) async throws -> CheckoutSession` | `CheckoutSession { url: String, plan: String }` |
340
+ | `g.payments.buy(_:item:)` | `func buy(_ product: String, item: String? = nil) async throws -> PaymentSession` | `PaymentSession { url: String, product: String, item: String? }` |
341
+ | `g.purchases.mine()` | `func mine() async throws -> [Purchase]` | `[Purchase]` — `item`, `kind`, `amountMinor: Int?`, `currency: String?`, `refundedMinor: Int`, `status`, `grants: [String]`, `paidAt`, `delivery: Purchase.Delivery?` (`.gemmeinFile(String)` or `.externalURL(String)` — an enum where JS has a tagged object) |
342
+
236
343
  Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
237
344
  `buy` self-navigate via `window.location` — just `await` them on the click;
238
345
  don't also redirect to the returned `url`, and never build a Stripe URL
@@ -278,6 +385,14 @@ honestly, not hidden.)
278
385
  carries a footer naming why it arrived; replies land in the owner's Inbox
279
386
  as a thread (`threadId`), and `replyRail` states honestly whether
280
387
  reply-by-email is on. Every send is on the record.
388
+ - **Your domain, or nothing.** `notify()` sends only from your verified
389
+ sender domain. Until one is verified on the Domains page the send is
390
+ refused `409 sender_domain_required` — nothing leaves, nothing is
391
+ recorded, no cap is spent, and the same `key` sends once you verify. If
392
+ the provider refuses that address at send time you get `502 send_failed`
393
+ and can retry; the email is never re-sent from a Gemmein address. Sign-in
394
+ codes are the one email Gemmein sends on your behalf before a domain is
395
+ verified, as `<App name> (via Gemmein)`.
281
396
 
282
397
  ---
283
398
 
@@ -297,6 +412,12 @@ the message), never partly. No expiry. No per-token pricing.
297
412
  | `gemmeinServer(sk).spendCredits` | `(personId, { amount?: number; reason: string; key?: string })` | `Promise<{ ok: true; spent: number; deduped: boolean; balance: { before: number; after: number }; event: { id, reason, actor } }>` — `amount` defaults to 1 (1..10,000); `reason` ≤ 200 chars is what the owner reads on the person's ledger; `key` makes the spend at-most-once per person (a repeat answers `deduped: true`, `spent: 0`, the same `event`, and moves nothing; one key reused for a second person charges that person). Needs **"Spend a person's credits"** ticked on the key (`403 capability_required` otherwise) |
298
413
  | `Holdings.credits` | — | `{ balance: number }` on `verifySession()` and `holdings()` from engine 0.8.0 (`null` from an older local engine) — one verify answers who, what they hold and how many |
299
414
 
415
+
416
+ | Swift | Signature | Returns |
417
+ |---|---|---|
418
+ | `g.credits.balance()` | `func balance() async throws -> Int` | **`Int`** — the balance itself, where JS answers `{ balance }`. Read it directly |
419
+ | `gemmeinServer(sk).spendCredits` | `@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 }` |
420
+
300
421
  Where credits come from, and where they go:
301
422
 
302
423
  - **A product with `grantsCredits`** (the "Grants credits" field on the
@@ -353,30 +474,99 @@ connected.
353
474
 
354
475
  ---
355
476
 
356
- ## AI — `g.ai.chat` / `g.ai.text`
477
+ ## AI — `g.ai.run` / `g.ai.runText` / `g.ai.calls` (and the raw `g.ai.chat` / `g.ai.text`)
357
478
 
358
479
  Your app talks to **OpenAI, Anthropic or Google** through Gemmein, on the
359
480
  **owner's own provider key**, which never reaches the browser, through a
360
- **named AI tool** a slug the owner prices in credits, pins a model or
361
- provider to, and gates behind an entitlement, from the dashboard's Tools
362
- page or a local `gemmein/ai/tools/<name>.json` file. Removing a tool is a
363
- step-up action, like removing a key. A call that names no
364
- `tool` runs as the **default tool**: one credit, the app's configured
365
- provider, any allowed model every existing integration that never passed
366
- `tool` keeps working unchanged. The owner pastes each provider key once in
367
- the dashboard's Keys room; it is write-only from then on. The route
368
- forwards the provider's own request body as sent minus the `provider`
369
- field, and for Google minus `model` and `stream`, which ride the URL — adds
370
- the provider's auth headers, `content-type` and `accept`, and passes the
371
- status and the bytes straight back — a stream stays a stream. It does not
372
- choose models, cache, summarise, moderate or reshape anything, and it is for
373
- the browser only: a server key is refused (`403 scope_denied`) a server
374
- calls the provider directly.
481
+ **named AI tool whose definition lives on the server** (W9.6): the
482
+ instructions (system prompt), the prompt template, the inputs it accepts,
483
+ the pinned model, the caps beside the price in credits and the
484
+ entitlement gate. The app sends a **name and inputs**; the server composes
485
+ the provider request in the provider's own grammar, gates it, spends the
486
+ tool's credits, runs it and streams the answer back. The owner prices and
487
+ gates the tool on the dashboard's AI tools page; the implementation is
488
+ written there too, or as a file at `gemmein/ai/tools/<name>.json` that
489
+ `npx gemmein sync` carries (`--live` into production with a sync key). A
490
+ file carries at least `label`, `provider` and `credits` (its name is the
491
+ file name). On every sync the file's implementation (`provider`, `model`,
492
+ `kind`, `instructions`, `promptTemplate`, `inputs`, `bounds`) applies; its
493
+ commerce values (`label`, `credits`, `requires`, `enabled`, `recordCalls`)
494
+ apply once, at creation after that the dashboard owns them. In
495
+ development, sync also writes a tool that exists only in the cloud back to
496
+ `gemmein/ai/tools/` as a file; in production such a row is left as it is.
497
+ A tool's name is fixed once created. Removing a tool is a step-up action, like
498
+ removing a key.
499
+
500
+
501
+ **Swift.** `AiResponse` stands where JS returns the fetch `Response`, and
502
+ `run` / `runText` take the label **`inputs:`** —
503
+ `runText("summary", inputs: ["text": .string(body)])`.
504
+
505
+ | Swift | Signature | Returns |
506
+ |---|---|---|
507
+ | `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 |
508
+ | `runText(_:inputs:)` | `func runText(_ tool: String, inputs: [String: JSONValue] = [:]) async throws -> String` | `String` — the label is **`inputs:`** |
509
+ | `calls(limit:before:)` | `func calls(limit: Int? = nil, before: String? = nil) async throws -> AiCallPage` | `AiCallPage { calls: [AiCallRecord], nextCursor: String? }` |
510
+ | `chat(_:provider:tool:)` | `func chat(_ body: [String: JSONValue], provider: AiProvider? = nil, tool: String? = nil) async throws -> AiResponse` | `AiResponse` |
511
+ | `text(_:provider:tool:)` | `func text(_ body: [String: JSONValue], provider: AiProvider? = nil, tool: String? = nil) async throws -> String` | `String` |
512
+
513
+ `AiResponse` carries `status: Int`, `headers: [String: String]`, `ok: Bool`,
514
+ `creditsRemaining: Int?`, `refunded: Bool`, `tool: String?`, `isFake: Bool`,
515
+ `func data() async throws -> Data`, and two streams —
516
+ `func lines() -> AsyncThrowingStream<String, Error>` and
517
+ `func events() -> AsyncThrowingStream<String, Error>` (the SSE `data:`
518
+ payloads). `AiProvider` is `.openai`, `.anthropic`, `.google`.
519
+
520
+ **Raw calls are off by default.** `g.ai.chat(body)` — the browser sending
521
+ the provider's own request body — answers `403 raw_calls_off` unless the
522
+ owner switches **raw calls** on for that provider's key on the AI tools page. Behind the switch, the raw route is what it always was: the body is
523
+ forwarded as sent (minus `provider`, and for Google minus `model` and
524
+ `stream`, which ride the URL), the provider's own status and bytes pass
525
+ straight back, a stream stays a stream, and a call that names no `tool`
526
+ runs as the **default tool** (one credit, the configured provider, any
527
+ allowed model). `gemmein dev` with no key keeps raw calls open — the fake
528
+ answers and there is no switch locally. Both routes are for the browser
529
+ only: a server key is refused (`403 scope_denied`) — a server calls the
530
+ provider directly.
531
+
532
+ **Every call lands on `ai_calls`**, the managed collection: who, which tool,
533
+ kind, provider, model, tokens in/out (when the provider said), credits,
534
+ outcome, refusal code, latency, when. The **prompt and answer** ride the row
535
+ only when the owner switches *record calls* on for that tool — off by
536
+ default. A person reads their own (`g.ai.calls()`), the owner reads by app
537
+ in the back office, erasure removes a person's rows.
375
538
 
376
539
  | Method | Signature | Returns |
377
540
  |--------|-----------|---------|
378
- | `ai.chat` | `(body: object, options?: { tool?: string; provider?: "openai" \| "anthropic" \| "google"; signal?: AbortSignal })` | `Promise<Response>` — the fetch `Response`, untouched: the provider's status, headers and body, streaming intact. A non-2xx from the provider is returned as-is (not thrown) — an answer carrying `x-gemmein-credits-remaining` passed the spend and is the provider's; a Gemmein refusal throws `GemmeinError` |
379
- | `ai.text` | `(body: object, options?: { tool?: string; provider?: …; signal?: AbortSignal })` | `Promise<string>` — a non-stream call collected to one string, whichever provider answered (openai `choices[0].message.content`; anthropic `content[].text` joined; google `candidates[0].content.parts[].text` joined); a provider's non-2xx throws `provider_error` with the provider's status and message |
541
+ | `ai.run` | `(tool: string, inputs?: Record<string, string \| number \| boolean>, options?: { stream?: boolean; signal?: AbortSignal })` | `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` |
542
+ | `ai.runText` | `(tool: string, inputs?: …, options?: { signal?: AbortSignal })` | `Promise<string>` — a non-stream run collected to one string, whichever provider answered; a provider's non-2xx throws `provider_error` |
543
+ | `ai.calls` | `(options?: { limit?: number; before?: string })` | `Promise<{ calls: AiCallRecord[]; nextCursor: string \| null }>` — the signed-in person's own calls, newest first |
544
+ | `ai.chat` | `(body: object, options?: { tool?: string; provider?: "openai" \| "anthropic" \| "google"; signal?: AbortSignal })` | `Promise<Response>` — RAW (needs the owner's switch): the fetch `Response`, untouched |
545
+ | `ai.text` | `(body: object, options?: { tool?: string; provider?: …; signal?: AbortSignal })` | `Promise<string>` — RAW, non-stream, one string |
546
+
547
+ - **The tool file.** `name` (the file name; fixed once created), `label`,
548
+ `provider`, `model` (pin it — a composed call needs one; with none, the
549
+ owner's first allowed model, else the provider's small default),
550
+ `credits`, `requires`, `enabled`, `bounds`, plus the implementation:
551
+ `kind` (`chat`, the first), `instructions` (the system turn, ≤ 20,000
552
+ chars — never leaves the server), `promptTemplate` (the user turn with
553
+ `{{input}}` placeholders naming declared inputs; ≤ 20,000; absent = the
554
+ inputs rendered one per line as `name: value`), `inputs` (≤ 20 of
555
+ `{ name, type: text|number|boolean, required?, maxLength? }` — names
556
+ 1–40 lowercase letters/digits/underscores, starting with a letter; a
557
+ text input ≤ 4,000 chars
558
+ unless it says, ≤ 20,000), `recordCalls` (off by default). A tool with
559
+ neither a template nor inputs composes nothing: `409 tool_incomplete`.
560
+ - **Inputs are checked by name** before any spend: an unknown input, a
561
+ missing required one, a wrong type or a value over its cap is
562
+ `400 invalid_inputs` — the message names the input and the rule. The run
563
+ body is `{ inputs, stream? }` and at most 64 KB.
564
+ - **Composition** per provider: OpenAI `messages` (system + user),
565
+ `max_completion_tokens`; Anthropic `system` + `messages`, `max_tokens`;
566
+ Google `systemInstruction` + `contents`, `generationConfig.maxOutputTokens`.
567
+ The output ceiling is `bounds.maxOutputTokens` or 4,096 — a composed
568
+ call is never "as long as the model likes". `bounds.stream: false`
569
+ forces a non-stream answer.
380
570
 
381
571
  - `tool` names an `ai_tools` row by its slug; omit it for the default tool.
382
572
  An unknown name is `404 unknown_tool`; a tool the owner switched off is
@@ -397,7 +587,7 @@ calls the provider directly.
397
587
  OpenAI chat completions, Anthropic messages, Google generateContent. Its
398
588
  `model` field, when present, must match `^[A-Za-z0-9._:-]{1,80}$`; Google
399
589
  needs it (it rides the URL); when the owner lists allowed models (up to
400
- 20 — the Keys room's test call uses the first), any other answers
590
+ 20 — the AI tools page's test call uses the first), any other answers
401
591
  `403 model_not_allowed`.
402
592
  - `provider` is optional when one key is configured and required when more
403
593
  than one is (`400 provider_required`); a named tool fixes its own
@@ -418,31 +608,45 @@ calls the provider directly.
418
608
  - Limits: 20 calls per person per minute (`429 ai_capped`, `resetAt`), 256 KB
419
609
  body by default (`413 payload_too_large`, unless a tool sets a smaller
420
610
  `bounds.maxBodyBytes`) nested at most 32 levels (`400 invalid_body`),
421
- 170 s in all and, on a stream, 10 s to the first response headers. Every
611
+ 170 s in all and, on a stream, 10 s to the first response headers. A
612
+ tool's `credits` are 1–10,000; `bounds.maxOutputTokens` is 1–100,000,
613
+ 4,096 when the tool sets none. Every
422
614
  `/ai/chat` call counts toward the app's `api_requests` band like any other
423
615
  request.
424
616
 
425
617
  ```ts
426
- const res = await g.ai.chat({
427
- model: "gpt-4o-mini", stream: true,
428
- messages: [{ role: "user", content: text }]
429
- }, { tool: "deep-research" })
618
+ // gemmein/ai/tools/deep-research.json
619
+ // { "label": "Deep Research", "provider": "openai", "model": "gpt-4o", "credits": 20,
620
+ // "requires": "access:pro-max",
621
+ // "instructions": "You are a careful research assistant. Answer with sources.",
622
+ // "promptTemplate": "Research this for a {{audience}} reader:\n\n{{question}}",
623
+ // "inputs": [{ "name": "question", "type": "text", "required": true, "maxLength": 2000 },
624
+ // { "name": "audience", "type": "text" }],
625
+ // "bounds": { "maxOutputTokens": 4000 } }
626
+
627
+ const res = await g.ai.run("deep-research", { question: text, audience: "beginner" }, { stream: true })
430
628
  for await (const chunk of res.body) render(chunk) // the provider's SSE, byte for byte
431
629
 
432
- const answer = await g.ai.text({ messages: [{ role: "user", content: text }] }, { tool: "deep-research" })
630
+ const answer = await g.ai.runText("deep-research", { question: text })
631
+ const { calls } = await g.ai.calls() // the person's own history
632
+
633
+ // RAW — only behind the owner's "raw calls" switch for that provider key:
634
+ const raw = await g.ai.chat({ model: "gpt-4o-mini", stream: true, messages: [{ role: "user", content: text }] })
433
635
  ```
434
636
 
435
637
  | code | status | meaning · do |
436
638
  |------|--------|--------------|
639
+ | `raw_calls_off` | 403 | The browser may not compose provider requests for this provider — call a named tool with `g.ai.run`, or the owner switches raw calls on for the key on the AI tools page |
640
+ | `invalid_inputs` | 400 | An input is unknown, missing, the wrong type or over its cap — the message names it |
641
+ | `tool_incomplete` | 409 | The tool composes nothing (no template, no inputs) — give it one; the owner's or the file's fix |
437
642
  | `unknown_tool` | 404 | No AI tool by this name in this environment — check the slug or the environment |
438
643
  | `tool_disabled` | 403 | The owner switched this tool off — turn it on, or use another |
439
644
  | `entitlement_required` | 403 | The tool's `requires` names a plan or product this person lacks — the message names it |
440
645
  | `model_pinned` | 403 | This tool's model is fixed — leave `model` out of the body |
441
- | `provider_not_configured` | 409 | Creating or updating a tool: no key is set for that provider yet — add one on the Keys page first |
442
646
  | `too_many_tools` | 409 | This environment already holds 50 AI tools — delete one before adding another |
443
647
  | `invalid_tool` | 400 | Creating or updating a tool with a bad field — the message names which one and its rule |
444
648
  | `credits_exhausted` | 402 | The person's balance is below the tool's price — the message names the tool, the price and the balance ("Deep Research costs 20 credits. You have 7.", singular for 1). Show the pack |
445
- | `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one in the Keys room |
649
+ | `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one on the AI tools page. A tool can be defined before its key exists; it runs the moment the key is pasted, and the console shows it as waiting on one until then |
446
650
  | `provider_required` | 400 | More than one provider key is set — pass `provider` |
447
651
  | `model_not_allowed` | 403 | The owner's allowlist names the models this app may call; the message lists them (only for a tool with no pinned model) |
448
652
  | `ai_capped` | 429 | 20 calls per person per minute — wait for `resetAt` |
@@ -451,13 +655,14 @@ const answer = await g.ai.text({ messages: [{ role: "user", content: text }] },
451
655
  | `session_required` | 401 | No signed-in person — sign in first |
452
656
  | `scope_denied` | 403 | A secret key called the route — the route is for the browser; a server calls the provider directly |
453
657
  | `provider_unreachable` | 502 | The provider did not answer before the first byte — nothing was charged (the tool's credits are refunded); retry |
454
- | `provider_error` | the provider's | `g.ai.text` only (client-side): the provider's own non-2xx, its message in `err.message` |
455
- | `ai_test_capped` | 429 | The Keys room's test callone a minute per app |
658
+ | `provider_error` | the provider's | `g.ai.text` and `g.ai.runText` (client-side): the provider's own non-2xx, its message in `err.message` |
659
+ | `invalid_response` | 0 (client-side) | `g.ai.text` and `g.ai.runText`: a non-stream answer with no text to lift out for a streaming body read the stream with `g.ai.chat` or `g.ai.run` |
660
+ | `ai_test_capped` | 429 | The AI tools page's test call — one a minute per app |
456
661
 
457
662
  `gemmein dev` answers a fake provider without a key (header `x-gemmein-ai:
458
663
  fake`, an echo stream), so the loop runs locally; set
459
664
  `GEMMEIN_AI_KEY_OPENAI`, `GEMMEIN_AI_KEY_ANTHROPIC` or `GEMMEIN_AI_KEY_GOOGLE`
460
- in the local rail's environment for a real call. The owner's Usage room
665
+ in the local rail's environment for a real call. The owner's Usage & billing page
461
666
  counts AI calls for the last 30 days: spent by the customers' credits, priced
462
667
  by the provider — Gemmein meters the calls, the provider bills the tokens.
463
668
 
@@ -477,6 +682,32 @@ signed in, it can create them by email first.
477
682
  | `grantAccess` | `(personId, { entitlement, source?, expiresAt?, reason? })` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` (201) — `holdings` is the state **after**; the owner's audit row carries before→after and the key's name |
478
683
  | `revokeAccess` | `(personId, grantId, { reason? }?)` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` — the returned grant carries `revokedAt` |
479
684
  | `invitePerson` | `(email)` | `Promise<{ person: InvitedPerson, created: boolean }>` — create a person by email **before they sign in** (201 `created: true`), or find them (200 `created: false`); idempotent, case-insensitive, one id. `InvitedPerson = { id, email, role, invited, suspended }` — `invited` stays true until their first sign-in; a suspended person is returned flagged. The one server call that takes an email |
685
+ | `notify` | `(personId, { subject, text, kind?, key? })` | `Promise<{ sent, deduped?, recorded?, id, threadId, replyRail? }>` — email one of your verified people by id; see **Notify** above |
686
+ | `spendCredits` | `(personId, { amount?, reason, key? })` | `Promise<{ ok, spent, deduped, balance: { before, after }, event }>` — one conditional decrement, floored at zero; see **Credits** above |
687
+ | `collection(name).get / .list / .update` | `(as the browser client)` | scoped per collection on the key; no creates, no deletes, no auth access; see **Setup** |
688
+ | `testSession` | `(email)` | `Promise<{ token, expiresAt, user }>` — development only; `sk_live` throws `test_session_forbidden_live`; see **Reaffirm** below |
689
+
690
+
691
+ **Swift.** `GemmeinServer` ships in the Swift package for Swift that runs on a
692
+ server. It takes a secret key, and a secret key never belongs in an app bundle
693
+ — `Gemmein` refuses one.
694
+
695
+ | Swift | Signature | Returns |
696
+ |---|---|---|
697
+ | `verifySession(_:)` | `func verifySession(_ token: String) async throws -> PersonHoldings` | `PersonHoldings { person: GatePerson, holdings: Holdings }` — no `ok` field; a refusal throws |
698
+ | `holdings(_:)` | `func holdings(_ personId: String) async throws -> PersonHoldings` | `PersonHoldings` |
699
+ | `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 }` |
700
+ | `revokeAccess(_:_:reason:)` | `@discardableResult func revokeAccess(_ personId: String, grantId: String, reason: String? = nil) async throws -> GrantResult` | `GrantResult` |
701
+ | `invitePerson(_:)` | `func invitePerson(_ email: String) async throws -> InviteResult` | `InviteResult { person: GatePerson, created: Bool }` |
702
+ | `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 |
703
+ | `spendCredits(_:amount:reason:key:)` | see **Credits** above | `SpendCreditsResult` |
704
+ | `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]) async throws -> GemmeinRecord` |
705
+ | `testSession(_:)` | `func testSession(_ email: String) async throws -> AuthSession` | `AuthSession` — development only |
706
+
707
+ `GatePerson { id: String, email: String, role: String, suspended: Bool?, invited: Bool? }`,
708
+ `Holdings { access: [String], grants: [Grant], credits: Int? }` — a bare `Int?`
709
+ where JS nests `{ balance }`. `ManualGrantSource` is `.manual`, `.trial`,
710
+ `.promotion`, `.migration`; `NotifyKind` is `.event` or `.account`.
480
711
 
481
712
  ```ts
482
713
  type Holdings = {
@@ -530,7 +761,7 @@ type Grant = {
530
761
  make two grants — call it once and keep your own retry key.
531
762
  - **Nothing silent, within a stated bound.** Every `/server/*` call a **resolved
532
763
  secret key** makes, ok or refused, lands in that key's usage ledger — the owner
533
- reads the summary on their Keys page and the full day × route × outcome table in
764
+ reads the summary on their Secret keys page and the full day × route × outcome table in
534
765
  the key's own room. A rejected or publishable (`pk_`) key can't be attributed to a
535
766
  key row, so its refusal reaches only the request log. Refusals also write one audit
536
767
  row per key, route and code each hour (exact counts stay in the ledger); grants and
@@ -569,8 +800,10 @@ provider's webhook arriving, a clock, a record changing — and one to ten of
569
800
  Gemmein's **own** verbs, run in order: write a record, grant or revoke access,
570
801
  email the person, call your URL. Gemmein runs no code of yours inside one;
571
802
  compute lives on your host, behind `call_url`. The definition is a JSON file
572
- your AI writes; the owner's dashboard shows it read-only with its receiver URL,
573
- its secrets (shown once), every event with each action's result, and a replay
803
+ your AI writes, or the owner writes in the dashboard, which creates and edits
804
+ it too, pauses, resumes, rotates and deletes it, and shows where it came from
805
+ ("from file, synced <when>" / "edited here <when>"), its receiver URL, its
806
+ secrets (shown once), every event with each action's result, and a replay
574
807
  button. There is no SDK method: the surface is the file and the dashboard.
575
808
  Stripe stays built in; any provider that signs its webhooks — GoCardless, Paddle,
576
809
  Lemon Squeezy among them — drives access the same way through a relay, and the
@@ -673,7 +906,7 @@ authorise on fields they cannot set, or from a receiver.
673
906
  | `revoke_access` | `entitlement` | ends every live grant of that entitlement the person holds; skipped when none |
674
907
  | `fulfil_product` | `product` (name, 1-40 chars), `ref?` (≤ 200, templated) | grants the product's key and credits to the event's person and writes the receipt, exactly as a Stripe purchase does; idempotent on `ref` (an absent `ref` → `rly:<eventId>`; a `ref` you wrote that names nothing on the event is refused, never replaced); a ref must be the provider's unique payment identifier; a ref already used by another purchase is refused and audited; the product's road must be this relay. The relay road binds by name: renaming or deleting the relay stops fulfilment until a relay with that name exists again; the product card shows it. |
675
908
  | `refund_product` | `product` (name, 1-40 chars), `ref?` (≤ 200, templated) | takes back what `fulfil_product` under the same `ref` granted; `refund_product` may run from any relay in the environment; it refunds only a purchase a relay fulfilled, for the event's person and the product it names; idempotent on `ref` |
676
- | `email_person` | `subject` (≤ 300), `text` (≤ 10,000), `kind?: "event" \| "account"` | rides `notify()`'s caps (200 per app per hour, 5 event sends per person per day) and the owner's sends switch; deduped per event |
909
+ | `email_person` | `subject` (≤ 300), `text` (≤ 10,000), `kind?: "event" \| "account"` | rides `notify()`'s caps (200 per app per hour, 5 event sends per person per day) and the owner's sends switch; deduped per event; and rides `notify()`'s sender law — it goes only from your verified sender domain, and until one is verified this action alone is refused `sender_domain_required` and the event's result says so. Nothing is sent via Gemmein |
677
910
  | `call_url` | `url` | https only, no template in the URL, no IP literal, never a gemmein.com host, no credentials in the URL; `gemmein dev` allows http to localhost |
678
911
 
679
912
  **The `call_url` contract.** Gemmein POSTs JSON:
@@ -760,7 +993,16 @@ receiver's local URL), schedules and data changes with the same runner and
760
993
  prints `RELAY · <name> · <trigger> · <n actions> · ok|failed`;
761
994
  `npx gemmein sync` carries the files to the cloud app's development
762
995
  environment with the collections — contract, never data; the cloud mints its
763
- own secrets.
996
+ own secrets. After go-live, `npx gemmein sync --live` carries relays and AI
997
+ tools into production: it asks for a **sync key** (Secret keys → production →
998
+ Sync key — step-up to mint, lives one hour, shown once, never saved by the
999
+ CLI), prints what would change, waits for the word `live`, never deletes, and
1000
+ refuses collections (they move by promotion). A row edited in the dashboard
1001
+ since the last sync is asked about, per item (`--overwrite` answers yes);
1002
+ every relay says where its definition came from — from file, or edited in the
1003
+ dashboard — and when. A relay's name is fixed once created: the receiver URL
1004
+ is built from it, so a rename is refused (`invalid_definition`) — create the
1005
+ new one, delete the old in the dashboard once the provider has moved.
764
1006
 
765
1007
  ---
766
1008
 
@@ -793,7 +1035,10 @@ line; a wrong-but-well-formed name surfaces as `unknown_collection` and exits
793
1035
 
794
1036
  ### `gemmeinServer(sk).testSession(email) → { token, expiresAt, user }`
795
1037
  **Dev only** — throws `test_session_forbidden_live` on an `sk_live` key, and
796
- the server refuses it on a live environment too. Pass the `token` to
1038
+ the server refuses it on a live environment too. Test **people** only: a
1039
+ server key is refused the account owner's or an admin's email
1040
+ (`scope_denied`) — the app's CLI key (Setup page) is the credential that
1041
+ acts as the founder, and it is the only key that links or syncs. Pass the `token` to
797
1042
  `gemmein(pk, { tokenStore })` to act as that user. Dev and live enforce the
798
1043
  *same* rules, so what is proven in dev holds in live.
799
1044
 
@@ -817,6 +1062,102 @@ current. Add a probe whenever you add a feature.
817
1062
 
818
1063
  ---
819
1064
 
1065
+ ## Swift — the package
1066
+
1067
+ `GemmeinSwift` is the same HTTP contract and the same method names as
1068
+ `@gemmein/sdk`, in Swift idiom, for iOS 17+ and macOS 14+, with no
1069
+ dependencies:
1070
+
1071
+ ```swift
1072
+ .package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.10.0")
1073
+ ```
1074
+
1075
+ Every method is beside its JavaScript twin in the sections above, under
1076
+ **Swift**. Argument labels are as written there; everything that leaves the
1077
+ device is `async throws` and throws `GemmeinError`. The parity check
1078
+ (`tests/security/sdkParity.test.ts`) holds the two method lists equal and
1079
+ `tests/security/docsSwiftReference.test.ts` holds every signature on this page
1080
+ to the package's own source, so a signature here is the signature the compiler
1081
+ sees.
1082
+
1083
+ ```swift
1084
+ let g = try Gemmein(appKey: "pk_live_…")
1085
+ let notes = try g.collection("notes")
1086
+ let page = try await notes.list(ListOptions(limit: 20, sort: .newest))
1087
+ let note = try await notes.create(["title": .string(typed), "done": false])
1088
+ ```
1089
+
1090
+ ### The client
1091
+
1092
+ | Swift | Signature | Returns |
1093
+ |---|---|---|
1094
+ | `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_` |
1095
+ | `g.collection(_:intent:)` | `func collection(_ name: String, intent: String? = nil) throws -> CollectionClient` | `CollectionClient` — `g.storage.collection(_:intent:)` is the same factory, the same signature |
1096
+ | `GemmeinServer(secretKey:…)` | `init(secretKey: String, apiURL: URL = defaultAPIURL, session: URLSession = .shared) throws` | `GemmeinServer` — see **Server gate** above |
1097
+
1098
+ `apiURL` points at a local `gemmein dev` engine while you build; `tokenStore`
1099
+ defaults to `KeychainTokenStore`, so a relaunch keeps the person signed in;
1100
+ `platform` defaults to this OS and is appended to `x-client-info`.
1101
+ `g.auth`, `g.storage`, `g.subscriptions`, `g.payments`, `g.purchases`,
1102
+ `g.account`, `g.files`, `g.credits`, `g.ai` and `g.tokenStore` are `public let`
1103
+ properties on `Gemmein`.
1104
+
1105
+ ### The three shapes that differ from JavaScript
1106
+
1107
+ - `credits.balance()` answers a bare **`Int`** — the number itself, not a
1108
+ `{ balance }` wrapper.
1109
+ - `files.link(_:)` answers a `FileLink` whose `url` is a **`String`**; pass it
1110
+ through `URL(string:)` before `AsyncImage`.
1111
+ - A record body and an AI body are `[String: JSONValue]`. A **literal** needs
1112
+ no ceremony (`["title": "Ran", "done": false, "seats": 4]`); a **variable**
1113
+ needs its case (`["title": .string(typed), "seats": .int(count)]`).
1114
+
1115
+ ### The session store
1116
+
1117
+ `TokenStore` is a protocol — `func get() async -> String?`,
1118
+ `func set(_ token: String) async throws`, `func clear() async`.
1119
+ `KeychainTokenStore(appKey:)` is the default and `MemoryTokenStore(token:)` is
1120
+ there for tests and for the app that catches `secure_store_unavailable`.
1121
+ Only `set` throws, on all three of the SDK's stores.
1122
+
1123
+ Everything the package throws is a `GemmeinError`
1124
+ (`status: Int`, `code: String`, `message: String`, `resetAt: String?`,
1125
+ `requires: String?`) — the same table below.
1126
+
1127
+ ---
1128
+
1129
+ ## Expo — `@gemmein/sdk/expo`
1130
+
1131
+ The same package, a second entry point. `export *` re-exports the whole core
1132
+ surface, so an Expo app imports from one place; what follows is everything the
1133
+ entry adds. The peers install in the app, not in your monorepo:
1134
+ `npx expo install expo-secure-store expo-file-system`.
1135
+
1136
+ ```ts
1137
+ import { createExpoGemmein } from "@gemmein/sdk/expo"
1138
+ import { AppState, Platform } from "react-native"
1139
+
1140
+ export const g = createExpoGemmein({ appKey: "pk_live_..." }, { AppState, Platform })
1141
+ ```
1142
+
1143
+ | Export | Signature | What it is |
1144
+ |---|---|---|
1145
+ | `createExpoGemmein` | `(options: GemmeinOptions, modules?: ExpoModules) => Gemmein` | The client. It **is** `Gemmein` — the four seams a phone has, filled in, plus the picker conversion in front of every `upload()`. Anything you pass in `options` wins over the defaults |
1146
+ | `SecureStoreTokenStore` | `new SecureStoreTokenStore(appKey: string, secureStore?: SecureStoreModule)` | The `TokenStore` backed by `expo-secure-store` — `get(): Promise<string \| undefined>`, `set(token: string): Promise<void>`, `clear(): Promise<void>`. Keyed `gemmein_session_<first 20 of the 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` |
1147
+ | `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 |
1148
+ | `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 is what `g.ai.chat({ stream: true })` and `g.ai.run(…, { stream: true })` return |
1149
+ | `expoPlatformTag` | `(platform?: PlatformModule) => string` | The `platform` string on `x-client-info`: `expo-ios`, `expo-android`, `expo-web`, or `expo` when no `Platform` was passed |
1150
+ | `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` |
1151
+ | `SECURE_STORE_VALUE_LIMIT` | `2048` | The byte ceiling on a secure-store value. Over it, `set` throws `token_too_large` rather than letting iOS throw a native error the app cannot read |
1152
+ | `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 there is no dynamic resolution at all |
1153
+
1154
+ `SecureStoreModule`, `AppStateModule`, `PlatformModule` and `FileSystemModule`
1155
+ are the exported types of those four slices — the little of each module this
1156
+ entry calls, and nothing more. Nothing is imported from Expo or React Native at
1157
+ module load, so the entry resolves in Node too.
1158
+
1159
+ ---
1160
+
820
1161
  ## Errors — `GemmeinError`
821
1162
 
822
1163
  Every failed call throws a `GemmeinError`:
@@ -844,6 +1185,9 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
844
1185
  | `entitlement_required` | 403 — signed in, but not on a plan (or holding a product) this collection is unlocked by; `err.requires` is that plan's key (`access:<slug of its name>`) | show your upgrade screen and send them to checkout — the one 403 that succeeds later |
845
1186
  | `not_found` | record you can't see (existence not leaked) | treat as absent |
846
1187
  | `denied` | 401 (sign in first) or 429 (rate limit — see `resetAt`) | re-auth or wait+retry |
1188
+ | `invalid_body` | 400 — the request body is not a JSON object (an array or a bare value was sent) | send an object |
1189
+ | `missing_params` | 400 — a required field is absent or EMPTY; the message names it (`missing email`, `missing code`). The SDK passes what you give it, so a blank form field reaches the API as missing | check the field before calling — a blank email is not a sign-in attempt |
1190
+ | `field_too_long` | 400 — a sign-in field (`email`, `code`, the test-session fields) is over 500 characters; the message names the field | shorten it |
847
1191
  | `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
848
1192
  | `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
849
1193
  | `invalid_publish` | `{ published }` on a non-public rule | drop it |
@@ -853,36 +1197,43 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
853
1197
  | `invalid_limit` | `limit` isn't a whole number from 1 to 100 (the message says so: "limit must be between 1 and 100") | ask for at most 100 and page with `cursor` — nothing is clamped for you |
854
1198
  | `not_a_customer` (404) | `notify()`'s recipient isn't a verified person of this app and environment | fix the person id — one code on purpose |
855
1199
  | `in_flight` (409) | a `notify()` with the same `key` is sending right now | retry in a moment — a delivered send answers idempotently |
1200
+ | `sender_domain_required` (409) | `notify()` (and a relay's `email_person`) with no verified sender domain — your app's mail goes only from your own domain | verify a sender domain on the Domains page, then retry with the same key. Nothing was sent, recorded or capped |
856
1201
  | `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
857
1202
  | `unknown_record` | a link field points at a missing record (live only) | fix the id |
858
- | `payload_too_large` / `file_too_large` | over the size cap (message states it) | shrink it |
1203
+ | `payload_too_large` / `file_too_large` | over the size cap (message states it): a record's data over 32 KB, an upload over 25 MB | shrink it — files go through uploads, never inline |
859
1204
  | `invalid_file_content` | uploaded bytes aren't the claimed type (usually a renamed file) | send the real file |
860
1205
  | `test_session_forbidden_live` | `testSession()` called on a live env / `sk_live` key | reaffirm's Tier B is dev-only — point it at your dev environment |
861
1206
  | `unknown_plan` | no plan by that name | use a name from the list in the message |
862
1207
  | `plan_not_purchasable` | tried to check out the free default plan | nothing to buy — gate on the paid plan's name |
863
1208
  | `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
864
- | `scope_denied` | secret key used outside its dashboard-configured scope (or on auth/management routes) | scope the key to that collection, or use the right surface |
1209
+ | `scope_denied` | secret key used outside its dashboard-configured scope (or on auth/management routes); a server key asked to sign in as the account owner or an admin, or to link/sync; a sync key asked for anything but relays and AI tools | scope the key to that collection, or use the right surface — linking and dev sync use the CLI key (Setup), production sync a Sync key (Secret keys → production) |
1210
+ | `secret_key_expired` (403) | a sync key past its hour — the message names the instant | mint a new Sync key (Secret keys → production) and run `npx gemmein sync --live` again |
865
1211
  | `unsupported_file_type` (415) | upload isn't an allowed type | images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
866
1212
  | `invalid_key` | a keyed create's `key` breaks the charset/length law — or `spendCredits`' `key` is not text of up to 200 chars | 1-120 chars of letters, numbers, `: _ . @ / -` (a create); text ≤ 200 (a spend) |
867
1213
  | `invalid_amount` / `invalid_reason` (400) | `spendCredits`: `amount` outside 1..10,000, or `reason` missing / over 200 chars | fix the field the code names |
868
1214
  | `dedupe_conflict` (409) | `spendCredits`: the `key` already names a different movement (another kind or another person) | a key is one movement — reuse it only to retry that same one |
869
1215
  | `credits_ceiling` (409) | a credit would carry the balance past 1,000,000,000 (a comp, a pack, a relay grant) — nothing was added | the balance is at its most |
870
1216
  | `invalid_body` (400) | on `g.ai.chat`: the body is not the provider's JSON request object, or is nested deeper than 32 levels | send the provider's own request object |
871
- | `provider_error` (client-side, the provider's status) | `g.ai.text`: the provider answered a non-2xx; `err.message` is the provider's own reason | read it — the credit was refunded when the provider failed before its first byte |
872
- | `ai_test_capped` (429) | the Keys room's test call — one a minute per app | wait a minute |
1217
+ | `provider_error` (client-side, the provider's status) | `g.ai.text` and `g.ai.runText`: the provider answered a non-2xx; `err.message` is the provider's own reason | read it — the credit was refunded when the provider failed before its first byte |
1218
+ | `ai_test_capped` (429) | the AI tools page's test call — one a minute per app | wait a minute |
873
1219
  | `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
874
- | `invalid_response` (client-side, status 0) | the server answered 200 to `verifyEmailCode` without a session token — a proxy or mock in the path, not Gemmein | check `apiUrl` and anything rewriting responses; the call is safe to retry |
1220
+ | `invalid_response` (client-side, status 0) | the server answered 200 to `verifyEmailCode` without a session token — a proxy or mock in the path, not Gemmein; or `g.ai.text` / `g.ai.runText` got a non-stream provider answer with no text to lift out | check `apiUrl` and anything rewriting responses; the call is safe to retry — for AI, read the stream with `g.ai.chat` or `g.ai.run` |
1221
+ | `network_unreachable` (client-side, status 0) | the request never reached Gemmein — no connection, an offline device, a host that does not resolve, an `apiUrl` pointing at nothing; the message names the host, and `err.cause` carries the fetch's own error. A cancel (`{ signal }`) is not this: it stays an `AbortError` | check the connection and the `apiUrl`; the call is safe to retry. "The server said no" and "the server was never reached" are different problems — branch on them separately |
1222
+ | `upload_input_unsupported` (client-side, status 0) | `@gemmein/sdk/expo`: `upload()` was handed a picker's `{ uri }` and `expo-file-system` is not installed, so there is nothing to read the bytes with | `npx expo install expo-file-system`, or pass a `Blob` / an `expo-file-system` `File` |
1223
+ | `token_too_large` (client-side, status 0) | `@gemmein/sdk/expo`: a value over 2,048 bytes was handed to the device secure store, which iOS refuses with a native throw the app cannot read | store the token, not the session payload — a Gemmein session token is two orders of magnitude smaller |
1224
+ | `secure_store_unavailable` (client-side, status 0) | the token store could not KEEP the session, so sign-in throws rather than handing back a session the next launch will lose. **All three stores.** `BrowserTokenStore` (`@gemmein/sdk`): the browser refused the `localStorage` write — private mode past its quota, site data blocked, a sandboxed iframe. `SecureStoreTokenStore` (`@gemmein/sdk/expo`): `expo-secure-store` is not installed, or the device refused the write. Swift `KeychainTokenStore`: the Keychain refused, and the OSStatus is in the message; `-34018` is an unsigned build with no keychain access group. The store's own error is on `err.cause` (in Swift, in the message). Reads and clears stay lenient on all three — an unreadable store means signed out, never a crash | install `expo-secure-store`, or sign the app (ad-hoc is enough). The session is real — the server minted it — so an app that would rather run than stop catches this one code and rebuilds its client with a `MemoryTokenStore`: signed in until the process ends |
1225
+ | `invalid_collection_name` (client-side, status 0) | `g.collection(name)` was given a name outside the grammar: lowercase letters, digits and underscores, starting with a letter, 2-63 characters (`tasks`, `user_notes`). Thrown synchronously, before any request exists — the message echoes what it got | fix the name. A collection that exists but is not reachable is a different code (`unknown_collection`, 404) |
875
1226
  | `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
876
1227
  | `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
877
1228
  | `product_not_sellable` (409) | `g.payments.buy` on a product sold via a relay or not yet — there is no Payment Link to open | tell the buyer how the product is sold, or ask the owner to wire a road |
878
1229
  | `relay_missing` (400) | saving a relay action (or a product's `sold.relay`) names a relay that does not exist in this environment | create that relay first, or name one that exists |
879
1230
  | `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
880
1231
  | `credits_exhausted` (402) | the person's balance is below the spend — the message carries the balance ("this person has {balance} credits — the spend needs {amount}") | show the pack; never retry the same spend |
881
- | `ai_not_configured` (409) | no provider key on this app and environment | the owner pastes one in the Keys room |
1232
+ | `ai_not_configured` (409) | no provider key on this app and environment | the owner pastes one on the AI tools page |
882
1233
  | `provider_required` (400) | more than one provider key is set and the call named none | pass `provider` |
883
1234
  | `model_not_allowed` (403) | the owner's allowlist does not name this model | use one the message lists |
884
1235
  | `ai_capped` (429) | 20 AI calls per person per minute | wait for `resetAt` |
885
- | `payload_too_large` (413) | on `g.ai.chat`: the body is over 256 KB | shorten the conversation you send |
1236
+ | `payload_too_large` (413) | on `g.ai.chat`: the body is over 256 KB; on `g.ai.run`: the inputs are over 64 KB; on a record write: the data is over 32 KB | shorten the conversation you send; send less; move files to uploads |
886
1237
  | `session_required` (401) | `g.ai.chat` / `g.credits.balance` without a signed-in person | sign in first |
887
1238
  | `scope_denied` (403) | on `g.ai.chat`: a secret key called the route | the route is for the browser — a server calls the provider directly |
888
1239
  | `provider_unreachable` (502) | the provider did not answer before the first byte; the credit is refunded | retry |