---
title: "AI calls"
description: "Call any model through one interface, with personal data redacted first, no training or retention, spend limits per user and every call audited."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "ai"
canonical: "https://mainsdk.dev/docs/interfaces/ai"
updated: "2026-10-28"
---

# AI calls

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

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/ai · 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

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/ai.md
- Category: AI and agents

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?)`

In main.yaml:

```yaml
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 }
```

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

Pick one in main.yaml with `ai.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Local (no account | `local` | yes |  |
| Anthropic | `anthropic` |  |  |
| OpenAI | `openai` |  |  |
| AI gateway | `gateway` |  |  |
| Amazon Bedrock | `bedrock` |  |  |
| Ollama | `ollama` |  |  |
| llama.cpp server | `llama-cpp` |  |  |
| OpenAI-compatible server | `openai-compatible` |  |  |

## Codes

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

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Streaming and schema-checked typed output | 1 | 0.3 | `ai#stream-structured` |
| Generate and embed | 1 | 0.3 |  |
| Tools that are MainSDK actions | 1 | 0.3 | `ai#tools` |
| Extract typed data from text with a schema | 1 | 0.3 | `ai#extract` |
| Classify into one of your labels | 1 | 0.3 | `ai#classify-summarize` |
| Summaries in short | 1 | 0.3 | `ai#classify-summarize` |
| Model parser for parse.first | 1 | 0.3 | `ai#extractor` |
| Schema and repairs in generate | 1 | 0.3 | `ai#generate-schema` |
| Every model verb through redaction | 1 | 0.3 | `ai#verb-guards` |
| Images, PDFs, audio and video frames as prompt parts through files | 1 | 0.4 | `ai-multimodal#verbs` |
| Image generate, edit, inpaint and variations, output through files | 1 | 0.4 | `ai-image#edit` |
| Disclosure metadata and C2PA content credentials on AI images | 1 | 0.4 | `ai-image#c2pa` |
| Agent loop with ai.run: action tools, bounds, approval, handoff and delegation | 1 | 0.4 | `ai-run#loop` |
| Fallback routes that never weaken data use or region | 1 | 0.4 | `ai-routing#fallback` |
| Prompt caching and a tenant-safe response cache | 1 | 0.4 | `ai-routing#response-cache` |
| Batch jobs at batch prices | 1 | 0.4 | `ai-routing#batch` |
| Local models through Ollama, llama.cpp and OpenAI-compatible servers | 1 | 0.5 | `ai-local#drivers` |
| Eval sets with a CI regression gate and the result in the seal | 1 | 0.5 | `ai-evals#gate` |
| AI video as a durable long job | 2 | 0.6 | `ai-video#job` |
| Realtime voice agents on phone lines and WebRTC rooms | 2 | 0.6 | `ai-voice#session` |

## MainSDK Cloud

Everything on this page is free in your app.


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

```yaml title="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 }
```

TypeScript:

```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 });
}
```

Rust:

```rust
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

```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

| 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:`.

```yaml title="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 }
```

| 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](https://mainsdk.dev/docs/concepts/data-and-regions) 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](#fallback-routes)) |
| `cache` | map | Prompt caching and per-feature response cache ([below](#caching)) |
| `input` | map | Limits on what a prompt may carry: `image`, `document`, `audio`, `video`, `max-parts` ([below](#images-pdfs-and-audio-in-a-prompt)) |
| `batch` | map | `drivers` and `max-items` for [batch jobs](#batch-jobs) |
| `image` | map | `driver`, `key`, `max-count`, `sizes` for [images](#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`](https://mainsdk.dev/docs/interfaces/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:

```yaml title="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](https://mainsdk.dev/docs/concepts/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](https://mainsdk.dev/docs/interfaces/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.

```ts title="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):

```ts title="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:

```ts title="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.

```ts title="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.

```ts title="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`.

```ts title="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.

```ts title="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`](https://mainsdk.dev/docs/interfaces/schema#parsefirst). It runs only when the cheaper parsers before it do not match, and its hit carries `via: "model"` and the cost.

```ts title="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](https://mainsdk.dev/docs/interfaces/action) 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.

```ts title="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 });
```

```yaml title="main.yaml"
agents:
  support-bot:
    budget: { spend: $5/day, calls: 500/day }
```

See [Agents with an identity, a budget and approvals](https://mainsdk.dev/docs/agents/governance).

### Embeddings for search

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

```ts title="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](https://mainsdk.dev/docs/interfaces/action) that runs its own `access` check. The run is charged to a named [agent](https://mainsdk.dev/docs/interfaces/agents), never above that agent's budget.

```ts title="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.

```yaml title="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:

```yaml title="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`.

```ts title="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.

```yaml title="main.yaml"
ai:
  image: { driver: openai, max-count: 4, sizes: ["1024x1024"] }   # openai | gateway | replicate | local
  credentials: { signer: local:certs/ai.pem, required: false }
```

```ts title="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](https://mainsdk.dev/docs/reference/errors) 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.

```yaml title="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](https://mainsdk.dev/docs/guides/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:

```yaml title="test/main.yaml"
ai:
  driver: local
  models: [anthropic/claude-*]
  data: { training: never, retention: zero }
  limits: { per-user: $0/day }
```

```ts title="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](https://mainsdk.dev/docs/interfaces/testing).

## 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](#video) and [Voice](#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.

```yaml title="main.yaml"
ai:
  video: { driver: openai, max-seconds: 8 }   # openai | google | gateway
```

```ts title="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.

```yaml title="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 }
```

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

```ts title="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.

| 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](https://mainsdk.dev/docs/interfaces/action): write the tools a model can call.
- [Network permissions](https://mainsdk.dev/docs/interfaces/network): where model hosts come from.
- [Logs, audit and telemetry](https://mainsdk.dev/docs/interfaces/log): read the audit events for each call.
