@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 +73 -0
- package/README.md +3 -1
- package/REFERENCE.md +191 -55
- package/dist/index.cjs +123 -17
- package/dist/index.d.cts +120 -18
- package/dist/index.d.ts +120 -18
- package/dist/index.js +123 -17
- package/llms.txt +284 -78
- package/migrations/README.md +1 -0
- package/migrations/raw-calls-off.md +51 -0
- package/package.json +1 -1
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,
|
|
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
|
|
306
|
-
|
|
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
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
the
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
362
|
-
| `ai.
|
|
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
|
|
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`)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
384
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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.
|
|
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
|
-
| `
|
|
401
|
-
| `
|
|
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
|
|
410
|
-
| `provider_error` | the provider's | `g.ai.text`
|
|
411
|
-
| `
|
|
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
|
|
529
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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` |
|