@gemmein/sdk 0.7.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -12,6 +12,79 @@ the same release.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.9.0] — 2026-09-07
16
+
17
+ ### Keys that know what they are, production sync, and AI tools defined on the server (engine 0.10.0, sdk 0.9.0, mcp 0.6.0)
18
+
19
+ - **`g.ai.run(tool, inputs, { stream? })` / `g.ai.runText(tool, inputs)`** — call a
20
+ named AI tool with inputs only; the server composes the provider request
21
+ from the tool's own instructions and template, gates it, spends its
22
+ credits and streams the answer back in the provider's own shape.
23
+ Refusals: `invalid_inputs` (400), `tool_incomplete` (409), plus the tool
24
+ codes `g.ai.chat` already answered.
25
+ - **`g.ai.calls()`** — the signed-in person's own AI calls, newest first,
26
+ keyset-paged (`AiCallRecord`): tool, provider, model, tokens, credits,
27
+ outcome, latency, and the prompt and answer where the tool keeps them.
28
+ - **Raw calls are off by default.** `g.ai.chat(body)` answers
29
+ `403 raw_calls_off` unless the owner switches raw calls on for that
30
+ provider's key on the AI tools page. Migration note: an app built on
31
+ `g.ai.chat` needs that switch, or a named tool through `g.ai.run`.
32
+ - **Server keys cannot sign in as the account owner or admin.**
33
+ `gemmeinServer(sk).testSession(email)` refuses those two emails with
34
+ `scope_denied`; test people sign in as before. Linking and dev sync use
35
+ the app's CLI key (`sk_cli_…`, Setup page); production sync uses a
36
+ one-hour Sync key (`sk_sync_…`). `secret_key_expired` (403) names an
37
+ expired sync key's instant.
38
+ - CLI: `npx gemmein sync` links with ONE paste (the CLI key); `npx gemmein
39
+ sync --live` carries relays and AI tools into production behind a sync
40
+ key and the typed word `live`, never deletes, asks before replacing a
41
+ row edited in the dashboard (`--overwrite`). A relay's name is fixed once
42
+ created. Tool files gain `kind`, `instructions`, `promptTemplate`,
43
+ `inputs`, `recordCalls`.
44
+
45
+ ## [0.8.0] — 2026-09-05
46
+
47
+ ### Products are provider-neutral (engine 0.9.0, sdk 0.8.0, mcp 0.5.0)
48
+
49
+ A product is sold one of three ways, set on the Payments page ("How it's
50
+ sold" in the console, `sold` in the CLI): via a Stripe Payment Link, via a
51
+ relay (any provider whose webhook the founder maps — GoCardless, Lemon
52
+ Squeezy, Paddle, bank transfer), or not yet (defined, its grants and
53
+ credits known, no road wired). Two new relay actions carry the relay
54
+ road: `fulfil_product { product, ref? }` grants the product's key and
55
+ credits to the event's person and writes the receipt, exactly as a
56
+ Stripe purchase does; `refund_product { product, ref? }` takes them
57
+ back. Both are idempotent on `ref`. `g.payments.buy` on a product sold
58
+ via a relay or not yet answers 409 `product_not_sellable`; saving a
59
+ relay action or a product's relay road against a relay that does not
60
+ exist in this environment answers 400 `relay_missing`.
61
+
62
+ ### The AI tool primitive (engine 0.9.0, sdk 0.8.0, mcp 0.5.0)
63
+
64
+ The AI route now runs named, priced tools instead of one flat rate. An
65
+ owner creates an `ai_tools` row (console Tools table, or `PUT
66
+ /internal/ai/tools`; the CLI's `gemmein ai tools` lists, sets and removes
67
+ them, and the local rail reads `gemmein/ai/tools/<name>.json`) — a slug,
68
+ a label, a provider, an optional pinned model, a credit price (1 to
69
+ 10,000), an optional `requires` entitlement key, and optional bounds
70
+ (`maxBodyBytes`, `maxOutputTokens`, `stream`). `g.ai.chat` and `g.ai.text`
71
+ take a new `tool` option naming one; a call with no `tool` runs as the
72
+ **default tool** — one credit, the app's configured provider, any allowed
73
+ model — so every existing integration keeps working unchanged. Up to 50
74
+ tools per environment.
75
+
76
+ New codes: `unknown_tool` (404 — no tool by that name), `tool_disabled`
77
+ (403 — the owner switched it off), `entitlement_required` (403 — the
78
+ message names the plan or product the tool requires), `model_pinned`
79
+ (403 — the tool's model is fixed; leave `model` out of the body),
80
+ `provider_not_configured` (409 — creating a tool on a provider with no
81
+ key set), `too_many_tools` (409 — 50 tools per environment) and
82
+ `invalid_tool` (400 — a bad field creating or updating a tool).
83
+
84
+ A response from a named tool now carries `x-gemmein-tool` — the tool's slug —
85
+ alongside `x-gemmein-credits-remaining`. `x-gemmein-tool` names the tool;
86
+ absent on the implicit default.
87
+
15
88
  ## [0.7.0] — 2026-09-04
16
89
 
17
90
  ### Credits and the AI route
package/README.md CHANGED
@@ -292,7 +292,9 @@ try {
292
292
  |--------|------------|----------------|
293
293
  | `pk_test_...` | Development | Frontend code — safe to expose |
294
294
  | `pk_live_...` | Production | Frontend code — safe to expose |
295
- | `sk_dev_...` / `sk_live_...` | Dev / Prod | Server env vars only |
295
+ | `sk_dev_...` / `sk_live_...` | Dev / Prod | Server env vars only — runs your app; never links or syncs |
296
+ | `sk_cli_...` | Development | The CLI key, shown on Setup: what `gemmein sync` and `gemmein go-live` act with. Lives in `gemmein/.data/`, never in git |
297
+ | `sk_sync_...` | Production | A one-hour sync key (Secret keys → production): `gemmein sync --live` carries relays and AI tools into production. Pasted, used, never saved |
296
298
 
297
299
  Environments are fully isolated: different data, different users, different collections.
298
300
 
package/REFERENCE.md CHANGED
@@ -238,6 +238,16 @@ Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
238
238
  don't also redirect to the returned `url`, and never build a Stripe URL
239
239
  yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
240
240
  gate one-off fulfilment on the receipt record, never the redirect.
241
+
242
+ A product is sold one of three ways, set on the Payments page: **via a
243
+ Stripe Payment Link** (`buy` navigates there); **via a relay** — any
244
+ provider whose webhook the founder maps (GoCardless, Lemon Squeezy,
245
+ Paddle, bank transfer) fulfils it with the `fulfil_product` relay
246
+ action, and refunds it with `refund_product`; or **not yet** — the
247
+ product is defined, its grants and credits are known, no road wired.
248
+ `buy` on a product sold via a relay or not yet answers 409
249
+ `product_not_sellable`. The price always comes from the provider — the
250
+ Payment Link or the relay — never the app.
241
251
  By-hand grants (trial, promotion, a support comp) are not listed to the
242
252
  app — a gated read simply succeeds — so never rebuild the paywall from
243
253
  `mine()`; let the server refuse with `entitlement_required`. The two grant
@@ -274,7 +284,7 @@ honestly, not hidden.)
274
284
  ## Credits — `g.credits` / `gemmeinServer(sk).spendCredits`
275
285
 
276
286
  A balance your customers hold and your product spends: a pack they buy, a
277
- comp the owner gives, a call that costs one. Credits are a **quantity beside
287
+ comp the owner gives, an AI tool call priced in credits. Credits are a **quantity beside
278
288
  access**, never access itself — a locked collection still asks for the
279
289
  entitlement, whatever the balance. The ledger adds at every purchase, grant
280
290
  and refund, subtracts at every spend, and never goes below zero: a spend
@@ -302,8 +312,9 @@ Where credits come from, and where they go:
302
312
  ledger lines and the credits the person spent in the last 30 days.
303
313
  - **A relay** adds with `grant_credits { amount (1..10,000), reason? }` — see
304
314
  **Relays**.
305
- - **Your server** spends with `spendCredits`; **the AI route** spends one per
306
- call (see **AI**). Nothing spends from the browser.
315
+ - **Your server** spends with `spendCredits`; **the AI route** spends the
316
+ credits the owner set for the named tool, one by default (see **AI**).
317
+ Nothing spends from the browser.
307
318
  - Spend lines are kept 90 days on the cloud rail; purchases, grants and
308
319
  clawbacks are kept. A balance carries at most 1,000,000,000. Account
309
320
  erasure removes the person's balance and ledger.
@@ -342,73 +353,171 @@ connected.
342
353
 
343
354
  ---
344
355
 
345
- ## AI — `g.ai.chat` / `g.ai.text`
356
+ ## AI — `g.ai.run` / `g.ai.runText` / `g.ai.calls` (and the raw `g.ai.chat` / `g.ai.text`)
346
357
 
347
358
  Your app talks to **OpenAI, Anthropic or Google** through Gemmein, on the
348
- **owner's own provider key**, which never reaches the browser. The owner
349
- pastes the key once in the dashboard's Keys room; it is write-only from then
350
- on. A signed-in person's request costs **one credit**, spent before the
351
- request is forwarded; the provider bills the tokens on the owner's own
352
- account. The route forwards the provider's own request body as sent — minus
353
- the `provider` field, and for Google minus `model` and `stream`, which ride
354
- the URL adds the provider's auth headers, `content-type` and `accept`, and
355
- passes the status and the bytes straight back a stream stays a stream. It does not choose models, cache, summarise,
356
- moderate or reshape anything, and it is for the browser only: a server key is
357
- refused (`403 scope_denied`) a server calls the provider directly.
359
+ **owner's own provider key**, which never reaches the browser, through a
360
+ **named AI tool whose definition lives on the server** (W9.6): the
361
+ instructions (system prompt), the prompt template, the inputs it accepts,
362
+ the pinned model, the caps beside the price in credits and the
363
+ entitlement gate. The app sends a **name and inputs**; the server composes
364
+ the provider request in the provider's own grammar, gates it, spends the
365
+ tool's credits, runs it and streams the answer back. The owner prices and
366
+ gates the tool on the dashboard's AI tools page; the implementation is
367
+ written there too, or as a file at `gemmein/ai/tools/<name>.json` that
368
+ `npx gemmein sync` carries (`--live` into production with a sync key). A
369
+ file carries at least `label`, `provider` and `credits` (its name is the
370
+ file name). On every sync the file's implementation (`provider`, `model`,
371
+ `kind`, `instructions`, `promptTemplate`, `inputs`, `bounds`) applies; its
372
+ commerce values (`label`, `credits`, `requires`, `enabled`, `recordCalls`)
373
+ apply once, at creation — after that the dashboard owns them. In
374
+ development, sync also writes a tool that exists only in the cloud back to
375
+ `gemmein/ai/tools/` as a file; in production such a row is left as it is.
376
+ A tool's name is fixed once created. Removing a tool is a step-up action, like
377
+ removing a key.
378
+
379
+ **Raw calls are off by default.** `g.ai.chat(body)` — the browser sending
380
+ the provider's own request body — answers `403 raw_calls_off` unless the
381
+ 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
382
+ forwarded as sent (minus `provider`, and for Google minus `model` and
383
+ `stream`, which ride the URL), the provider's own status and bytes pass
384
+ straight back, a stream stays a stream, and a call that names no `tool`
385
+ runs as the **default tool** (one credit, the configured provider, any
386
+ allowed model). `gemmein dev` with no key keeps raw calls open — the fake
387
+ answers and there is no switch locally. Both routes are for the browser
388
+ only: a server key is refused (`403 scope_denied`) — a server calls the
389
+ provider directly.
390
+
391
+ **Every call lands on `ai_calls`**, the managed collection: who, which tool,
392
+ kind, provider, model, tokens in/out (when the provider said), credits,
393
+ outcome, refusal code, latency, when. The **prompt and answer** ride the row
394
+ only when the owner switches *record calls* on for that tool — off by
395
+ default. A person reads their own (`g.ai.calls()`), the owner reads by app
396
+ in the back office, erasure removes a person's rows.
358
397
 
359
398
  | Method | Signature | Returns |
360
399
  |--------|-----------|---------|
361
- | `ai.chat` | `(body: object, options?: { 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` |
362
- | `ai.text` | `(body: object, options?)` | `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 |
363
-
400
+ | `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` |
401
+ | `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` |
402
+ | `ai.calls` | `(options?: { limit?: number; before?: string })` | `Promise<{ calls: AiCallRecord[]; nextCursor: string \| null }>` — the signed-in person's own calls, newest first |
403
+ | `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 |
404
+ | `ai.text` | `(body: object, options?: { tool?: string; provider?: …; signal?: AbortSignal })` | `Promise<string>` — RAW, non-stream, one string |
405
+
406
+ - **The tool file.** `name` (the file name; fixed once created), `label`,
407
+ `provider`, `model` (pin it — a composed call needs one; with none, the
408
+ owner's first allowed model, else the provider's small default),
409
+ `credits`, `requires`, `enabled`, `bounds`, plus the implementation:
410
+ `kind` (`chat`, the first), `instructions` (the system turn, ≤ 20,000
411
+ chars — never leaves the server), `promptTemplate` (the user turn with
412
+ `{{input}}` placeholders naming declared inputs; ≤ 20,000; absent = the
413
+ inputs rendered one per line as `name: value`), `inputs` (≤ 20 of
414
+ `{ name, type: text|number|boolean, required?, maxLength? }` — names
415
+ 1–40 lowercase letters/digits/underscores, starting with a letter; a
416
+ text input ≤ 4,000 chars
417
+ unless it says, ≤ 20,000), `recordCalls` (off by default). A tool with
418
+ neither a template nor inputs composes nothing: `409 tool_incomplete`.
419
+ - **Inputs are checked by name** before any spend: an unknown input, a
420
+ missing required one, a wrong type or a value over its cap is
421
+ `400 invalid_inputs` — the message names the input and the rule. The run
422
+ body is `{ inputs, stream? }` and at most 64 KB.
423
+ - **Composition** per provider: OpenAI `messages` (system + user),
424
+ `max_completion_tokens`; Anthropic `system` + `messages`, `max_tokens`;
425
+ Google `systemInstruction` + `contents`, `generationConfig.maxOutputTokens`.
426
+ The output ceiling is `bounds.maxOutputTokens` or 4,096 — a composed
427
+ call is never "as long as the model likes". `bounds.stream: false`
428
+ forces a non-stream answer.
429
+
430
+ - `tool` names an `ai_tools` row by its slug; omit it for the default tool.
431
+ An unknown name is `404 unknown_tool`; a tool the owner switched off is
432
+ `403 tool_disabled`; a tool gated with `requires` refuses a person who
433
+ lacks that entitlement with `403 entitlement_required` (the message names
434
+ the plan or product). A tool with a pinned `model` refuses a body that
435
+ sets one with `403 model_pinned`; a tool with no pinned model follows the
436
+ allowlist rule below. A tool's own `bounds.maxBodyBytes` (≤ 262,144)
437
+ replaces the default 256 KB cap. `bounds.maxOutputTokens` is a ceiling: a
438
+ larger or absent value in the body becomes the bound; a smaller one stays.
439
+ It reaches the provider as ONE field, never two: the ceiling is written
440
+ onto the field the body already used, and a second spelling of the same
441
+ ceiling is folded away, so a browser cannot buy more output by naming the
442
+ field the route was not watching. A body that names none is sent the
443
+ current field — `max_completion_tokens` on OpenAI, `max_tokens` on
444
+ Anthropic, `generationConfig.maxOutputTokens` on Google.
364
445
  - `body` is exactly what the provider documents for its chat endpoint —
365
446
  OpenAI chat completions, Anthropic messages, Google generateContent. Its
366
447
  `model` field, when present, must match `^[A-Za-z0-9._:-]{1,80}$`; Google
367
448
  needs it (it rides the URL); when the owner lists allowed models (up to
368
- 20 — the Keys room's test call uses the first), any other answers
449
+ 20 — the AI tools page's test call uses the first), any other answers
369
450
  `403 model_not_allowed`.
370
451
  - `provider` is optional when one key is configured and required when more
371
- than one is (`400 provider_required`). No key at all is
372
- `409 ai_not_configured`. `?provider=` and `?stream=1` on the URL do what
373
- the body fields do.
374
- - Two response headers from Gemmein on every answer that passed the spend:
375
- `x-gemmein-credits-remaining` (the balance after this call) and, on a
376
- refund, `x-gemmein-credit: refunded`.
377
- - **The refund rule.** A credit is refunded only when the provider fails
378
- before its first byte (a non-2xx, or `502 provider_unreachable`). A stream
379
- that dies after the first byte is not refunded; hanging up early does not
380
- refund. A provider that echoes the key in a refusal reaches the app as
381
- `***<hint>`.
452
+ than one is (`400 provider_required`); a named tool fixes its own
453
+ provider, and a `provider` in the body or `?provider=` that disagrees with
454
+ it is `400 invalid_body`. No key at all is `409 ai_not_configured`.
455
+ `?provider=` and `?stream=1` on the URL do what the body fields do.
456
+ - Response headers from Gemmein. `x-gemmein-credits-remaining` (the balance
457
+ after this call) rides every answer that passed the spend.
458
+ `x-gemmein-credit` reads `refunded`, and is there only when the credits
459
+ came back. `x-gemmein-ai` reads `fake`, and is there only on the local
460
+ engine's rehearsal answer. `x-gemmein-tool` names the tool; absent on the
461
+ implicit default.
462
+ - **The refund rule.** The tool's credits are refunded only when the
463
+ provider fails before its first byte (a non-2xx, or
464
+ `502 provider_unreachable`). A stream that dies after the first byte is
465
+ not refunded; hanging up early does not refund. A provider that echoes
466
+ the key in a refusal reaches the app as `***<hint>`.
382
467
  - Limits: 20 calls per person per minute (`429 ai_capped`, `resetAt`), 256 KB
383
- body (`413 payload_too_large`) nested at most 32 levels (`400 invalid_body`),
384
- 170 s in all and, on a stream, 10 s to the first response headers. Every
468
+ body by default (`413 payload_too_large`, unless a tool sets a smaller
469
+ `bounds.maxBodyBytes`) nested at most 32 levels (`400 invalid_body`),
470
+ 170 s in all and, on a stream, 10 s to the first response headers. A
471
+ tool's `credits` are 1–10,000; `bounds.maxOutputTokens` is 1–100,000,
472
+ 4,096 when the tool sets none. Every
385
473
  `/ai/chat` call counts toward the app's `api_requests` band like any other
386
474
  request.
387
475
 
388
476
  ```ts
389
- const res = await g.ai.chat({
390
- model: "gpt-4o-mini", stream: true,
391
- messages: [{ role: "user", content: text }]
392
- }, { provider: "openai" })
477
+ // gemmein/ai/tools/deep-research.json
478
+ // { "label": "Deep Research", "provider": "openai", "model": "gpt-4o", "credits": 20,
479
+ // "requires": "access:pro-max",
480
+ // "instructions": "You are a careful research assistant. Answer with sources.",
481
+ // "promptTemplate": "Research this for a {{audience}} reader:\n\n{{question}}",
482
+ // "inputs": [{ "name": "question", "type": "text", "required": true, "maxLength": 2000 },
483
+ // { "name": "audience", "type": "text" }],
484
+ // "bounds": { "maxOutputTokens": 4000 } }
485
+
486
+ const res = await g.ai.run("deep-research", { question: text, audience: "beginner" }, { stream: true })
393
487
  for await (const chunk of res.body) render(chunk) // the provider's SSE, byte for byte
394
488
 
395
- const answer = await g.ai.text({ model: "gpt-4o-mini", messages: [{ role: "user", content: text }] })
489
+ const answer = await g.ai.runText("deep-research", { question: text })
490
+ const { calls } = await g.ai.calls() // the person's own history
491
+
492
+ // RAW — only behind the owner's "raw calls" switch for that provider key:
493
+ const raw = await g.ai.chat({ model: "gpt-4o-mini", stream: true, messages: [{ role: "user", content: text }] })
396
494
  ```
397
495
 
398
496
  | code | status | meaning · do |
399
497
  |------|--------|--------------|
400
- | `credits_exhausted` | 402 | The person's balance is 0the message carries it. Show the pack |
401
- | `ai_not_configured` | 409 | No provider key on this app and environment the owner pastes one in the Keys room |
498
+ | `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 |
499
+ | `invalid_inputs` | 400 | An input is unknown, missing, the wrong type or over its cap the message names it |
500
+ | `tool_incomplete` | 409 | The tool composes nothing (no template, no inputs) — give it one; the owner's or the file's fix |
501
+ | `unknown_tool` | 404 | No AI tool by this name in this environment — check the slug or the environment |
502
+ | `tool_disabled` | 403 | The owner switched this tool off — turn it on, or use another |
503
+ | `entitlement_required` | 403 | The tool's `requires` names a plan or product this person lacks — the message names it |
504
+ | `model_pinned` | 403 | This tool's model is fixed — leave `model` out of the body |
505
+ | `provider_not_configured` | 409 | Creating or updating a tool: no key is set for that provider yet — add one on the AI tools page first |
506
+ | `too_many_tools` | 409 | This environment already holds 50 AI tools — delete one before adding another |
507
+ | `invalid_tool` | 400 | Creating or updating a tool with a bad field — the message names which one and its rule |
508
+ | `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 |
509
+ | `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one on the AI tools page |
402
510
  | `provider_required` | 400 | More than one provider key is set — pass `provider` |
403
- | `model_not_allowed` | 403 | The owner's allowlist names the models this app may call; the message lists them |
511
+ | `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) |
404
512
  | `ai_capped` | 429 | 20 calls per person per minute — wait for `resetAt` |
405
- | `payload_too_large` | 413 | The body is over 256 KB — shorten the conversation you send |
406
- | `invalid_body` | 400 | The body must be the provider's JSON request object, nested at most 32 levels |
513
+ | `payload_too_large` | 413 | The body is over the cap (256 KB, or the tool's own `bounds.maxBodyBytes`) — shorten the conversation you send |
514
+ | `invalid_body` | 400 | The body must be the provider's JSON request object, nested at most 32 levels; or a named tool's own provider was contradicted |
407
515
  | `session_required` | 401 | No signed-in person — sign in first |
408
516
  | `scope_denied` | 403 | A secret key called the route — the route is for the browser; a server calls the provider directly |
409
- | `provider_unreachable` | 502 | The provider did not answer before the first byte — nothing was charged (the credit is refunded); retry |
410
- | `provider_error` | the provider's | `g.ai.text` only (client-side): the provider's own non-2xx, its message in `err.message` |
411
- | `ai_test_capped` | 429 | The Keys room's test callone a minute per app |
517
+ | `provider_unreachable` | 502 | The provider did not answer before the first byte — nothing was charged (the tool's credits are refunded); retry |
518
+ | `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` |
519
+ | `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` |
520
+ | `ai_test_capped` | 429 | The AI tools page's test call — one a minute per app |
412
521
 
413
522
  `gemmein dev` answers a fake provider without a key (header `x-gemmein-ai:
414
523
  fake`, an echo stream), so the loop runs locally; set
@@ -525,8 +634,10 @@ provider's webhook arriving, a clock, a record changing — and one to ten of
525
634
  Gemmein's **own** verbs, run in order: write a record, grant or revoke access,
526
635
  email the person, call your URL. Gemmein runs no code of yours inside one;
527
636
  compute lives on your host, behind `call_url`. The definition is a JSON file
528
- your AI writes; the owner's dashboard shows it read-only with its receiver URL,
529
- its secrets (shown once), every event with each action's result, and a replay
637
+ your AI writes, or the owner writes in the dashboard, which creates and edits
638
+ it too, pauses, resumes, rotates and deletes it, and shows where it came from
639
+ ("from file, synced <when>" / "edited here <when>"), its receiver URL, its
640
+ secrets (shown once), every event with each action's result, and a replay
530
641
  button. There is no SDK method: the surface is the file and the dashboard.
531
642
  Stripe stays built in; any provider that signs its webhooks — GoCardless, Paddle,
532
643
  Lemon Squeezy among them — drives access the same way through a relay, and the
@@ -627,6 +738,8 @@ authorise on fields they cannot set, or from a receiver.
627
738
  | `grant_access` | `entitlement` (plan or product **name**, or `access:<slug>`), `expiresAt?` (`"30d"`, `"12h"`, `"2w"` or an ISO date), `reason?` (≤ 200, templated) | source `relay`, the seventh grant source; skipped when this event already granted |
628
739
  | `grant_credits` | `amount` (1..10,000), `reason?` (≤ 200, templated) | adds to the person's credit balance, once per event (a replay finds its own row); the ledger line reads "relay: <reason>"; a relay adds and never spends |
629
740
  | `revoke_access` | `entitlement` | ends every live grant of that entitlement the person holds; skipped when none |
741
+ | `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. |
742
+ | `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` |
630
743
  | `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 |
631
744
  | `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 |
632
745
 
@@ -665,11 +778,16 @@ Egress refusals arrive as the action's error, naming the rule: `https_only`,
665
778
  ### Templates
666
779
 
667
780
  In string values only — `write_record.data`, `email_person.subject` / `text`,
668
- `grant_access.reason`; never in `call_url.url` or an `entitlement`:
781
+ `grant_access.reason`, `fulfil_product.ref` / `refund_product.ref`; never in
782
+ `call_url.url` or an `entitlement`:
669
783
  `{{event.a.b}}` (the raw body), `{{mapped.x}}`, `{{record.data.x}}`,
670
784
  `{{record.id}}`, `{{person.email}}`, `{{person.id}}`, `{{tick.periodStart}}`.
785
+ Mapped fields ride into templates as `{{mapped.<name>}}`; `{{event.<path>}}`
786
+ reads the raw payload.
671
787
  A path that names nothing renders empty and adds a warning to the event;
672
- objects render as JSON. No expressions, no filters.
788
+ objects render as JSON. No expressions, no filters. The one place an empty
789
+ render is a refusal rather than a blank is a `ref` you wrote on
790
+ `fulfil_product` / `refund_product`.
673
791
 
674
792
  ### Lifecycle
675
793
 
@@ -709,7 +827,16 @@ receiver's local URL), schedules and data changes with the same runner and
709
827
  prints `RELAY · <name> · <trigger> · <n actions> · ok|failed`;
710
828
  `npx gemmein sync` carries the files to the cloud app's development
711
829
  environment with the collections — contract, never data; the cloud mints its
712
- own secrets.
830
+ own secrets. After go-live, `npx gemmein sync --live` carries relays and AI
831
+ tools into production: it asks for a **sync key** (Secret keys → production →
832
+ Sync key — step-up to mint, lives one hour, shown once, never saved by the
833
+ CLI), prints what would change, waits for the word `live`, never deletes, and
834
+ refuses collections (they move by promotion). A row edited in the dashboard
835
+ since the last sync is asked about, per item (`--overwrite` answers yes);
836
+ every relay says where its definition came from — from file, or edited in the
837
+ dashboard — and when. A relay's name is fixed once created: the receiver URL
838
+ is built from it, so a rename is refused (`invalid_definition`) — create the
839
+ new one, delete the old in the dashboard once the provider has moved.
713
840
 
714
841
  ---
715
842
 
@@ -742,7 +869,10 @@ line; a wrong-but-well-formed name surfaces as `unknown_collection` and exits
742
869
 
743
870
  ### `gemmeinServer(sk).testSession(email) → { token, expiresAt, user }`
744
871
  **Dev only** — throws `test_session_forbidden_live` on an `sk_live` key, and
745
- the server refuses it on a live environment too. Pass the `token` to
872
+ the server refuses it on a live environment too. Test **people** only: a
873
+ server key is refused the account owner's or an admin's email
874
+ (`scope_denied`) — the app's CLI key (Setup page) is the credential that
875
+ acts as the founder, and it is the only key that links or syncs. Pass the `token` to
746
876
  `gemmein(pk, { tokenStore })` to act as that user. Dev and live enforce the
747
877
  *same* rules, so what is proven in dev holds in live.
748
878
 
@@ -793,6 +923,9 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
793
923
  | `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 |
794
924
  | `not_found` | record you can't see (existence not leaked) | treat as absent |
795
925
  | `denied` | 401 (sign in first) or 429 (rate limit — see `resetAt`) | re-auth or wait+retry |
926
+ | `invalid_body` | 400 — the request body is not a JSON object (an array or a bare value was sent) | send an object |
927
+ | `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 |
928
+ | `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 |
796
929
  | `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
797
930
  | `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
798
931
  | `invalid_publish` | `{ published }` on a non-public rule | drop it |
@@ -810,22 +943,25 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
810
943
  | `unknown_plan` | no plan by that name | use a name from the list in the message |
811
944
  | `plan_not_purchasable` | tried to check out the free default plan | nothing to buy — gate on the paid plan's name |
812
945
  | `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
813
- | `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 |
946
+ | `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) |
947
+ | `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 |
814
948
  | `unsupported_file_type` (415) | upload isn't an allowed type | images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
815
949
  | `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) |
816
950
  | `invalid_amount` / `invalid_reason` (400) | `spendCredits`: `amount` outside 1..10,000, or `reason` missing / over 200 chars | fix the field the code names |
817
951
  | `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 |
818
952
  | `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 |
819
953
  | `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 |
820
- | `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 |
821
- | `ai_test_capped` (429) | the Keys room's test call — one a minute per app | wait a minute |
954
+ | `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 |
955
+ | `ai_test_capped` (429) | the AI tools page's test call — one a minute per app | wait a minute |
822
956
  | `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
823
- | `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 |
957
+ | `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` |
824
958
  | `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
825
959
  | `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
960
+ | `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 |
961
+ | `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 |
826
962
  | `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
827
963
  | `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 |
828
- | `ai_not_configured` (409) | no provider key on this app and environment | the owner pastes one in the Keys room |
964
+ | `ai_not_configured` (409) | no provider key on this app and environment | the owner pastes one on the AI tools page |
829
965
  | `provider_required` (400) | more than one provider key is set and the call named none | pass `provider` |
830
966
  | `model_not_allowed` (403) | the owner's allowlist does not name this model | use one the message lists |
831
967
  | `ai_capped` (429) | 20 AI calls per person per minute | wait for `resetAt` |