AI
Call language models from your worker. One binding, env.ai, reaches both frontier and value models, and the cost comes out of your org's credit balance.
Your worker has env.ai from the first deploy. There is nothing to turn on.
env.ai only exists inside the worker. A project that ships static assets with no worker entry point cannot use it.
Calling it
const r = await env.ai.run("anthropic/claude-sonnet-5", {
messages: [{ role: "user", content: "Write the order confirmation email." }],
maxTokens: 1024,
});
r.text // the generated text
r.usage // { inputTokens, outputTokens, cachedInputTokens }
r.model // the model that actually ran
r.stopReason // why generation stopped
r.usageComplete // whether usage is the exact final totaltext can be empty even after a successful call. In particular, a reasoning model can spend the output budget on reasoning and stop with stopReason: "max_tokens" before it emits visible text. Log stopReason, usage and usageComplete together; an empty string by itself does not tell you whether the model had nothing to say or ran out of budget.
When usageComplete is false, usage is only the provider-reported subtotal Runlot observed before termination. Zero does not mean the provider did no work or incurred no cost. Runlot does not invent the missing tokens.
For a single message, prompt is the shorthand — it is the same as one user message.
const r = await env.ai.run("@runlot/nova-micro", { prompt: "Summarise this in one line." });| Option | |
|---|---|
messages | Array of { role, content } |
prompt | Shorthand for a single user message |
system | System prompt |
maxTokens | Maximum tokens to generate |
temperature | Sampling temperature |
reasoning | { effort } — how hard the model thinks before answering. Each model's values are on its env.ai.models() row (effortLevels); omitting it runs the provider's default |
responseFormat | { type: "json_schema", name?, schema } — the answer is JSON conforming to schema. Only on models whose env.ai.models() row says structuredOutput: true |
tools | Server-side tools: { name, description, inputJsonSchema, execute }[] |
maxToolRounds | Maximum model/tool rounds, 1–32 (default 8) |
stream | true returns a ReadableStream |
role is user or assistant, nothing else. The system prompt goes in system, not in messages.
In a Next.js project, import env from @runlot/next. Its exported RunlotEnv type includes env.ai, so run, embed, models, tool results, usage and terminal results are typed without a separate ambient declaration.
The Studio and runlot dev relays preserve stopReason, cachedInputTokens and usageComplete; local code sees the same terminal metadata instead of losing it at the relay boundary.
Reasoning effort and structured output
reasoning.effort trades answer thoroughness for speed and cost. The values differ per model family, so env.ai.models() lists each model's own in effortLevels — the OpenAI rows take none through max, the Claude rows low through max, Gemini low/medium/high. Asking a model for a level it does not have is 400 bad_effort, which lists what it does have; a model with no effort control at all (@runlot/…) refuses the option rather than ignoring it.
responseFormat constrains the answer to your JSON Schema, so JSON.parse(r.text) is the object you described — no tool loop needed.
const r = await env.ai.run("openai/gpt-6-luna", {
prompt: "Order A-123 shipped Tuesday. Extract it.",
reasoning: { effort: "low" },
responseFormat: {
type: "json_schema",
schema: {
type: "object",
properties: { orderId: { type: "string" }, status: { type: "string" } },
required: ["orderId", "status"],
additionalProperties: false,
},
},
});
const order = JSON.parse(r.text); // { orderId, status }Which models honour it is on the env.ai.models() row (structuredOutput: true) — today the openai/* rows, anthropic/claude-haiku-4-5 and both Gemini rows. The rest answer 400 format_unsupported rather than quietly returning prose. Two shapes are refused by name: responseFormat together with stream (the providers scope it to non-streaming calls) and together with tools (a tool's own inputJsonSchema is the way to shape tool arguments). Write the schema the providers' constrained decoders take: an object schema, every field in required, additionalProperties: false.
Server-side tools
execute runs inside the same app worker generation that called env.ai.run. The model receives only the tool's name, description and JSON Schema; no callback URL, credential or function code leaves the worker.
const answer = await env.ai.run("anthropic/claude-sonnet-5", {
prompt: "Where is order A-123?",
tools: [{
name: "lookup_order",
description: "Look up an order owned by the current user.",
inputJsonSchema: {
type: "object",
properties: { orderId: { type: "string" } },
required: ["orderId"],
additionalProperties: false,
},
async execute({ orderId }, { signal }) {
const order = await lookupOrder(orderId, { signal });
if (order.status === "cancelled") {
return { stop: true, text: "The order is cancelled.", data: order };
}
return { data: order };
},
}],
});Return { text }, { data }, { isError: true, text }, or { stop: true, text?, data? }. Throwing becomes an error result the model can handle; it does not automatically end the run. stop: true is terminal only when isError is not true. Runlot submits that result, cancels the identified remote run and waits for the cancellation to be acknowledged, then returns immediately without dispatching later tool calls or starting another model round.
A terminal non-streaming result keeps everything observed before the stop:
{
text, // model text accumulated before the tool stopped it
model,
stopReason: "tool_stop",
usage, // cumulative observed usage
usageComplete,
finalTool: { id, name, args, result },
}usageComplete is true for a credit-backed tool_stop, because that path reports and settles each completed provider round before exposing its tool calls. It is false when a Claude or Codex subscription supplied through Studio is stopped at a paused tool call: those adapters report usage only when a run completes, so Runlot has only the subtotal from earlier completed work. A normal completed run reports true; every error reports false. The BYOS audit records the same observed subtotal rather than making up a final total.
Cancellation is not a promise that no provider work was billed. Before the service has supplied a run ID there is no remote run that the wrapper can acknowledge, and an interruption can hide provider usage that was never reported to Runlot. Tools are currently available on anthropic/* and openai/*. Other model families return tools_unsupported instead of silently ignoring them.
Streaming
With stream: true you get a ReadableStream of SSE frames. Hand it straight to a Response. A tool call pauses generation while execute runs, then later text continues on this same stream; tool control frames are not exposed as text.
const stream = await env.ai.run("@runlot/nova-micro", { prompt, stream: true });
return new Response(stream, { headers: { "content-type": "text/event-stream" } });The frames look like this. A tool-bearing public stream also forwards usage events. On the credit path an event without cumulative: true is one round's increment, so add it to the running total. A subscription event with cumulative: true is a replacement snapshot of everything observed so far — replace the running total instead of adding it, or the final snapshot will be counted twice. The terminal done or error frame carries cumulative text and usage, not just the last round.
data: {"delta":"Hello"}
event: usage
data: {"inputTokens":12,"outputTokens":3,"cachedInputTokens":4,"round":1}
data: {"delta":" there"}
event: done
data: {"text":"Hello there","model":"anthropic/claude-sonnet-5","stopReason":"end_turn","usage":{"inputTokens":12,"outputTokens":3,"cachedInputTokens":4},"usageComplete":true}If generation is cut short, the last frame is event: error.
event: error
data: {"code":"upstream_error","message":"…","text":"Hello","model":"anthropic/claude-sonnet-5","usage":{"inputTokens":12,"outputTokens":3,"cachedInputTokens":4},"usageComplete":false}A terminal frame always says whether its cumulative usage is complete. When usageComplete is false, it contains only provider-reported usage Runlot observed before the interruption; zero is still only an observed subtotal. It cannot promise tokens the provider consumed but never reported. Once the stream has started, a failure arrives as that frame, not as a thrown error — the 200 went out with the first frame. Handle event: error wherever you read the stream.
The catalog
Read the list of models this project can actually call from your code.
const models = await env.ai.models();There are two tiers, and they are priced differently.
| Tier | Model ID | Price |
|---|---|---|
| Frontier | anthropic/… · openai/… · google/… | The provider's list price, passed through with no markup |
| Value | @runlot/… | Set by runlot |
A model ID that is not in the catalog is a 400. The list is a fixed one we curate.
Each row carries kind: chat for env.ai.run, embedding for env.ai.embed. Embeddings and the vector store that goes with them are on their own page.
The free allowance
Every org gets $0.11 a day to spend on value models. It resets at UTC midnight. With no credit at all, you can still call @runlot/… models up to that much every day.
Frontier models get no free allowance. Open the daily amount up to frontier models and a single call consumes all of it — that is not a free tier, it is a broken one. anthropic/…, openai/… and google/… need credit on the org; without it you get a 402.
Credit is money
Credit is a money balance, not a bundle of tokens. What a call deducts is whatever the price table says at the time of the call.
Provider list prices move — Google's, for one, doubles on 2027-01-01. If credit were a bundle of tokens, a price change would silently change the value of credits users had already purchased. A money balance is what prevents that: you bought an amount, and what it buys is decided when you call.
Both tiers are priced in the same currency. The only difference is where the price comes from. There is no separate unit to learn.
Screens and the CLI show USD. The integers in the API and in --json output are micro-cents (1 micro-cent = 1e-8 USD). In cents, a small call would round to zero and be silently free — a thousand-token call is less than a hundredth of a cent.
usage.inputTokens is the total input. usage.cachedInputTokens is the cached subset of that total, not an additional number to add. Runlot normalizes provider-specific cache counters into that shape before pricing: cache reads use the cached-input rate, while uncached input and cache writes use the normal input rate. A correction to that normalization applies to calls after the corrected release only; Runlot does not create retroactive charges for earlier ledger entries.
Limits
| Free | Pro | |
|---|---|---|
| Concurrent calls (per project) | 2 | 10 |
| Calls per minute (per project) | 30 | 300 |
maxTokens per call | 4,096 | Model maximum |
| Input per call | 128k tokens | Model maximum |
| Free daily allowance (value) | $0.11 per org | Same |
Errors
A refused or failed call throws. Errors raised before a stream starts carry e.status (the HTTP status) and e.code (a short string). If a tool-enabled run fails after it starts, the error also exposes e.text, e.model, cumulative e.usage and e.usageComplete: false, so recovery code can keep the text and provider-reported subtotal observed before the failure. That subtotal is an observation, not a promise of zero unseen provider cost.
An error can also carry e.finalTool when the callback already returned { stop: true } but cancellation acknowledgement failed. The callback's side effect may already be committed. Do not retry that tool merely because env.ai.run threw; inspect finalTool.id, its arguments and your own idempotency record first. Stream errors use the same finalTool field.
| Status | code | Meaning |
|---|---|---|
| 400 | unknown_model | The model ID is not in the catalog. env.ai.models() is the list. |
| 400 | bad_effort | reasoning.effort is not one of the model's values. The message lists them. |
| 400 | bad_format | responseFormat is malformed — the type is not json_schema, the schema is missing, or the name is invalid. |
| 400 | format_unsupported | The model has no structured output, or responseFormat was asked together with stream or tools. |
| 402 | no_credit | No credit, or the free daily allowance is used up. |
| 413 | input_too_large | The input is over the per-call cap. |
| 429 | rate_limited | The per-minute limit. |
| 429 | too_many_concurrent | The concurrency limit. |
| 429 | upstream_rate_limited | The provider is busy. |
| 502 | upstream_error | The provider call failed. |
| 503 | model_unavailable | That model cannot be called on this deployment. |
| 504 | upstream_timeout | The provider call ran out of time. |
rate_limited and upstream_rate_limited come with a Retry-After response header. too_many_concurrent does not — we cannot say when a slot will free up. It clears when a call in flight finishes.
Topping up
Top up from the Billing tab on your org screen in the dashboard.
Auto top-up
When the balance falls below a level you set, the saved card is charged automatically. It is off by default.
| Top-up amount | One of $10 · $20 · $50 · $100 |
| Threshold | Greater than 0, and less than the top-up amount |
| Charges per day | 5 |
| Consecutive failures | 3 turns it off |
- The first top-up is manual. That payment is where the card gets saved. With no saved payment method, auto top-up cannot be turned on.
- Turning it on is explicit consent. The card is charged while you are not at a payment screen, so the screen states the charge conditions in full and asks you to confirm them.
- Five charges a day is the spend ceiling. The daily maximum is 5 × the top-up amount. Buggy code calling in a loop stops there. The count is not adjustable — raise the top-up amount instead.
- The threshold has to be above 0. Topping up is asynchronous, so there has to be enough balance left to carry you until it lands. A threshold of 0 is refused.
- The threshold has to be below the top-up amount. Otherwise one top-up still leaves you under the threshold, and it keeps topping up to the daily cap. That is refused too.
- Three consecutive declines turn it off. Each failure sends a notification, and the third one turns the setting off. We do not retry a customer's card quietly and forever.
CLI
runlot ai # calls, tokens, money spent, and your limits
runlot ai models # the catalog with per-1M-token prices
runlot ai credit # the org's credit balance and auto top-up settings
runlot ai delete # turn it off (admin)All three read commands take --json. There is no command that calls a model — the CLI is outside the worker, and env.ai is reachable only inside it.
What is not here
- Arbitrary model pass-through. The catalog is a fixed list we choose and maintain.
- A way in from outside.
env.aiis reachable only from inside your deployed worker. There is no API key, no external endpoint, and no way to call it from outside runlot. This is a boundary we drew, not a feature we forgot. - Bring your own key. There is no path that takes your provider key and calls through us.
- Fine-tuning, image and audio models. Not yet. Embeddings and vector search are here — see Embeddings and vectors.