AI calls
aiCall any model through one interface, with personal data redacted first, no training or retention, spend limits per user and every call audited.
aiFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
ai calls a language or embedding model with personal data redacted first, no training or retention, spend limits and every call audited. Reach for it whenever a feature calls a model.
Before a prompt leaves your app, personal data is replaced with placeholders, the provider is held to no training and no retention, the spend limit is checked, and the call is written to the audit log. Tool results and inputs are checked for prompt injection, and generated content carries an AI label. Reach for it whenever a feature calls a model: a chat, a summary, a classifier, search embeddings. The same rules apply whichever provider sits behind it, so you can change provider without changing code.
At a glance
ai:
driver: anthropic # anthropic | openai | local | fake; no driver means fake; bedrock is not built
models: [anthropic/claude-*] # allowlist
data: { training: never, retention: zero }
redact: [personal, sensitive, child, phi]
limits: { per-user: $1/day, per-feature: { order-summary: $200/month }, total: $500/month }import { ai } from "mainsdk";
const summary = await ai.generate({
model: "anthropic/claude-sonnet-4-5",
prompt: `Summarise order ${order.id}`,
feature: "order-summary", // spend limits and audit per feature
user, // and per user
});lib/ai-limits.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { ai } from "mainsdk";
describe("ai limits", () => {
it("ai.AC4 refuses a call over the per-user limit before it reaches the provider", async () => {
createTestApp({ main: "./test/main.yaml" });
const user = { id: "u_1" };
await assert.rejects(ai.generate({ model: "anthropic/claude-sonnet-4-5", feature: "chat", user, prompt: "hello" }), { code: "MAIN-AI-0007" });
});
});Calls
ai.generate({ model, prompt, feature, user })ai.embed({ model, input, feature })ai.tool(action)ai.stream({ model, prompt | messages, feature })ai.extract({ input, schema, model, feature })ai.classify({ input, labels, model, feature })ai.summarize({ input, length, model, feature })ai.disclose(generation)ai.extractor(schema, { model, feature })ai.image.generate({ model, prompt, feature })ai.image.edit({ model, image, prompt, feature })ai.image.inpaint({ model, image, mask, prompt, feature })ai.image.variations({ model, image, count, feature })ai.credentials.verify(file, { user })ai.run({ model, feature, agent, goal, tools, maxSteps, budget })ai.run.stream(opts)ai.run.resume(runId)ai.run.get(runId)ai.batch.submit({ model, feature, items })ai.batch.get(id)ai.batch.cancel(id, { reason })ai.video.generate({ model, prompt, feature, seconds })ai.video.get(id)ai.video.cancel(id, { reason })ai.voice.session({ model, voice, instructions, feature, agent })ai.voice.calls.handle(req, session)ai.voice.calls.dial({ to, from, session })ai.voice.calls.upgrade({ req, socket, head })ai.voice.rooms.join({ room, session })ai.voice.rooms.token({ room, user })ai.speech({ model, voice, text | input, format, feature })ai.transcribe({ model, audio, feature })ai.transcribe.stream({ model, audio, format, sampleRate, feature })ai.voices.list()ai.voices.clone({ name, samples, statement, feature })ai.voices.delete(id, { reason })ai.voices.share(id, share)createAi(opts)new ProviderError(code, message, opts?)fakeImageDriver(opts?)fileStore(dir)gatewayImageDriver(opts)inlineWorkflows(timers?)localAiDriver(opts?)localImageDriver(opts?)localSigner(opts)looksLikeInjection(text)meetsData(declared, required)memoryStore()openaiImageDriver(opts)parseSse(body)signAsset(asset, mime, { req, signer })verifyAsset(asset, mime, opts?)ai.html(text, opts?)
Guarantees
Whatever the driver.
- Provider keys never in code, only secret references
- Providers that train on or keep prompts are refused
- Personal data redacted before the model sees it
- Spend limits per user, per feature and per month
- Every call audited with feature, model, tokens and cost, never the prompt
- Typed output checked against its schema; invalid output is repaired, then refused, never returned
Drivers
8 drivers. Select one to see its line in main.yaml.
ai:
driver: localChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-AI-0001 | Checkmainsdk check, before deploy | The driver's training setting is weaker than ai.data.training |
MAIN-AI-0044 | Checkmainsdk check, before deploy | A prompt part is built from request body bytes or a data: URL instead of a FileRef |
MAIN-AI-0049 | Checkmainsdk check, before deploy | AI images are generated with no ai.credentials.signer (warning) |
MAIN-AI-0068 | Checkmainsdk check, before deploy | A tool passed to ai.run isn't ai.tool() of a declared action |
MAIN-AI-0070 | Checkmainsdk check, before deploy | A route lists a model not in ai.models, unpriced, or whose driver doesn't meet ai.data |
MAIN-AI-0079 | Checkmainsdk check, before deploy | A local endpoint uses plain HTTP off the machine, isn't in network.allow, or a remote one lacks data or region |
MAIN-VOICE-0010 | Checkmainsdk check, before deploy | A phone number routes to a voice session with no disclosure |
MAIN-VOICE-0011 | Checkmainsdk check, before deploy | A voice, telephony or room driver's hosts aren't in network.allow |
MAIN-EVAL-0002 | Checkmainsdk check, before deploy | A feature's score is below its threshold or regressed from the baseline (exit 1) |
MAIN-EVAL-0003 | Checkmainsdk check, before deploy | A prompt or model changed since the last eval result, or a feature with an eval set has none |
MAIN-EVAL-0004 | Checkmainsdk check, before deploy | An eval set or exported sample holds personal data |
MAIN-AI-0005 | RuntimeError responses and logs | Prompt injection suspected in a tool result; wrapped as untrusted data |
MAIN-AI-0007 | RuntimeError responses and logs | Call refused: it would pass a spend limit |
MAIN-AI-0010 | RuntimeError responses and logs | ai.tool() for a function not declared with action() |
MAIN-AI-0011 | RuntimeError responses and logs | Typed output does not match schema after its repairs (one by default, up to two with repairs); no value is returned |
MAIN-AI-0014 | RuntimeError responses and logs | A model verb option is invalid: repairs is not 0, 1 or 2, a label list is empty or repeats a label, or a summary length is unknown (400); no request is sent |
MAIN-AI-0040 | RuntimeError responses and logs | A prompt part is invalid: unknown type, no file, a mime that doesn't match the bytes, pages out of order, or video given as bytes (400); nothing is sent |
MAIN-AI-0041 | RuntimeError responses and logs | A prompt part is over a limit: size, pixels, pages, duration, frames or max-parts (413); nothing is sent |
MAIN-AI-0042 | RuntimeError responses and logs | The model or driver doesn't accept this kind of part (501) |
MAIN-AI-0043 | RuntimeError responses and logs | A labelled image, audio or scan would go to a driver whose data use or region doesn't meet the rules, and media can't be redacted (403) |
MAIN-AI-0045 | RuntimeError responses and logs | An image option is invalid: size, count, mask size or an empty prompt (400); nothing is sent |
MAIN-AI-0046 | RuntimeError responses and logs | The image driver doesn't offer this operation (501) |
MAIN-AI-0047 | RuntimeError responses and logs | The provider refused the image prompt under its content policy (422) |
MAIN-AI-0048 | RuntimeError responses and logs | A content credential couldn't be signed and ai.credentials.required is true (500) |
MAIN-AI-0060 | RuntimeError responses and logs | An ai.run option is invalid: goal and messages, maxSteps, budget, timeout or agent (400); nothing is sent |
MAIN-AI-0061 | RuntimeError responses and logs | The run stopped at maxSteps (stop: "max-steps") |
MAIN-AI-0062 | RuntimeError responses and logs | The run stopped at its budget (stop: "budget") |
MAIN-AI-0063 | RuntimeError responses and logs | The run stopped at its timeout (stop: "timeout") |
MAIN-AI-0064 | RuntimeError responses and logs | The model asked for a tool that isn't in the run's tools; the step was refused |
MAIN-AI-0065 | RuntimeError responses and logs | A handoff or delegation names an agent not declared for it (400) |
MAIN-AI-0066 | RuntimeError responses and logs | A delegation asked for more than the parent's remaining budget or the declared share (402) |
MAIN-AI-0067 | RuntimeError responses and logs | No run with that id in the current tenant, or it isn't waiting (404 or 409) |
MAIN-AI-0071 | RuntimeError responses and logs | Every model in the route failed with a retryable error (502) |
MAIN-AI-0072 | RuntimeError responses and logs | A fallback model was skipped because its endpoint is outside the tenant's region for labelled data (logged) |
MAIN-AI-0073 | RuntimeError responses and logs | A response cache entry is invalid: tenant scope with labelled data, a TTL over 7 days, or local kv in production |
MAIN-AI-0074 | RuntimeError responses and logs | A batch option is invalid: no items, too many, duplicate ids, or no batch API (400) |
MAIN-AI-0075 | RuntimeError responses and logs | No batch with that id in the current tenant (404) |
MAIN-AI-0078 | RuntimeError responses and logs | The local model server is unreachable (503, retryable) |
MAIN-AI-0080 | RuntimeError responses and logs | The model isn't on the local server (404) |
MAIN-AI-0050 | RuntimeError responses and logs | A video option is invalid: seconds, size, prompt or references (400); nothing is sent |
MAIN-AI-0051 | RuntimeError responses and logs | No video job with that id in the current tenant (404) |
MAIN-AI-0052 | RuntimeError responses and logs | The provider failed or refused the video job; the job is failed |
MAIN-AI-0053 | RuntimeError responses and logs | A video job lost its provider heartbeat and was cancelled at the provider |
MAIN-VOICE-0001 | RuntimeError responses and logs | A voice session option is invalid (400) |
MAIN-VOICE-0002 | RuntimeError responses and logs | The voice driver doesn't offer this (501) |
MAIN-VOICE-0003 | RuntimeError responses and logs | Recording refused: recording off, or no consent from the caller (403) |
MAIN-VOICE-0004 | RuntimeError responses and logs | The session reached its duration or cost limit and ended after a closing line |
MAIN-VOICE-0005 | RuntimeError responses and logs | A telephony or room webhook failed signature verification (401) |
MAIN-AI-0069 | RuntimeError responses and logs | See the spec |
MAIN-AI-0002 | RuntimeError responses and logs | The model isn't in the ai.models allowlist (403) |
MAIN-AI-0003 | RuntimeError responses and logs | The model provider answered with an error (502); the message has the status, never the body |
MAIN-AI-0004 | RuntimeError responses and logs | No price is set for the model, so its spend can't be limited |
MAIN-AI-0006 | RuntimeError responses and logs | The driver has no embeddings, for example anthropic |
MAIN-AI-0008 | RuntimeError responses and logs | An agent's model call waits for a person's approval; nothing reached the provider (202) |
MAIN-AI-0012 | RuntimeError responses and logs | A call passed neither or both of prompt and messages (400) |
MAIN-AI-0013 | RuntimeError responses and logs | ai.driver names a driver that isn't built yet |
MAIN-AI-0015 | RuntimeError responses and logs | A speech or transcription option is invalid: both or neither of text and input, an unknown format or sample rate, or empty text (400); nothing is sent |
MAIN-AI-0016 | RuntimeError responses and logs | Voice clone refused: no granted voice-clone consent recorded by the owner in their own session (403) |
MAIN-AI-0017 | RuntimeError responses and logs | The consent statement's transcript doesn't match ai.speech.clone.script, or the statement and samples aren't one speaker (422) |
MAIN-AI-0018 | RuntimeError responses and logs | The voice can't be used: blocked or deleted after consent was revoked or the owner erased, or the caller isn't its owner or in its shared tenant (403) |
MAIN-AI-0019 | RuntimeError responses and logs | A voice that isn't an alias under ai.speech.voices or a cloned voice id |
MAIN-AI-0020 | RuntimeError responses and logs | Speech text holds labelled data and the speech driver's data use doesn't meet ai.data (403); nothing is sent |
MAIN-AI-0021 | RuntimeError responses and logs | The driver doesn't offer this: speech, transcription, streaming, diarisation or cloning (501) |
MAIN-AI-0022 | RuntimeError responses and logs | Deleting a cloned voice at the provider failed; the voice stays blocked and the delete is retried (logged) |
MAIN-AI-0023 | RuntimeError responses and logs | Audio is larger than ai.speech.max-audio, or a live transcription passed max-duration (413) |
MAIN-AI-0076 | RuntimeError responses and logs | The store that counts ai.limits did not answer and the call is under a spend limit, so it is refused (503, retryable) |
MAIN-EVAL-0001 | RuntimeError responses and logs | An eval set is invalid, or its judge model is the feature's own model (exit 2) |
MAIN-EVAL-0005 | RuntimeError responses and logs | The eval run would pass --max-cost (exit 2) |
MAIN-VOICE-0006 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Streaming and schema-checked typed output | Phase 1 | 0.3 | ai#stream-structured |
| Generate and embed | Phase 1 | 0.3 | Not specified yet |
| Tools that are MainSDK actions | Phase 1 | 0.3 | ai#tools |
| Extract typed data from text with a schema | Phase 1 | 0.3 | ai#extract |
| Classify into one of your labels | Phase 1 | 0.3 | ai#classify-summarize |
| Summaries in short | Phase 1 | 0.3 | ai#classify-summarize |
| Model parser for parse.first | Phase 1 | 0.3 | ai#extractor |
| Schema and repairs in generate | Phase 1 | 0.3 | ai#generate-schema |
| Every model verb through redaction | Phase 1 | 0.3 | ai#verb-guards |
| Images, PDFs, audio and video frames as prompt parts through files | Phase 1 | 0.4 | ai-multimodal#verbs |
| Image generate, edit, inpaint and variations, output through files | Phase 1 | 0.4 | ai-image#edit |
| Disclosure metadata and C2PA content credentials on AI images | Phase 1 | 0.4 | ai-image#c2pa |
| Agent loop with ai.run: action tools, bounds, approval, handoff and delegation | Phase 1 | 0.4 | ai-run#loop |
| Fallback routes that never weaken data use or region | Phase 1 | 0.4 | ai-routing#fallback |
| Prompt caching and a tenant-safe response cache | Phase 1 | 0.4 | ai-routing#response-cache |
| Batch jobs at batch prices | Phase 1 | 0.4 | ai-routing#batch |
| Local models through Ollama, llama.cpp and OpenAI-compatible servers | Phase 1 | 0.5 | ai-local#drivers |
| Eval sets with a CI regression gate and the result in the seal | Phase 1 | 0.5 | ai-evals#gate |
| AI video as a durable long job | Phase 2 | 0.6 | ai-video#job |
| Realtime voice agents on phone lines and WebRTC rooms | Phase 2 | 0.6 | ai-voice#session |
Quick start
Declare the driver, the key and the models you allow, then call ai.generate(). The anthropic driver is the simplest start: one provider key and no cloud account.
ai:
driver: anthropic
key: secret:ANTHROPIC_API_KEY
models: [anthropic/claude-*] # only these may be called
data: { training: never, retention: zero }
limits: { per-user: $1/day, total: $500/month }app/api/orders/[id]/summary/route.ts
import { ai, auth, models } from "mainsdk";
import { s } from "mainsdk/schema";
const { orders } = models({ orders: s.object({ total: s.string() }) });
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth.require(req);
const order = await orders.for(session.user).get(id);
const summary = await ai.generate({
model: "anthropic/claude-sonnet-4-5",
feature: "order-summary", // limits and audit per feature
user: session.user, // limits and audit per user
prompt: `Summarise this order for its owner: ${JSON.stringify(order)}`,
});
return Response.json({ text: summary.text, cost: summary.cost });
}use mainsdk::prelude::*;
let app = App::test();
Context::new().tenant("tnt_a").user("u1").scope(async {
let answer = app.ai().generate("Say hello").await?;
assert!(!answer.text.is_empty());
Ok::<_, MainError>(())
}).await?;Use prompt for one instruction as a string, and messages for a conversation of role and content turns. Emails, phone numbers and other labelled values in the prompt reach the provider as placeholders, and the reply comes back with the values restored inside your app.
What comes back
type Money = string // "$0.05"; maxCost and every limit use it
type Usage = { inputTokens: number; outputTokens: number }
type Generation<T = string> = { value: T; text: string; usage: Usage; cost: Money; model: string }
type Extraction<T> = { value: T; via: "model"; cost: Money; usage: Usage; model: string; repairs: number }
type Chunk = { type: "text"; text: string } | { type: "done"; usage: Usage; cost: Money }generate returns a Generation: with a schema, value is the typed result and text is the raw reply. stream yields Chunk values: text chunks as they arrive, then one done chunk with the usage and cost.
Which driver
| Driver | Use it when |
|---|---|
anthropic | You have an Anthropic key. The default for a solo builder. |
openai | You use OpenAI models. |
local | You build or test. An offline driver that needs no key and no network; fake is its older name. |
bedrock | Planned. You run on AWS. main.yaml refuses it with MAIN-AI-0013 until it ships. |
gateway | Planned as a main.yaml driver: one gateway in front of several providers. It already serves route models and images (below). |
Configuration
Every key below lives under ai:.
ai:
driver: anthropic # anthropic | openai | local (fake); bedrock and gateway are planned here
key: secret:ANTHROPIC_API_KEY # a secret reference, never a literal
models: [anthropic/claude-*] # allowlist; a model not listed cannot be called
data:
training: never # refuse any provider, model or account mode that trains on prompts
retention: zero # refuse any that keeps prompts beyond the call
redact: [personal, sensitive, child, phi] # canonical data labels removed before the call
limits:
per-user: $1/day # spend limit per user
per-feature: { order-summary: $200/month } # spend limit per feature name
total: $500/month # spend limit for the whole app
label: true # AI disclosure and machine-readable content labels
prices: # per million tokens; a model with no price cannot be called
anthropic/claude-sonnet-4-5: { input: $3, output: $15 }| Key | Type | What it does |
|---|---|---|
driver | string | The provider that runs the model |
models | list of patterns | Models your code may call; anything else is refused |
data.training | string | Data use you accept (never). Each driver declares its own. Media labelled with a data label is refused at run time when the driver doesn't meet this; the start-up check (MAIN-AI-0001) is planned |
data.retention | string | Retention you accept (zero), checked the same way |
redact | list of labels | Data labels the built-in redactor removes before the call |
key | secret: reference | The provider key, when the driver needs one |
limits.per-user | money per period | Spend limit per user, enforced before the call |
limits.per-feature | map of feature to money per period | Spend limit per feature name |
limits.total | money per period | Spend limit for the app |
prices | map of model to price | Price per model (input, output, and for media image, page, minute). The local driver prices everything at $0 |
routes | map of name to models | Fallback routes, called as route:<name> (below) |
cache | map | Prompt caching and per-feature response cache (below) |
input | map | Limits on what a prompt may carry: image, document, audio, video, max-parts (below) |
batch | map | drivers and max-items for batch jobs |
image | map | driver, key, max-count, sizes for images |
credentials | map | signer, trust, required, claim-generator for content credentials on images |
label | boolean | Marks generated content ai-generated and shows an AI disclosure in chat UI helpers |
Model hosts come from each driver's declared hosts under network, so you don't add them to network.allow yourself. Provider keys, when a driver needs them, are secret: references and never literals.
Overlays by market and environment
Spend limits and allowed models often differ by environment. Tighten them in an overlay:
ai:
driver: local # the built-in local fake while you build
limits: { total: $10/month }Overlays may tighten a parent but never weaken it without an exception. See main.yaml.
What you get
Every call runs in the current tenant, checks the model against ai.models, and is written to the audit log.
| Call | What it does |
|---|---|
ai.generate({ model, prompt, feature, user }) | Get a reply with its text, usage and cost; prompt is a string and messages a conversation |
ai.stream({ model, prompt, feature }) | The same call as a stream of chunks |
ai.embed({ model, input, feature }) | Embeddings for a string or a list of strings |
ai.extract, ai.classify, ai.summarize | Typed results from a schema, a label list or a length, repaired and checked |
ai.run({ model, feature, agent, goal, tools, maxSteps, budget }) | An agent loop with step, budget and time limits |
Always:
- Personal data goes to the provider as placeholders and comes back restored inside your app.
- A call that would pass a per-user, per-feature or total limit is refused with
MAIN-AI-0007. - Generated content carries an AI label, and prompt injection in inputs and tool results is isolated.
Examples
Chat with tools
ai.tool() turns an action into a tool the model can call. Only functions declared with action() can become tools, they run the same access check as every other door, and destructive actions still need a person's yes.
import { action, ai, auth } from "mainsdk";
import type { Message } from "mainsdk/ai";
import { s } from "mainsdk/schema";
const listOrders = action({
name: "orders.list",
intent: "List the signed-in user's orders",
input: s.object({}),
risk: "read",
run: async () => [],
});
const refundOrder = action({
name: "orders.refund",
intent: "Refund an order the signed-in user owns",
input: s.object({ orderId: s.string() }),
risk: "destructive",
run: async ({ input }) => ({ refunded: input.orderId }),
});
export async function POST(req: Request) {
const session = await auth.require(req);
const { messages } = (await req.json()) as { messages: Message[] };
const run = await ai.run({
model: "anthropic/claude-sonnet-4-5",
feature: "chat",
agent: "support-bot", // declared under agents in main.yaml
user: session.user,
messages,
tools: [ai.tool(listOrders), ai.tool(refundOrder)],
maxSteps: 5,
});
return Response.json(run);
}Tool results go back to the model through the injection check: an instruction hidden in a tool result is wrapped as untrusted data and the call is logged with MAIN-AI-0005, never followed.
Stream a reply under a spend limit
ai.stream() takes the same options as ai.generate() and returns an AsyncIterable<Chunk>. It applies redaction, limits and audit the same way. The limit is checked before the call and again as the stream runs: when the stream reaches it, the stream stops, the caller gets MAIN-AI-0007, and one audit event records the tokens used. One audit event is written whether the stream ends or aborts. maxCost limits a single call, as a Money string.
The server route writes one JSON object per line (NDJSON):
import { ai, auth, MainError } from "mainsdk";
import type { Message } from "mainsdk/ai";
export async function POST(req: Request) {
const session = await auth.require(req);
const { messages } = (await req.json()) as { messages: Message[] };
const stream = ai.stream({
model: "anthropic/claude-sonnet-4-5",
feature: "chat",
user: session.user,
messages,
maxCost: "$0.05",
});
const encoder = new TextEncoder();
const body = new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n")); // { type: "text", text } or { type: "done", usage, cost }
}
} catch (err) {
if (err instanceof MainError && err.code === "MAIN-AI-0007") {
controller.enqueue(encoder.encode(JSON.stringify({ type: "limit" }) + "\n")); // tell the user, don't retry
} else {
throw err;
}
}
controller.close();
},
});
return new Response(body, { headers: { "content-type": "application/x-ndjson" } });
}The { type: "limit" } line is this app's own signal, not a Chunk. The browser reads the stream with fetch, decodes the bytes, splits lines, parses each one and appends the text:
export async function readChat(
messages: { role: string; content: string }[],
onText: (text: string) => void,
) {
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages }),
});
if (!res.body) throw new Error("no stream");
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; // keep a partial line for the next read
for (const line of lines) {
if (!line) continue;
const chunk = JSON.parse(line);
if (chunk.type === "text") onText(chunk.text);
if (chunk.type === "done") return chunk; // { usage, cost }
if (chunk.type === "limit") throw new Error("limit reached");
}
}
}Typed output with a schema
Pass schema to ai.generate() and the reply is checked against it before it returns. The schema's JSON Schema goes to the model, and the reply is checked against it. A reply that does not match is repaired: the call sends the original request again with the issue paths and codes, never the bad reply. It is repaired once by default, or up to twice with repairs. If the reply still does not match, the call throws MAIN-AI-0011 and returns no value, so an invalid reply is never returned as if it were valid.
import { ai, auth, MainError } from "mainsdk";
import { s } from "mainsdk/schema";
const Triage = s.object({
urgency: s.enum(["low", "high"]),
topic: s.string(),
});
export async function POST(req: Request) {
const session = await auth.require(req);
const { text } = (await req.json()) as { text: string };
try {
const triage = await ai.generate({
model: "anthropic/claude-sonnet-4-5",
feature: "ticket-triage",
user: session.user,
prompt: `Classify this support message: ${text}`,
schema: Triage, // any native schema, Standard Schema value or { parse }
repairs: 1, // 0, 1 or 2; 1 by default
});
return Response.json(triage.value); // a Generation<T>: value is typed, text is the raw reply
} catch (err) {
if (err instanceof MainError && err.code === "MAIN-AI-0011") {
return Response.json({ problem: err.toProblem() }, { status: 502 }); // no value was produced
}
throw err;
}
}Extract, classify and summarize
Four verbs take app data and return a checked result. They apply the same model allowlist, redaction, injection check, limits, agent budget, audit event and AI label as ai.generate(). Each audit event names the verb and its repair count, and never holds the input.
ai.extract() turns text into a typed value. It sends your schema's JSON Schema to the model and puts the input in one <data> block that the model must read as data, never as instructions. A reply that does not match is repaired, as above.
import { ai, auth } from "mainsdk";
import { s } from "mainsdk/schema";
const Invoice = s.object({
number: s.string().max(40),
total: s.number().min(0),
currency: s.enum(["USD", "EUR", "INR"]),
});
export async function POST(req: Request) {
const session = await auth.require(req);
const { email } = (await req.json()) as { email: string };
const found = await ai.extract({
input: email,
schema: Invoice,
model: "anthropic/claude-sonnet-4-5",
feature: "invoice-intake",
user: session.user,
repairs: 2, // optional; 1 by default
});
return Response.json({ invoice: found.value, cost: found.cost, repairs: found.repairs });
}ai.classify() returns one of your labels, exactly as you wrote them. The request sends an enum of the labels. A reply outside the labels is repaired, and a reply that never matches fails with MAIN-AI-0011.
import { ai } from "mainsdk";
export async function triage(ticket: string, user: { id: string }) {
const label = await ai.classify({
input: ticket,
labels: ["billing", "bug", "other"],
model: "anthropic/claude-sonnet-4-5",
feature: "ticket-triage",
user,
});
return label.value; // "billing", "bug" or "other"
}ai.summarize() returns text only, in one of three lengths: short allows at most 128 output tokens, medium 384 and long 1024.
import { ai } from "mainsdk";
export async function summarise(notes: string, user: { id: string }) {
const summary = await ai.summarize({
input: notes,
length: "short", // "short", "medium" or "long"
model: "anthropic/claude-sonnet-4-5",
feature: "order-summary",
user,
});
return summary.text;
}ai.extractor(schema, options) returns a parser for parse.first. It runs only when the cheaper parsers before it do not match, and its hit carries via: "model" and the cost.
import { ai } from "mainsdk";
import { parse } from "mainsdk/parse";
import { s } from "mainsdk/schema";
export const findOrder = parse.first(
parse.exact(["refund"]),
ai.extractor(s.object({ orderId: s.string() }), { model: "anthropic/claude-sonnet-4-5", feature: "support" }),
);Invalid options fail before any request. repairs must be 0, 1 or 2, a label list must be non-empty with no repeats, and length must be one of the three sizes. Otherwise the call throws MAIN-AI-0014.
Label generated content
With label: true under ai in main.yaml, ai.generate() returns the text with label: "ai-generated", the machine-readable marker (EU AI Act Art. 50). For a chat UI, ai.disclose(generation) returns the text with the marker and the disclosure to show beside it.
import { ai } from "mainsdk";
const model = "anthropic/claude-sonnet-4-5";
const prompt = "Say hello";
const g = await ai.generate({ model, feature: "chat", prompt });
const view = ai.disclose(g); // with ai.label on: { text, marker: "ai-generated", disclosure: "This reply was written by AI." }With ai.label off, disclose returns the text only.
Model output is untrusted
A schema check proves the shape of a reply, not that its content is safe or true. Treat everything a model returns as untrusted input to your app, the same as a form field:
- Validate it again where it matters, and never build SQL, shell commands, file paths or HTML from it directly.
- Don't let it choose which user, record or amount an action touches. Take those from the signed-in session, not from the reply.
- Let a model act only through actions with
ai.tool(), which run the sameaccesscheck as every other door and need a person's yes when destructive.
Spend limits per call and per user
Limits are checked before the request leaves your app. At the limit the call fails with MAIN-AI-0007 and nothing reaches the provider, so you never overspend. The optional maxCost option, such as "$0.05", limits a single call.
import { Hono } from "hono";
import { ai, auth, MainError } from "mainsdk";
const app = new Hono();
app.post("/draft", async (c) => {
const session = await auth.require(c.req.raw);
const { topic } = (await c.req.raw.json()) as { topic: string };
try {
const draft = await ai.generate({
model: "anthropic/claude-sonnet-4-5",
feature: "drafts",
user: session.user,
prompt: `Write a short product update about ${topic}`,
});
return c.json(draft);
} catch (err) {
if (err instanceof MainError && err.code === "MAIN-AI-0007") {
return c.json({ limitReached: true, problem: err.toProblem() }); // tell the user, don't retry
}
throw err;
}
});
export default app;Charge a call to an app agent
When an app agent makes the call, name it. The call is charged to that agent's budget and audited with that actor, and a name that is not under agents fails with MAIN-AGENT-0001. The agent's budget is set in main.yaml, not in ai.limits.
import { ai } from "mainsdk";
const model = "anthropic/claude-sonnet-4-5";
const messages = [{ role: "user" as const, content: "Where is my order?" }];
const user = { id: "u_1" };
await ai.generate({ model, messages, feature: "support", agent: "support-bot", user });agents:
support-bot:
budget: { spend: $5/day, calls: 500/day }See Agents with an identity, a budget and approvals.
Embeddings for search
ai.embed() returns one vector per input, with the same redaction, limits and audit.
import { ai } from "mainsdk";
// model: any embedding model on your ai.models allowlist
export async function embedProducts(model: string, descriptions: string[]) {
const vectors = await ai.embed({
model,
input: descriptions,
feature: "product-search",
});
return vectors; // number[][]
}Agent loop
ai.run() asks a model for a step, runs the tools it names, and goes round until the model answers or a bound is reached. Each model turn is a normal guarded ai call, and each tool is an action that runs its own access check. The run is charged to a named agent, never above that agent's budget.
import { ai } from "mainsdk";
import { action } from "mainsdk/action";
import { s } from "mainsdk/schema";
const getOrder = action({
name: "orders.get",
intent: "Get an order",
input: s.object({ id: s.string() }),
risk: "read",
run: async ({ input }) => ({ id: input.id, total: 5 }),
});
export async function ask(goal: string, user: { id: string }) {
const run = await ai.run({
model: "anthropic/claude-sonnet-4-5",
feature: "support",
agent: "support-bot", // declared under agents in main.yaml
user,
goal, // or messages, never both
tools: [ai.tool(getOrder)],
maxSteps: 5, // 1 to 50, 10 by default
budget: "$0.50", // never above the agent's remaining budget
});
return run.status === "done" ? run.text : run.status; // done | waiting | handed-off | stopped
}run.status is waiting when a destructive tool needs a person's yes: finish the approval, then call ai.run.resume(run.id). ai.run.stream() takes the same options and yields model, tool-call, tool-result, waiting and done events. ai.run.get(id) shows a run's state without its conversation. A run that hits maxSteps, its budget or its timeout ends with status: "stopped" and a stop reason. Options also include output (a schema), handoffs and delegate for other agents, and durable for waits that survive a restart.
Fallback routes
A route is a named list of models tried in order. Declare it, then call route:<name> as the model.
ai:
routes:
support-chat: [anthropic/claude-sonnet-4-5, anthropic/claude-haiku-4-5]import { ai } from "mainsdk";
const user = { id: "u_1" };
const prompt = "Where is my order?";
const reply = await ai.generate({ model: "route:support-chat", feature: "chat", user, prompt });
console.log(reply.servedBy, reply.fallbacks); // the model that answered, and how many failed firstA fallback never weakens your rules: a model whose driver breaks ai.data, sits outside the tenant's region, or can't take the prompt's media is skipped. If every model fails the call throws MAIN-AI-0071. Every model on a route must also be on the models allowlist and have a price.
Caching
Prompt caching is on by default for drivers that support it (cache.prompt: auto); the reply's cache is prompt when the provider served input from its cache. A response cache is opt-in per feature, kept for at most 7 days and scoped to the user or the tenant:
ai:
cache:
prompt: auto # auto | off
response:
faq: { ttl: 1h, scope: user } # user | tenantThe second identical call to feature faq for the same user returns cache: "hit" with no provider call. A tenant-wide cache cannot hold labelled data (MAIN-AI-0073).
Batch jobs
ai.batch.submit() sends many requests through a provider's batch API at its lower price. Each item goes through redaction, the injection check and the limits first, and the results are written to files.
import { ai } from "mainsdk";
const job = await ai.batch.submit({
model: "anthropic/claude-sonnet-4-5",
feature: "bulk-summaries",
items: [{ id: "a", prompt: "Summarise A" }, { id: "b", prompt: "Summarise B" }], // distinct ids
});
const later = await ai.batch.get(job.id); // state: queued | running | ready | failed | cancelled
// later.results is a file when state is "ready"; later.saved is what batch pricing saved youai.batch.cancel(id, { reason }) stops a job. The driver must have a batch API: the local driver has none, so a batch against it fails with MAIN-AI-0074. Run-verified: that error only; a finished batch needs a provider account.
Images
ai.image makes and edits images under the same rails as text: allowlist, redaction, limits, audit. Outputs are written to files, labelled ai-generated and carry disclosure metadata. When ai.credentials.signer is set they are also signed with a C2PA content credential.
ai:
image: { driver: openai, max-count: 4, sizes: ["1024x1024"] } # openai | gateway | replicate | local
credentials: { signer: local:certs/ai.pem, required: false }import { ai } from "mainsdk";
const user = { id: "u_1" };
const out = await ai.image.generate({
model: "openai/gpt-image-1",
feature: "avatars",
user,
prompt: "A lighthouse at dusk",
size: "1024x1024",
});
const { file, credentials } = out.images[0]; // file is a FileRef; credentials is "signed" or "metadata"ai.image.edit({ image, prompt }), ai.image.inpaint({ image, mask, prompt }) and ai.image.variations({ image, count }) take images as file references. ai.credentials.verify(file, { user }) reads the file as that user, so a missing or other user fails with MAIN-FILES-0002, and reports verified, signed, invalid or none. Set files.maxSize to at least 64MB in main.yaml, or saving an output fails with MAIN-FILES-0003.
Images, PDFs and audio in a prompt
Message content may be a list of parts: text, image, document (PDF), audio and video. Media is read from files with the caller's rights, stripped of metadata and checked against ai.input limits before it is sent.
import { ai } from "mainsdk";
const user = { id: "u_1" };
const out = await ai.image.generate({ model: "openai/gpt-image-1", feature: "avatars", user, prompt: "A lighthouse at dusk" });
await ai.generate({
model: "anthropic/claude-sonnet-4-5",
feature: "describe",
user,
messages: [{ role: "user", content: [
{ type: "text", text: "Describe this picture" },
{ type: "image", file: out.images[0].file },
] }],
});Data labelled personal, sensitive, child or phi goes only to a driver that meets ai.data and is in the tenant's region (MAIN-AI-0043). A driver that can't take a kind of media fails with MAIN-AI-0042.
Errors and checks
Errors are MainError values that serialise as RFC 9457 problem details with a code, a fix and a docs link.
MAIN-AI-0001is a plannedmainsdk checkfinding, not built yet. It will fail when a driver's declared data use doesn't meetai.data, for example a driver that declarestraining: opt-out. Pick a driver, model or account mode with no training and no retention.MAIN-AI-0005is logged when an input or tool result is flagged as prompt injection and isolated. Review the flagged source.MAIN-AI-0007refuses a call that would pass a per-user, per-feature or total limit. Raise the limit inai.limits, or tell the user they hit their limit.MAIN-AI-0011failsai.generate(),ai.extract()orai.classify()when the reply still does not match after its repairs (one by default, up to two withrepairs). No value is returned. Loosen the schema, or fix the prompt so the model can meet it.MAIN-AI-0014refuses a verb option the spec does not allow, such asrepairs: 3, an empty or repeated label list, or an unknown summarylength. No request is sent.MAIN-AI-0013refuses a drivermain.yamldoesn't build yet (bedrock,gateway). Useanthropic,openaiorlocal.MAIN-AI-0040andMAIN-AI-0041: a media part could not be read (send file references the user may read), or it is over anai.inputlimit.MAIN-AI-0042andMAIN-AI-0043: no driver can take that media, or labelled media would go to a driver that breaksai.dataor the tenant's region.MAIN-AI-0045,MAIN-AI-0046,MAIN-AI-0047,MAIN-AI-0048: an invalid image option, an operation the image driver doesn't offer, a content-policy refusal from the provider, and a content credential that could not be signed undercredentials.required.MAIN-AI-0060toMAIN-AI-0067:ai.runproblems.0060invalid options,0061to0063stopped at max steps, budget or timeout,0064a tool that isn't in the run,0065a handoff or delegate not declared,0066a delegated budget that is not allowed,0067an unknown run or batch id.MAIN-AI-0071andMAIN-AI-0073: every model of a route failed; a response cache that lives over 7 days or holds labelled data tenant-wide.MAIN-AI-0074andMAIN-AI-0075: an invalid batch (or a driver with no batch API), and no batch with that id in this tenant.MAIN-AI-0010stops the app at start whenai.tool()gets a function not declared withaction(). Wrap it inaction().
Only models in ai.models may be called. Run mainsdk explain MAIN-AI-0007 for the cause and fix offline.
Swapping drivers
Change one line. Your calls to ai.generate(), ai.stream(), ai.embed() and ai.tool() stay the same, and so do redaction, limits, audit and labels.
ai:
driver: anthropic # was gatewayAI calls are stateless, so there is nothing to migrate. The new driver must declare data use that meets ai.data, (the mainsdk check finding MAIN-AI-0001 for this is planned), and its provider key, if it needs one, is a secret: reference. See swap a driver.
ai.html(text, opts?) turns model text (Markdown) into safe HTML: it calls sanitize.markdown with source: "ai", so images are dropped, links go only to declared hosts and raw HTML is escaped. It makes no model call.
A provider-only escape hatch, ai.raw(), is planned; it does not exist yet.
Testing
createTestApp() puts ai on its local fake, with redaction, limits and audit enforced as in production. A test main.yaml with a zero limit makes the limit test deterministic:
ai:
driver: local
models: [anthropic/claude-*]
data: { training: never, retention: zero }
limits: { per-user: $0/day }import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { ai } from "mainsdk";
describe("ai limits", () => {
it("ai.AC4 refuses a call over the per-user limit before it reaches the provider", async () => {
createTestApp({ main: "./test/main.yaml" });
const user = { id: "u_1" };
await assert.rejects(ai.generate({ model: "anthropic/claude-sonnet-4-5", feature: "chat", user, prompt: "hello" }), { code: "MAIN-AI-0007" });
});
});See the testing kit.
Security and privacy
- Personal data is redacted before the call by the built-in redactor, guided by your data labels. Restoring it is possible only inside your app.
- Providers, models or account modes that train on prompts or keep them are refused.
- Every call writes one audit event with feature, model, user, token counts, cost, decision and redaction count, never the raw prompt.
- People are told they are talking to AI, and generated content carries a machine-readable label (EU AI Act Art. 50).
- Provider keys are never in code, only
secret:references.
FAQ
Can I call a model that isn't in ai.models? No. Add it to the allowlist first, so every model in use is visible in main.yaml.
How is "no retention" proven for a provider? Each driver declares its data use and labelled media is refused at run time when it doesn't meet ai.data. A start-up check that compares them is planned.
Do I need MainSDK Cloud for limits or audit? No. Limits, redaction and audit all run in your app and are free.
Are evals part of this? Eval sets with a CI gate are planned (spec ai-evals); there is no code for them yet. Video and voice are covered in Video and Voice.
Video
ai.video makes a clip as a durable job, under the same rails as text and images: allowlist, redaction, limits and audit. The worst-case cost is checked before the job starts, a restart resumes the job, and the finished file is written to files, labelled ai-generated, with disclosure metadata.
ai:
video: { driver: openai, max-seconds: 8 } # openai | google | gatewayimport { ai } from "mainsdk";
const user = { id: "u_1" };
const job = await ai.video.generate({ model: "openai/sora-2", prompt: "A lighthouse at dusk", feature: "clips", seconds: 8, user });
const done = await ai.video.get(job.id); // queued, running, ready, failed or cancelled; the file when ready
await ai.video.cancel(job.id, { reason: "not needed" });cancel stops the job at the provider and records any cost already billed. Asking for more than ai.video.max-seconds is refused before any provider call. Run-verified offline against the fake driver and a stepped workflow host; a real clip needs a provider account.
Voice
ai.voice runs a spoken conversation for an agent over a phone line or a room. The first event of every session says the caller is talking to AI, tools run as MainSDK actions with the caller's access, and a session ends at max-duration or max-cost with a closing line.
ai:
voice:
driver: openai-realtime
disclosure: This call uses an AI assistant.
telephony:
driver: twilio
account-sid: secret:TWILIO_SID
auth-token: secret:TWILIO_TOKEN
webhook-url: https://app.example.com/voice
stream-url: wss://app.example.com/voice/stream
stream-secret: secret:VOICE_STREAM
numbers: { "+15550100": support }
rooms:
driver: livekit
url: wss://acme.livekit.cloud
api-key: lk-key
api-secret: secret:LK_SECRET
bridge: wss://media.example.com/rooms/{room}
sessions:
support: { voice: alloy, instructions: "Help with orders", feature: voice-support, agent: support }| Call | What it does |
|---|---|
ai.voice.session({ model, voice, instructions, feature, agent }) | Opens a spoken session. Send caller audio with send, read events, end it with close() for a summary |
ai.voice.calls.handle(req, session?) | Answers an inbound call webhook after verifying its signature. Without a session, the number called picks one from numbers; an unknown number gets a 404 with MAIN-VOICE-0001 |
ai.voice.calls.dial({ to, from, session }) | Places an outbound call and returns its callId |
ai.voice.calls.upgrade({ req, socket, head }) | The media stream route: call it from your HTTP server's upgrade event. It checks the one-call token in the stream URL, completes the WebSocket handshake and runs the session. A refused upgrade is answered 401 with MAIN-VOICE-0005 before 101 and resolves undefined |
ai.voice.calls.attach(callId, socket) | Runs the session on a media socket you accepted yourself, such as on a runtime with its own upgrade API |
ai.voice.rooms.join({ room, session }) | Joins a room as the agent |
ai.voice.rooms.token({ room, user }) | Issues a token scoped to one room and one user |
A caller who speaks over the agent interrupts it (barge-in), and the model keeps only what the caller heard. A recording needs record: consent and the caller's recorded call-recording consent, or it is refused with MAIN-VOICE-0003. mainsdk check reports a routed number with no disclosure (MAIN-VOICE-0010) and driver hosts missing from network.allow (MAIN-VOICE-0011).
Phone calls
With stream-url and stream-secret set, the answer to a webhook (and the request of a dial) hands the carrier a stream URL with a token in its path that binds one call and an expiry (ttl from stream-ttl, 10 minutes by default). Twilio drops a query string from a Stream URL, which is why the token is in the path. Your server forwards its upgrade event:
import { createServer } from "node:http";
import { ai } from "mainsdk";
const server = createServer();
server.on("upgrade", (req, socket, head) => {
if (req.url?.startsWith("/voice/stream/")) void ai.voice.calls.upgrade({ req, socket, head });
});The socket follows RFC 6455: frames from the carrier must be masked, a message over the limit closes it with 1009, text that isn't UTF-8 with 1007, pings are answered, a silent carrier is pinged and then dropped, and agent audio waits while the carrier isn't reading. Twilio and Telnyx send 8 kHz mu-law in JSON frames; the driver converts it to and from 16 kHz PCM. A token that is forged, expired, already used, or for a call nothing waits for is refused before the socket opens.
Rooms
ai.voice.rooms.token signs a LiveKit access token (or asks Daily for a meeting token) for one room and one user, for 10 minutes unless you pass ttl. ai.voice.rooms.join makes sure the room exists through the provider's REST API, and for LiveKit opens the signalling socket at /rtc, checks that the join answer names the agent and the room, keeps it alive with pings and sends a leave request at the end.
LiveKit and Daily carry audio over WebRTC, and MainSDK has no WebRTC stack. The agent's audio therefore flows over a media bridge you run next to the room, at rooms.bridge (a wss:// URL, with {room} replaced by the room name). The agent opens it with its room token as a bearer credential; the bridge's first message is {"type":"joined"}, binary messages are 16 kHz 16-bit PCM in both directions, and {"type":"room-ended"} ends the session. Without a bridge, join fails with MAIN-VOICE-0002; a bridge that doesn't say it joined in time fails with MAIN-VOICE-0006. You can pass your own transport instead, as connect on the driver.
Run-verified offline against recorded provider APIs, a real WebSocket on the stream route, and local LiveKit-shaped and Daily-shaped room servers on loopback sockets (localLivekitServer and localDailyServer in @mainsdk/testing). A live call and a live room need a carrier account and a LiveKit or Daily account with a media bridge.
Speech, transcription and voices
ai.speech turns text into audio, ai.transcribe turns audio into text, and ai.voices manages the voices you may use. They follow every rule of the other ai calls: the ai.models allowlist, the ai.data promise, per-user and per-feature limits, one audit event per call that never holds text or audio, and a price from ai.prices.
import { ai } from "mainsdk";
const bytes = new Uint8Array(0); // recorded audio, for example read from `files`
const speech = ai.speech({ model: "eleven_flash_v2_5", voice: "narrator", text: "Your order shipped.", feature: "notify" });
speech.disclosure; // { "ai-generated": true, model, provider, voice: "stock", createdAt }
const file = await speech.bytes(); // the whole mp3, with the disclosure in the container
const t = await ai.transcribe({ model: "nova-3", audio: bytes, diarize: true, feature: "calls" });
console.log(t.text, t.segments);ai.speech({ model, voice, text | input, format?, sampleRate?, feature })returns a stream of audio chunks. Pass exactly one oftextandinput(anAsyncIterable<string>, such as the text ofai.stream), or the call fails withMAIN-AI-0015.voiceis an alias underai.speech.voicesor a cloned voice idvc_…; app code never names a provider voice.cancel()stops the provider and charges only what was made.ai.transcribe({ model, audio, format?, language?, diarize?, feature })returns{ text, segments, language, seconds, cost, model }. Audio larger thanai.speech.max-audiofails withMAIN-AI-0023.ai.transcribe.stream({ model, audio, format, sampleRate, interim?, feature })yieldspartial(wheninterimis true) andfinalsegments in time order, then onedoneevent with the whole transcript. It ends withdoneatai.speech.max-duration.ai.voices.list()lists the aliases and the cloned voices the caller may use.ai.voices.clone({ name, samples, statement, share?, feature })makes a cloned voice. It needs the owner's own recordedvoice-cloneconsent (MAIN-AI-0016), and the spoken statement must matchai.speech.clone.script(MAIN-AI-0017).ai.voices.delete(id, { reason })blocks the voice at once and deletes it at the provider; a blocked voice fails withMAIN-AI-0018.ai.voices.share(id, "owner" | "tenant")changes who may speak with a voice. Only its owner may call it.
Every speech output carries an ai-generated disclosure. The local driver turns text into deterministic tones and back, so tests run offline.
Reference
These exports build or test the ai interface. Most apps never call them: main.yaml builds the interface for you, and createTestApp() swaps in the local drivers.
| Export | What it does |
|---|---|
createAi(opts) | Builds the ai interface over a driver, a price table, limits and optional agents, files and audit log. opts.driver and opts.prices are required. |
new ProviderError(code, message, opts?) | A driver throws this when a provider fails. It is a MainError with providerStatus, and refusal when the provider refused the prompt under a content policy. The HTTP status defaults to 502. |
localAiDriver(opts?) | The text driver that needs no account: scripted or echoing replies, with every request recorded on requests. |
localImageDriver(opts?) | The offline image driver: deterministic test patterns from the prompt, with every request recorded on calls. |
fakeImageDriver(opts?) | The same driver under the name fake. |
openaiImageDriver(opts) | Image generations, edits (with a mask for inpainting) and variations through the OpenAI Images API. opts takes fetch and apiKey. |
gatewayImageDriver(opts) | Image models through your own fal or Replicate account. Set opts.provider to fal or replicate. |
localSigner(opts) | Builds a C2PA signer from a PKCS#8 PEM private key (Ed25519 or P-256) and a PEM certificate chain, leaf first. An unreadable key or chain fails with MAIN-AI-0048. |
signAsset(asset, mime, { req, signer }) | Embeds a signed C2PA manifest in a jpeg, png or webp image and returns the new bytes. |
verifyAsset(asset, mime, opts?) | Reads and verifies an image's content credentials. It never throws on hostile input; a malformed file reports invalid. |
memoryStore() | Keeps runs and batch jobs in this process. This is the default. |
fileStore(dir) | Keeps runs and batch jobs as JSON files under dir, written by rename so a crash never leaves half a file. |
inlineWorkflows(timers?) | A workflow host that polls waiting jobs on timers in this process. tick() polls by hand in a test and watching() counts the jobs. |
looksLikeInjection(text) | Returns true when text looks like a prompt injection attempt (MAIN-AI-0005). |
meetsData(declared, required) | Returns true when a driver's declared training and retention keep the promise ai.data makes. |
parseSse(body) | Parses a byte stream of server-sent events into { event?, data } objects. |
Next
- Actions and agent tools: write the tools a model can call.
- Network permissions: where model hosts come from.
- Logs, audit and telemetry: read the audit events for each call.
Was this page helpful?