MainSDK
Search⌘K
Interfaces

AI and agents

AI calls

ai

Call any model through one interface, with personal data redacted first, no training or retention, spend limits per user and every call audited.

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec aiFree in your app, Apache-2.0

Status 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.
Stability levels
Updated 2026-10-28

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 }

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.

main.yamlLocal (no account (default)
ai:
  driver: local

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-AI-0001Checkmainsdk check, before deployThe driver's training setting is weaker than ai.data.training
MAIN-AI-0044Checkmainsdk check, before deployA prompt part is built from request body bytes or a data: URL instead of a FileRef
MAIN-AI-0049Checkmainsdk check, before deployAI images are generated with no ai.credentials.signer (warning)
MAIN-AI-0068Checkmainsdk check, before deployA tool passed to ai.run isn't ai.tool() of a declared action
MAIN-AI-0070Checkmainsdk check, before deployA route lists a model not in ai.models, unpriced, or whose driver doesn't meet ai.data
MAIN-AI-0079Checkmainsdk check, before deployA local endpoint uses plain HTTP off the machine, isn't in network.allow, or a remote one lacks data or region
MAIN-VOICE-0010Checkmainsdk check, before deployA phone number routes to a voice session with no disclosure
MAIN-VOICE-0011Checkmainsdk check, before deployA voice, telephony or room driver's hosts aren't in network.allow
MAIN-EVAL-0002Checkmainsdk check, before deployA feature's score is below its threshold or regressed from the baseline (exit 1)
MAIN-EVAL-0003Checkmainsdk check, before deployA prompt or model changed since the last eval result, or a feature with an eval set has none
MAIN-EVAL-0004Checkmainsdk check, before deployAn eval set or exported sample holds personal data
MAIN-AI-0005RuntimeError responses and logsPrompt injection suspected in a tool result; wrapped as untrusted data
MAIN-AI-0007RuntimeError responses and logsCall refused: it would pass a spend limit
MAIN-AI-0010RuntimeError responses and logsai.tool() for a function not declared with action()
MAIN-AI-0011RuntimeError responses and logsTyped output does not match schema after its repairs (one by default, up to two with repairs); no value is returned
MAIN-AI-0014RuntimeError responses and logsA 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-0040RuntimeError responses and logsA 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-0041RuntimeError responses and logsA prompt part is over a limit: size, pixels, pages, duration, frames or max-parts (413); nothing is sent
MAIN-AI-0042RuntimeError responses and logsThe model or driver doesn't accept this kind of part (501)
MAIN-AI-0043RuntimeError responses and logsA 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-0045RuntimeError responses and logsAn image option is invalid: size, count, mask size or an empty prompt (400); nothing is sent
MAIN-AI-0046RuntimeError responses and logsThe image driver doesn't offer this operation (501)
MAIN-AI-0047RuntimeError responses and logsThe provider refused the image prompt under its content policy (422)
MAIN-AI-0048RuntimeError responses and logsA content credential couldn't be signed and ai.credentials.required is true (500)
MAIN-AI-0060RuntimeError responses and logsAn ai.run option is invalid: goal and messages, maxSteps, budget, timeout or agent (400); nothing is sent
MAIN-AI-0061RuntimeError responses and logsThe run stopped at maxSteps (stop: "max-steps")
MAIN-AI-0062RuntimeError responses and logsThe run stopped at its budget (stop: "budget")
MAIN-AI-0063RuntimeError responses and logsThe run stopped at its timeout (stop: "timeout")
MAIN-AI-0064RuntimeError responses and logsThe model asked for a tool that isn't in the run's tools; the step was refused
MAIN-AI-0065RuntimeError responses and logsA handoff or delegation names an agent not declared for it (400)
MAIN-AI-0066RuntimeError responses and logsA delegation asked for more than the parent's remaining budget or the declared share (402)
MAIN-AI-0067RuntimeError responses and logsNo run with that id in the current tenant, or it isn't waiting (404 or 409)
MAIN-AI-0071RuntimeError responses and logsEvery model in the route failed with a retryable error (502)
MAIN-AI-0072RuntimeError responses and logsA fallback model was skipped because its endpoint is outside the tenant's region for labelled data (logged)
MAIN-AI-0073RuntimeError responses and logsA response cache entry is invalid: tenant scope with labelled data, a TTL over 7 days, or local kv in production
MAIN-AI-0074RuntimeError responses and logsA batch option is invalid: no items, too many, duplicate ids, or no batch API (400)
MAIN-AI-0075RuntimeError responses and logsNo batch with that id in the current tenant (404)
MAIN-AI-0078RuntimeError responses and logsThe local model server is unreachable (503, retryable)
MAIN-AI-0080RuntimeError responses and logsThe model isn't on the local server (404)
MAIN-AI-0050RuntimeError responses and logsA video option is invalid: seconds, size, prompt or references (400); nothing is sent
MAIN-AI-0051RuntimeError responses and logsNo video job with that id in the current tenant (404)
MAIN-AI-0052RuntimeError responses and logsThe provider failed or refused the video job; the job is failed
MAIN-AI-0053RuntimeError responses and logsA video job lost its provider heartbeat and was cancelled at the provider
MAIN-VOICE-0001RuntimeError responses and logsA voice session option is invalid (400)
MAIN-VOICE-0002RuntimeError responses and logsThe voice driver doesn't offer this (501)
MAIN-VOICE-0003RuntimeError responses and logsRecording refused: recording off, or no consent from the caller (403)
MAIN-VOICE-0004RuntimeError responses and logsThe session reached its duration or cost limit and ended after a closing line
MAIN-VOICE-0005RuntimeError responses and logsA telephony or room webhook failed signature verification (401)
MAIN-AI-0069RuntimeError responses and logsSee the spec
MAIN-AI-0002RuntimeError responses and logsThe model isn't in the ai.models allowlist (403)
MAIN-AI-0003RuntimeError responses and logsThe model provider answered with an error (502); the message has the status, never the body
MAIN-AI-0004RuntimeError responses and logsNo price is set for the model, so its spend can't be limited
MAIN-AI-0006RuntimeError responses and logsThe driver has no embeddings, for example anthropic
MAIN-AI-0008RuntimeError responses and logsAn agent's model call waits for a person's approval; nothing reached the provider (202)
MAIN-AI-0012RuntimeError responses and logsA call passed neither or both of prompt and messages (400)
MAIN-AI-0013RuntimeError responses and logsai.driver names a driver that isn't built yet
MAIN-AI-0015RuntimeError responses and logsA 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-0016RuntimeError responses and logsVoice clone refused: no granted voice-clone consent recorded by the owner in their own session (403)
MAIN-AI-0017RuntimeError responses and logsThe consent statement's transcript doesn't match ai.speech.clone.script, or the statement and samples aren't one speaker (422)
MAIN-AI-0018RuntimeError responses and logsThe 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-0019RuntimeError responses and logsA voice that isn't an alias under ai.speech.voices or a cloned voice id
MAIN-AI-0020RuntimeError responses and logsSpeech text holds labelled data and the speech driver's data use doesn't meet ai.data (403); nothing is sent
MAIN-AI-0021RuntimeError responses and logsThe driver doesn't offer this: speech, transcription, streaming, diarisation or cloning (501)
MAIN-AI-0022RuntimeError responses and logsDeleting a cloned voice at the provider failed; the voice stays blocked and the delete is retried (logged)
MAIN-AI-0023RuntimeError responses and logsAudio is larger than ai.speech.max-audio, or a live transcription passed max-duration (413)
MAIN-AI-0076RuntimeError responses and logsThe store that counts ai.limits did not answer and the call is under a spend limit, so it is refused (503, retryable)
MAIN-EVAL-0001RuntimeError responses and logsAn eval set is invalid, or its judge model is the feature's own model (exit 2)
MAIN-EVAL-0005RuntimeError responses and logsThe eval run would pass --max-cost (exit 2)
MAIN-VOICE-0006RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Streaming and schema-checked typed outputPhase 10.3ai#stream-structured
Generate and embedPhase 10.3Not specified yet
Tools that are MainSDK actionsPhase 10.3ai#tools
Extract typed data from text with a schemaPhase 10.3ai#extract
Classify into one of your labelsPhase 10.3ai#classify-summarize
Summaries in shortPhase 10.3ai#classify-summarize
Model parser for parse.firstPhase 10.3ai#extractor
Schema and repairs in generatePhase 10.3ai#generate-schema
Every model verb through redactionPhase 10.3ai#verb-guards
Images, PDFs, audio and video frames as prompt parts through filesPhase 10.4ai-multimodal#verbs
Image generate, edit, inpaint and variations, output through filesPhase 10.4ai-image#edit
Disclosure metadata and C2PA content credentials on AI imagesPhase 10.4ai-image#c2pa
Agent loop with ai.run: action tools, bounds, approval, handoff and delegationPhase 10.4ai-run#loop
Fallback routes that never weaken data use or regionPhase 10.4ai-routing#fallback
Prompt caching and a tenant-safe response cachePhase 10.4ai-routing#response-cache
Batch jobs at batch pricesPhase 10.4ai-routing#batch
Local models through Ollama, llama.cpp and OpenAI-compatible serversPhase 10.5ai-local#drivers
Eval sets with a CI regression gate and the result in the sealPhase 10.5ai-evals#gate
AI video as a durable long jobPhase 20.6ai-video#job
Realtime voice agents on phone lines and WebRTC roomsPhase 20.6ai-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.

main.yaml
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 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

ts
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

DriverUse it when
anthropicYou have an Anthropic key. The default for a solo builder.
openaiYou use OpenAI models.
localYou build or test. An offline driver that needs no key and no network; fake is its older name.
bedrockPlanned. You run on AWS. main.yaml refuses it with MAIN-AI-0013 until it ships.
gatewayPlanned 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:.

main.yaml
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 }
KeyTypeWhat it does
driverstringThe provider that runs the model
modelslist of patternsModels your code may call; anything else is refused
data.trainingstringData 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.retentionstringRetention you accept (zero), checked the same way
redactlist of labelsData labels the built-in redactor removes before the call
keysecret: referenceThe provider key, when the driver needs one
limits.per-usermoney per periodSpend limit per user, enforced before the call
limits.per-featuremap of feature to money per periodSpend limit per feature name
limits.totalmoney per periodSpend limit for the app
pricesmap of model to pricePrice per model (input, output, and for media image, page, minute). The local driver prices everything at $0
routesmap of name to modelsFallback routes, called as route:<name> (below)
cachemapPrompt caching and per-feature response cache (below)
inputmapLimits on what a prompt may carry: image, document, audio, video, max-parts (below)
batchmapdrivers and max-items for batch jobs
imagemapdriver, key, max-count, sizes for images
credentialsmapsigner, trust, required, claim-generator for content credentials on images
labelbooleanMarks 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:

main.env.dev.yaml
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.

CallWhat 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.summarizeTyped 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.

app/api/chat/route.ts
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):

app/api/chat/stream/route.ts
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:

lib/read-chat.ts
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.

app/api/tickets/classify/route.ts
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.

app/api/invoices/route.ts
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.

lib/triage.ts
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.

lib/summary.ts
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.

lib/support-parser.ts
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.

ts
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 same access check 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.

server/draft.ts
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.

ts
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 });
main.yaml
agents:
  support-bot:
    budget: { spend: $5/day, calls: 500/day }

See Agents with an identity, a budget and approvals.

ai.embed() returns one vector per input, with the same redaction, limits and audit.

lib/embed-products.ts
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.

lib/support-agent.ts
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.

main.yaml
ai:
  routes:
    support-chat: [anthropic/claude-sonnet-4-5, anthropic/claude-haiku-4-5]
ts
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 first

A 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:

main.yaml
ai:
  cache:
    prompt: auto                       # auto | off
    response:
      faq: { ttl: 1h, scope: user }    # user | tenant

The 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.

lib/bulk.ts
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 you

ai.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.

main.yaml
ai:
  image: { driver: openai, max-count: 4, sizes: ["1024x1024"] }   # openai | gateway | replicate | local
  credentials: { signer: local:certs/ai.pem, required: false }
lib/avatar.ts
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.

ts
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-0001 is a planned mainsdk check finding, not built yet. It will fail when a driver's declared data use doesn't meet ai.data, for example a driver that declares training: opt-out. Pick a driver, model or account mode with no training and no retention.
  • MAIN-AI-0005 is logged when an input or tool result is flagged as prompt injection and isolated. Review the flagged source.
  • MAIN-AI-0007 refuses a call that would pass a per-user, per-feature or total limit. Raise the limit in ai.limits, or tell the user they hit their limit.
  • MAIN-AI-0011 fails ai.generate(), ai.extract() or ai.classify() when the reply still does not match after its repairs (one by default, up to two with repairs). No value is returned. Loosen the schema, or fix the prompt so the model can meet it.
  • MAIN-AI-0014 refuses a verb option the spec does not allow, such as repairs: 3, an empty or repeated label list, or an unknown summary length. No request is sent.
  • MAIN-AI-0013 refuses a driver main.yaml doesn't build yet (bedrock, gateway). Use anthropic, openai or local.
  • MAIN-AI-0040 and MAIN-AI-0041: a media part could not be read (send file references the user may read), or it is over an ai.input limit.
  • MAIN-AI-0042 and MAIN-AI-0043: no driver can take that media, or labelled media would go to a driver that breaks ai.data or 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 under credentials.required.
  • MAIN-AI-0060 to MAIN-AI-0067: ai.run problems. 0060 invalid options, 0061 to 0063 stopped at max steps, budget or timeout, 0064 a tool that isn't in the run, 0065 a handoff or delegate not declared, 0066 a delegated budget that is not allowed, 0067 an unknown run or batch id.
  • MAIN-AI-0071 and MAIN-AI-0073: every model of a route failed; a response cache that lives over 7 days or holds labelled data tenant-wide.
  • MAIN-AI-0074 and MAIN-AI-0075: an invalid batch (or a driver with no batch API), and no batch with that id in this tenant.
  • MAIN-AI-0010 stops the app at start when ai.tool() gets a function not declared with action(). Wrap it in action().

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.

main.yaml
ai:
  driver: anthropic   # was gateway

AI 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:

test/main.yaml
ai:
  driver: local
  models: [anthropic/claude-*]
  data: { training: never, retention: zero }
  limits: { per-user: $0/day }
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" });
  });
});

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.

main.yaml
ai:
  video: { driver: openai, max-seconds: 8 }   # openai | google | gateway
lib/clip.ts
import { 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.

main.yaml
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 }
CallWhat 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:

server.ts
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.

ts
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 of text and input (an AsyncIterable<string>, such as the text of ai.stream), or the call fails with MAIN-AI-0015. voice is an alias under ai.speech.voices or a cloned voice id vc_…; 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 than ai.speech.max-audio fails with MAIN-AI-0023.
  • ai.transcribe.stream({ model, audio, format, sampleRate, interim?, feature }) yields partial (when interim is true) and final segments in time order, then one done event with the whole transcript. It ends with done at ai.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 recorded voice-clone consent (MAIN-AI-0016), and the spoken statement must match ai.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 with MAIN-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.

ExportWhat 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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet