The AI route
Your app talks to OpenAI, Anthropic or Google through Gemmein, on your own provider key, which never reaches the browser, through a named AI tool whose definition lives on the server: the instructions, the prompt template, the inputs it takes, the model, the caps, beside the price in credits and the access gate. A signed-in person's app sends a name and inputs; Gemmein composes the request, spends the tool's credits, adds your key, and streams the answer back.
What it is. A named AI operation, defined on the server, that you price in credits and gate by access.
Does. Composes the provider request from the tool's own instructions and template
with the inputs the app sent; runs it on your key with the pinned model and the output cap;
spends the tool's credits before the call and refunds them if the provider fails before
answering; records every call on ai_calls, with the prompt and answer only when
you switch that on for the tool.
Does not. Does not let the browser compose the request or see the prompt (raw calls are off unless the owner switches them on for a provider key); does not price by token; does not let the browser set a price, a model or a gate; does not run from a server key; does not rename a tool after creation.
Needs something else when. You want a plan-dependent price for the same
operation → make two tools and gate each; you meter something that is not an AI call
→ spendCredits from your server; you need images, audio or embeddings → your
server calls the provider directly (chat is the first kind).
Example. "Deep Research", openai, gpt-4o, 20 credits, requires access:pro-max, with
instructions and a question input.
Also true of the route itself. Your server makes the model call: call the provider directly with your key, and ask Gemmein's server gate who the person is and what they hold. The call is not a chat call, such as embeddings, images or audio: the provider directly, from your server. The provider is not on the list: write to hello@gemmein.com.
Tools
You create a tool on the dashboard's AI tools page, or your AI writes a file at
gemmein/ai/tools/<name>.json and npx gemmein sync carries it
to development; npx gemmein sync --live carries it into production with a
sync key (Secret keys → production → Sync key: your sign-in code to mint, one hour to
use, shown once, never saved). The file owns the implementation on every sync; the
dashboard owns the commerce (label, credits, gate, on/off, record calls) after
creation. A tool can be defined before its provider key exists: runs are refused
ai_not_configured until the key is added on the AI tools page, and the
console shows the tool as waiting on a key until then. A tool's name is fixed once
created: it is what the app calls.
| Field | Rule |
|---|---|
name | 1–40 lowercase letters, numbers or hyphens, starting with a letter or number. Fixed once created |
label | 1–60 characters, shown to the person and on the ledger |
provider / model | openai, anthropic or google; pin the model a composed call runs on |
credits | a whole number from 1 to 10,000 |
requires | an entitlement key like access:pro-max; a person without it is refused |
instructions | the system prompt, up to 20,000 characters. Never leaves the server |
promptTemplate | the user turn, with {{input}} placeholders naming declared inputs; absent, the inputs are rendered one per line |
inputs | up to 20 of { name, type: text | number | boolean, required?, maxLength? }; a text input holds 4,000 characters unless it says (up to 20,000) |
recordCalls | keep the prompt and answer on ai_calls. Off by default |
bounds | maxOutputTokens (a composed call's ceiling; 4,096 when unset), stream, maxBodyBytes (raw calls only, up to 262,144) |
A tool file for the example above, at gemmein/ai/tools/deep-research.json:
{
"label": "Deep Research",
"provider": "openai",
"model": "gpt-4o",
"credits": 20,
"requires": "access:pro-max",
"instructions": "You are a careful research assistant. Answer with sources.",
"promptTemplate": "Research this for a {{audience}} reader:\n\n{{question}}",
"inputs": [
{ "name": "question", "type": "text", "required": true, "maxLength": 2000 },
{ "name": "audience", "type": "text" }
],
"bounds": { "maxOutputTokens": 4000 }
}
A person without the entitlement sees "Deep Research requires Pro Max."; one short of the price sees "Deep Research costs 20 credits. You have 7."; the ledger line reads "20 credits spent · Deep Research · 87 remaining." One tool holds one credit price: a plan-dependent price for the same operation is two tools, each gated.
The worked case: a name and inputs; the server composes the rest
The app never sends a prompt or a provider request. It names the tool and passes the inputs the tool declared; Gemmein composes the request in the provider's own grammar, from the instructions and the template on the server. The answer is the provider's own shape, so a streaming run streams:
const res = await g.ai.run("deep-research", { question: text, audience: "beginner" }, { stream: true })
for await (const chunk of res.body) render(chunk) // the provider's SSE, byte for byte
res.headers.get("x-gemmein-credits-remaining") // the balance after this call
For a non-stream answer as one string, whichever provider answered, and for the person's own history:
const answer = await g.ai.runText("deep-research", { question: text })
const { calls } = await g.ai.calls() // what they ran, when, what it cost, how it ended
g.ai.runText reads choices[0].message.content from OpenAI, joins
content[].text from Anthropic, and joins
candidates[0].content.parts[].text from Google. A provider's non-2xx answer is
returned as it came by g.ai.run, not thrown; g.ai.runText, like
g.ai.text, throws it as provider_error with the provider's status and message; a refusal by Gemmein
throws GemmeinError with one of the codes below. An unknown input, a missing
required one, a wrong type or a value over its cap is refused by name before anything is
spent (invalid_inputs).
Raw calls: off by default
g.ai.chat(body), where the browser sends the provider's own request, is off
until you switch raw calls on for that key on the AI tools page. Behind the
switch it is the route it always was: the body forwarded as sent, the answer passed back byte
for byte, a call that names no tool running as the default tool for one credit. Without the
switch it answers raw_calls_off. The local rail's fake provider, with no key set,
keeps raw calls open; there is no switch locally.
The record: ai_calls
Every call lands as one row in the ai_calls collection, per person: the tool,
the kind, the provider and model, tokens in and out when the provider said, the credits it
cost, how it ended, the refusal code, the latency, the time. The prompt and the answer ride
the row only for a tool whose record calls switch is on. A person reads their own with
g.ai.calls(); you read them by app in the Records room and on the person's
record; erasure removes a person's rows.
The key, the providers, the models
The AI tools page holds one key per provider, per environment. Paste it, make a test call,
and it is set; the room shows the last four characters of a key of 16 or more characters (a
shorter key shows dots) and the last call. A key is 8 to 512 printable characters. Replace
it by pasting again. Removing it is a step-up action, and your app's AI features stop the
moment it is removed. Optionally, list up to 20 models the app may call (the test call uses
the first); any other model answers model_not_allowed. The model name is the
provider's, 1 to 80 characters of letters, digits, dots, colons, underscores and hyphens;
Google needs one, OpenAI and Anthropic refuse a missing one themselves.
?provider= and ?stream=1 on the URL do what the body fields do.
| Provider | The body you send | Where it goes |
|---|---|---|
openai | Chat completions: model, messages, stream? | OpenAI's chat completions endpoint |
anthropic | Messages: model, max_tokens, messages, stream? | Anthropic's messages endpoint |
google | generateContent: model, contents; stream: true selects the streaming form | Google's generateContent endpoint for that model |
Those three endpoints are the whole list. There is no field for a URL, and no other host is reachable through the route.
Credits and the refund rule
The tool's credits are spent before the request is forwarded; the default tool spends
one. A person short of the price is answered 402 credits_exhausted before
anything is sent, with the balance in the message. Those credits are refunded only when the
provider fails before its first byte, a non-2xx answer or no answer at all; the response
then carries x-gemmein-credit: refunded. A stream that dies after the first
byte is not refunded, and hanging up early does not refund. Every answer that passed the
spend carries x-gemmein-credits-remaining. x-gemmein-tool names the
tool; absent on the implicit default. A provider that echoes the key in a refusal reaches
the app as ***<hint>.
How credits are bought, granted and spent is on Credits.
Numbers
- The credits the owner set for that tool; one by default.
- Up to 50 tools per environment; a name is at most 40 characters, a label at most 60.
- 20 calls per person per minute.
- 64 KB of inputs on a
g.ai.runcall. - 256 KB raw request body on a
g.ai.chatcall, unless a tool sets a smaller cap (up to 262,144 bytes); nested at most 32 levels. - 170 seconds in all; on a stream, 10 seconds to the first response headers.
- Every call counts toward the app's request band like any other request.
- One test call per minute from the AI tools page; a test call spends no credits.
Codes
| Code | Status | Meaning · what to do |
|---|---|---|
raw_calls_off | 403 | The browser may not compose provider requests for this provider. Call a named tool with g.ai.run, or switch raw calls on for the key on the AI tools page |
invalid_inputs | 400 | An input is unknown, missing, the wrong type or over its cap; the message names it |
tool_incomplete | 409 | The tool composes nothing: no template and no inputs |
unknown_tool | 404 | No AI tool by that name in this environment |
tool_disabled | 403 | The owner switched this tool off |
entitlement_required | 403 | The tool requires a plan or product this person lacks; the message names it |
model_pinned | 403 | This tool's model is fixed; leave model out of the body |
too_many_tools | 409 | An environment holds at most 50 AI tools |
invalid_tool | 400 | Creating or updating a tool with a bad field; the message names it |
credits_exhausted | 402 | The person's balance is below the tool's price; the message carries it. Show the pack |
ai_not_configured | 409 | No provider key on this app and environment. The owner pastes one on the AI tools page, and the tool then runs unchanged |
provider_required | 400 | More than one provider key is set; pass provider |
model_not_allowed | 403 | The owner's allowlist names the models this app may call; the message lists them |
ai_capped | 429 | 20 calls per person per minute; wait for resetAt |
payload_too_large | 413 | On g.ai.run: the inputs are over 64 KB; send less. On g.ai.chat: the raw body is over 256 KB; shorten the conversation you send |
invalid_body | 400 | The body is not the provider's JSON request object, or is nested deeper than 32 levels |
session_required | 401 | No signed-in person; sign in first |
scope_denied | 403 | A secret key called the route. The route is for the browser; a server calls the provider directly |
provider_unreachable | 502 | The provider did not answer before the first byte. Nothing was charged; the credit is refunded. Retry |
provider_error | the provider's | Thrown by g.ai.text and g.ai.runText: the provider's own non-2xx, its message in err.message. g.ai.chat and g.ai.run return it as it came |
ai_test_capped | 429 | The AI tools page's test call; one a minute per app |
Both rails
gemmein dev answers the route without a key: a fake provider echoes a
stream, marked x-gemmein-ai: fake, and the tool's credits are spent from the
local ledger. A fresh person's balance is zero, and the route refuses at zero on both
rails, so the loop starts with a credit arriving. Locally that is the pay
simulator: declare a product with “Grants credits” in
gemmein/payments.json (npx gemmein payments setup asks for it),
call g.payments.buy("credits pack"), and open the URL it returns
— the simulated payment travels the real signed-webhook path and the credits land on
that person's local ledger. A relay's grant_credits is the other local road,
for a receiver you have wired. In the cloud the two roads are the same purchase, and a
comp the owner adds by hand on that person's page under
Customers; there is no comp on the local rail,
because the console is not part of it. Set
GEMMEIN_AI_KEY_OPENAI, GEMMEIN_AI_KEY_ANTHROPIC or
GEMMEIN_AI_KEY_GOOGLE in the local rail's environment for a real call. Keys
never sync; the cloud app holds its own, pasted on the AI tools page. Tools do sync: a file at
gemmein/ai/tools/<name>.json hot-reloads like a relay, and the boot card
prints a line per tool — AI tools: deep-research (20 credits · requires
access:pro-max · openai).
Two facts for the reference
- The key is write-only. No response, no audit row and no page shows more than its last four characters.
- The Usage & billing page counts AI calls for the last 30 days: spent by your customers' credits, priced by your provider. Gemmein meters the calls; your provider bills the tokens.