---
title: "@templatical/ai-kit"
description: The AI wire, an HTTP provider, a server kit with prompts and tools, and a conformance suite. MIT.
---

# @templatical/ai-kit

::: code-group

```bash [npm]
npm install @templatical/ai-kit
```

```bash [pnpm]
pnpm add @templatical/ai-kit
```

```bash [yarn]
yarn add @templatical/ai-kit
```

```bash [bun]
bun add @templatical/ai-kit
```

:::

MIT, with no LLM SDK and no Node-only API: Web Streams and `fetch`, so the same code runs in Node, Deno, Bun, Cloudflare Workers and browsers.

## Request, result and history fields

The complete provider declarations from `@templatical/types` cover requests, results, handlers and history, including `AiChatAttachment` and `AiAltTextRequest`. `conversation.list` returns messages oldest first; `createdAt` is ISO 8601. User messages have empty `batches`; an editor-applied batch has `revision: null`, a server-applied batch a numeric revision. Only user messages carry optional attachment metadata, never files.

```ts
import type {
  SyntaxPresetName,
  Operation,
  OperationErrorCode,
  TemplateContent,
} from "@templatical/types";

export type AiMergeTagSyntax = SyntaxPresetName | "custom";

export interface AiMergeTag {
  value: string;
  label: string;
  group?: string;
  description?: string;
}

export interface AiMergeTags {
  syntax: AiMergeTagSyntax;
  tags: AiMergeTag[];
}

export interface AiRequestBase {
  /** The active language; `null` in the default view. */
  locale: string | null;
  templateId: string;
  /**
   * The template as the editor has it, after it sent its pending edits. A
   * backend that holds the template itself (Templatical Cloud) ignores it.
   */
  content: TemplateContent;
  /** The collaboration revision `content` is at, or `null` without collaboration. */
  revision: number | null;
  /** The editor session (`X-Templatical-Session` on the wire). */
  sessionId: string;
  /** The editor syntax and declared tags; `tags` is empty when none are declared. */
  mergeTags: AiMergeTags;
}

export interface AiChatAttachment {
  file: Blob;
  kind: "image" | "pdf";
  name: string;
}

export interface AiChatRequest extends AiRequestBase {
  /** 1–4,000 characters. */
  message: string;
  /** The selected block, when the backend has it; `null` otherwise. */
  selectedBlockId: string | null;
  /** Always present; at most 4 files, each at most 5 MB, at most 10 MB together. */
  attachments: AiChatAttachment[];
}

export interface AiFixFinding {
  /** Non-empty lint rule id, at most 200 characters. */
  ruleId: string;
  /** The problem, at most 1,000 code points. */
  message: string;
  /** The linter's suggested action, at most 1,000 code points. */
  suggestion: string;
  /** Rule docs URL: http(s), at most 2,048 characters; never fetched by the model. */
  reference: string;
  /** Block id (at most 200 characters), or null for a template-level finding. */
  blockId: string | null;
  /** Diagnostic excerpt when supplied by the rule, at most 1,000 code points. */
  excerpt?: string;
}

export interface AiFixRequest extends AiRequestBase {
  finding: AiFixFinding;
}

export type AiRewritePreset =
  "shorten" | "expand" | "friendlier" | "formal" | "fixGrammar" | "translate";

export interface AiRewriteRequest extends AiRequestBase {
  /**
   * The paragraph's HTML (1–20,000 characters), merge tags as the editor's
   * tags (`<span data-merge-tag="{{first_name}}" …>`).
   */
  html: string;
  preset: AiRewritePreset | null;
  /** A BCP 47 tag; set when `preset` is `translate`. */
  language: string | null;
  /** ≤ 500 characters. At least one of `preset` and `instruction` is set. */
  instruction: string | null;
}

export interface AiAltTextRequest {
  templateId: string;
  sessionId: string;
  blockId: string;
  image: { url: string };
  currentAlt: string | null;
  linkHref: string | null;
  /** Plain text, at most 2,000 code points. */
  nearbyText: string;
  locale: string;
  /** Effective lint altMaxLength: integer 1–1,000, counted as string.length. */
  maxLength: number;
}

export interface AiAppliedBatch {
  batchId: string;
  revision: number;
  operationCount: number;
}

export type AiOperationsResult =
  | { applied: true; inverse: Operation[] }
  | {
      applied: false;
      code: OperationErrorCode;
      opIndex: number;
      message: string;
    };

export interface AiTurnHandlers {
  onText(text: string): void;
  onOperations(ops: Operation[]): Promise<AiOperationsResult>;
  onApplied(batch: AiAppliedBatch): void;
}

export interface AiRewriteHandlers {
  /** A chunk of the rewritten text, for a live preview. */
  onText(text: string): void;
}

export interface AiTurnResult {
  /** The assistant message's id in `conversation`, or `null` without history. */
  messageId: string | null;
  /** The whole reply. */
  text: string;
}

export type AiReplay =
  | { operations: Operation[]; replayId: string }
  | { applied: { batchId: string; revision: number } };

export type AiMessageKind = "chat" | "fix";

export interface AiMessageBatch {
  batchId: string;
  revision: number | null;
  operationCount: number;
}

export interface AiChatMessage {
  /** The active language; `null` in the default view. Absent in older history. */
  locale?: string | null;
  id: string;
  role: "user" | "assistant";
  kind: AiMessageKind;
  /** The author's message, a fix's "Fix: {message}", or the assistant's reply. */
  text: string;
  /** Reference metadata on user messages only; file retention is the backend's choice. */
  attachments?: { name: string; kind: "image" | "pdf" }[];
  /** What the assistant's turn applied; empty for user messages. */
  batches: AiMessageBatch[];
  /** `true` after Undo, `false` after Redo. */
  undone: boolean;
  /** ISO 8601. */
  createdAt: string;
}

export interface AiConversationProvider {
  /** Settle an editor-applied replay, idempotently, after applying or refusing it. */
  settleReplay(
    templateId: string,
    messageId: string,
    outcome: AiReplayOutcome,
  ): Promise<void>;
  /** Oldest first. */
  list(templateId: string): Promise<AiChatMessage[]>;
  /** New chat; `false` hides the button. */
  clear: false | ((templateId: string) => Promise<void>);
  undo:
    | false
    | ((
        templateId: string,
        messageId: string,
        context: AiReplayContext,
      ) => Promise<AiReplay>);
  redo:
    | false
    | ((
        templateId: string,
        messageId: string,
        context: AiReplayContext,
      ) => Promise<AiReplay>);
}

export interface AiReplayOutcome {
  replayId: string;
  direction: "undo" | "redo";
  applied: boolean;
  sessionId: string;
}

export interface AiReplayContext {
  sessionId: string;
}

export type AiErrorCode =
  | "feature_unavailable"
  | "limit_reached"
  | "rate_limited"
  | "ai_turn_running"
  | "content_too_large"
  | "ai_output_invalid"
  | "ai_provider_error";

export interface AiLimitDetails extends Readonly<Record<string, unknown>> {
  /** Which allowance ran out, in the backend's words (`aiMonthly`). */
  limit: string;
  /** ISO 8601: when it renews. */
  resetsAt: string;
}

export interface AiProvider {
  chat:
    | false
    | ((
        request: AiChatRequest,
        on: AiTurnHandlers,
        signal: AbortSignal,
      ) => Promise<AiTurnResult>);
  fixFinding:
    | false
    | ((
        request: AiFixRequest,
        on: AiTurnHandlers,
        signal: AbortSignal,
      ) => Promise<AiTurnResult>);
  /** True means chat accepts attachments; has no effect while chat is false. */
  chatAttachments: false | true;
  rewrite:
    | false
    | ((
        request: AiRewriteRequest,
        on: AiRewriteHandlers,
        signal: AbortSignal,
      ) => Promise<{ html: string }>);
  altText:
    | false
    | ((
        request: AiAltTextRequest,
        signal: AbortSignal,
      ) => Promise<{ alt: string }>);
  conversation?: AiConversationProvider;
}

export type AiFeatureName = Exclude<keyof AiProvider, "conversation">;
```

### Request limits

| Field | Limit and representation |
| --- | --- |
| `content` | At most 200,000 UTF-8 bytes of serialized JSON; not proof of schema validity |
| `revision` | Non-negative integer, or `null` without collaboration |
| `message` | 1–4,000 code points, not whitespace-only |
| `selectedBlockId`, `sessionId` | At most 200 code points; selection may be `null` |
| `mergeTags` | Required `{ syntax, tags }`; `tags: []` when none are declared; at most 500 tags; syntax `liquid`, `handlebars`, `mailchimp`, `ampscript` or `custom` |
| Merge-tag fields | `value` ≤ 200, `label` ≤ 100, optional `group` ≤ 100 and `description` ≤ 200 code points; no samples or callbacks |
| Rewrite | `html` 1–20,000 code points; `instruction` ≤ 500; at least `preset` or `instruction`; `language` required for `translate` matching `/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/` |
| Fix finding | [Lint fixes](#lint-fixes) gives every field limit and excluded field |
| Alt text | [Alt text](#alt-text) gives URL, context, locale and output limits |
| Attachments | [Chat attachments](#chat-attachments) gives MIME types, file-name and byte limits |
| Operation batch | 1–100 operations in order; a turn's batches form one undo step |

`mergeTags` always carries the editor's syntax (`liquid` by default), including an empty tag list. `readAiRequest` refuses missing or `null` context with status 422 and message `mergeTags must be { syntax, tags } with at most 500 tags.` The editing session and `checkRewrite` reject invented tags even when the declared list is empty; tags already in the input remain allowed. `checkRewrite` checks chips and declared tag values literally for `custom`; custom regex patterns are not sent to the backend.

## Client

```ts
import { createHttpAiProvider } from '@templatical/ai-kit/client';

const ai = createHttpAiProvider({
  baseUrl: '/api/ai',
  credentials: 'include',
  features: { chatAttachments: false, conversation: false },
});
```

| Option | |
| --- | --- |
| `baseUrl` | Where the wire's routes live |
| `headers` | Your auth headers, or a function (async for a token refresh) called per request |
| `credentials` | `fetch`'s `credentials`, for cookie auth |
| `features` | `false` turns a member off, `conversation` included |

It parses the event stream, rejects with `SdkError`s (`ThrottledError` for a 429, `CollaborationConflict` for an operation refusal), and, because a stream is one-way, ends a turn whose operations the editor refused by aborting the request: the turn rejects with that operation's code and "The template changed while the AI was working."

## Server

```ts
import {
  aiErrorResponse,
  aiEventStream,
  applyOperationsTool,
  buildChatRequest,
  checkRewrite,
  createEditingSession,
  editingInstructions,
  readAiRequest,
  runEditingTurn,
} from '@templatical/ai-kit/server';
```

| Export | |
| --- | --- |
| `editingInstructions` | The editing system prompt: the rules, the [operations](/api/operations) and the template schema |
| `rewriteInstructions` | The other prompts |
| `buildEditingPrompt`, `buildChatRequest`, `buildFixRequest`, `buildRewritePrompt`, `buildAltTextRequest` | Per-request prompts |
| `applyOperationsTool` | The one tool: `apply_operations`, its `ops` a JSON string |
| `createEditingSession` | A turn's working copy: checks each call (shape, the 100-operation cap, no custom, slot or wrapper blocks, declared merge tags, the engine, a size cap) and answers a sentence the model can act on |
| `runEditingTurn` | The reference loop over an `AiModelAdapter`, at most three model calls |
| `aiEventStream` | The event-stream `Response` for any Fetch-API server |
| `aiErrorResponse`, `aiJsonResponse` | Errors before the stream, and JSON answers, with the wire's header |
| `readAiRequest` | A wire request, parsed and checked |
| `checkRewrite`, `buildRewriteRepairPrompt` | Output checks |
| `checkAltTextAnswer`, `buildAltTextRepairRequest`, `checkAltText` | Alt-text checks and one repair request |

There is no HTML sanitizer in the kit. The editor sanitizes the rewrite preview and the HTML applied by Replace. HTML blocks the AI writes keep their markup unchanged; follow the [server-side sanitizing advice before saving or sending](/guide/blocks#html).

### Lint fixes

`AI_FIXABLE_RULES`, exported from `@templatical/editor`, contains exactly the rules listed below. `AI_ALT_TEXT_RULES` contains exactly `a11y.img-missing-alt`, `a11y.img-alt-is-filename`, `a11y.img-alt-too-long` and `a11y.img-linked-no-context`. Both actions require `@templatical/quality`, lint on and a loaded template. The canonical categories, catalogs and options are [Accessibility](/quality/accessibility/), the [accessibility rule catalog](/quality/accessibility/rule-catalog), [Deliverability](/quality/deliverability/), the [deliverability rule catalog](/quality/deliverability/rule-catalog) and [Options](/quality/options).

`fixFinding` receives the JSON-serializable lint projection `finding: { ruleId, message, suggestion, reference, blockId, excerpt? }`; `severity`, `clients` and the local `fix` function are excluded. [Quality](/quality/) documents the rules and findings. The editor offers fixes for these rule ids:

- `a11y.link-vague-text`
- `a11y.button-vague-label`
- `a11y.link-empty`
- `a11y.text-all-caps`
- `a11y.missing-preheader`
- `deliverability.link-text-domain-mismatch`
- `deliverability.low-text`

`buildFixRequest(finding)` fills `prompts/fix.md` with diagnostic data and guidance from `prompts/fix-rules.json`, exported as `FIX_RULE_GUIDANCE` from `@templatical/ai-kit/server`. Unlisted rules get `Change only what the finding names.`. Every finding field is data, never instructions; the model must not fetch `reference`. Domain-mismatch fixes keep the `href`, and low-text fixes add body copy using only existing facts.

`readAiRequest` rejects invalid fields with `SdkError` (status 422, no `code`) and never truncates: `ruleId` is non-empty and at most 200 characters; `message`, `suggestion` and optional `excerpt` are at most 1,000 Unicode code points; `reference` is an `http:` or `https:` URL of at most 2,048 characters; `blockId` is `null` or a string of at most 200 characters. ID and URL limits count UTF-16 units (`string.length`).

Check your fix endpoint with `runAiWireConformance({ baseUrl })` in the example below; `runAiProviderConformance(provider)` also sends a lint-shaped finding to `fixFinding`.

### Alt text

`buildAltTextRequest(request: AiAltTextRequest)` returns `{ system: string; messages: AiModelMessage[] }` with the image as an `image-url` attachment on the user message. `prompts/alt-text.md` requests the image's purpose in this email, destination or action for linked images, visible image text, the template's locale, plain text and the request's `maxLength`. Nearby text and current alt are data, never instructions.

`checkAltTextAnswer` checks an answer without throwing and returns `{ ok: true, alt }` or `{ ok: false, reason }`. `checkAltText` is the final check and throws `SdkError(AI_ERROR_MESSAGES.ai_output_invalid, 502, "ai_output_invalid")` for an invalid answer.

```ts
import type { AiModelMessage } from "@templatical/ai-kit/server";

export declare function checkAltTextAnswer(
  answer: string,
  { maxLength }: { maxLength: number },
): { ok: true; alt: string } | { ok: false; reason: string };

export declare function buildAltTextRepairRequest(
  built: { system: string; messages: AiModelMessage[] },
  answer: string,
  reason: string,
  maxLength: number,
): { system: string; messages: AiModelMessage[] };

export declare function checkAltText(
  answer: string,
  { maxLength }: { maxLength: number },
): string;
```

Normalization trims outer whitespace, strips one surrounding single or double quote pair, removes one leading “Image of” or “Picture of” case-insensitively, then trims again. Line breaks after trimming outer whitespace are refused even when the text is short or prefix normalization would consume the break. The normalized string's length counts UTF-16 units (`alt.length`). The check returns the first matching reason in this order:

| Condition | Exact `reason` |
| --- | --- |
| Empty after normalization | `Your answer was empty.` |
| Contains a line break (CR, LF, U+2028 or U+2029) | `Your answer had several lines.` |
| Contains HTML (`/<[a-z]/i`) | `Your answer contained HTML.` |
| Longer than `maxLength` | `` `Your answer was ${alt.length} characters; the limit is ${maxLength}.` `` |

`buildAltTextRepairRequest(built, answer, reason, maxLength)` keeps the same system prompt and original messages, including the image attachment. It appends `{ role: "assistant", content: answer }` and a user message whose content is `` `${reason} Reply again with only the alt text: one line of plain text, at most ${maxLength} characters.` ``. Pass `maxLength` from the original request as the fourth parameter. The function does not mutate `built` or call the model.

The guide's server and `evalAltTextWith` check the first answer with `checkAltTextAnswer`. On refusal they send exactly one repair request with `buildAltTextRepairRequest`, then run `checkAltText`. A second invalid answer raises `ai_output_invalid`; there is no third model call. Check your endpoint with `runAiWireConformance({ baseUrl })` in the example below.

`readAiRequest` validates the `altText` route without truncation: `blockId` is non-empty, at most 200 characters; `image.url` is an `http:` or `https:` URL parsed by `new URL()`, at most 2,048 characters; `currentAlt` and `linkHref` are strings of at most 2,048 characters or `null`; `nearbyText` is at most 2,000 code points; `locale` is at most 35 characters and matches `/^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/`; `maxLength` is an integer from 1 to 1,000. Invalid fields throw `SdkError(message, 422)` with no code. Character limits other than `nearbyText` count UTF-16 units.

The editor supplies its effective lint `altMaxLength`, default 125. Always check the answer with the request's value. The model provider fetches `image.url`; your backend authorizes the template and block and resolves private or signed media. [Alt text in the editor](/backend/ai#alt-text) documents review and the `decorative` flag.

The model attachment union is:

```ts
export type AiModelAttachment =
  | { kind: "image" | "pdf"; mediaType: string; data: Blob }
  | { kind: "image-url"; url: string };
```

### Model adapters

`runEditingTurn` calls your model through one method:

```ts
import type { AiToolDefinition } from "@templatical/ai-kit/server";

/** A design file or an image URL attached to a user message. */
export type AiModelAttachment =
  | { kind: "image" | "pdf"; mediaType: string; data: Blob }
  | { kind: "image-url"; url: string };

export interface AiModelToolCall {
  id: string;
  name: string;
  /** The tool's arguments: a JSON string of the arguments object, or the object. */
  input: string | Record<string, unknown>;
}

export type AiModelMessage =
  | { role: "user"; content: string; attachments?: AiModelAttachment[] }
  | {
      role: "assistant";
      content: string;
      toolCalls?: AiModelToolCall[];
      /** What the adapter yielded as `state` for this step; it replays its own. */
      state?: unknown;
    }
  | { role: "tool"; toolCallId: string; name: string; content: string };

export interface AiUsage {
  /** Input tokens billed at the full rate: not read from or written to a cache. */
  inputTokens: number;
  outputTokens: number;
  /** Input tokens read from the provider's prompt cache, when it reports them. */
  cacheReadInputTokens?: number;
  /** Input tokens written to the provider's prompt cache, when it reports them. */
  cacheWriteInputTokens?: number;
}

export type AiModelPart =
  | { type: "text"; text: string }
  | ({ type: "tool-call" } & AiModelToolCall)
  /** Opaque to the kit: the adapter's own record of the step (e.g. thinking), JSON-safe. */
  | { type: "state"; state: unknown }
  | ({ type: "usage" } & AiUsage);

/**
 * Your model, behind one method: stream a reply to `messages` with `system` as
 * the system prompt and `tools` offered. `@templatical/ai-kit/adapters/*`
 * implements it for Anthropic, OpenAI and the Vercel AI SDK. Honour `signal`.
 */
export interface AiModelAdapter {
  stream(input: {
    system: string;
    messages: AiModelMessage[];
    tools: AiToolDefinition[];
    signal?: AbortSignal;
  }): AsyncIterable<AiModelPart>;
}
```

`@templatical/ai-kit/adapters/anthropic`, `/adapters/openai` and `/adapters/vercel` implement it: `createAnthropicAdapter`, `createOpenAiAdapter` and `createVercelAdapter`, with `AnthropicAdapterOptions`, `OpenAiAdapterOptions` and `VercelAdapterOptions` ([options and limits](/guide/bring-your-own-ai#model-adapters)). A `state` part is the adapter's own record of a step, such as the model's thinking: `runEditingTurn` puts it on that step's assistant message, and the adapter replays its own. `usage` counts `inputTokens` at the full rate, with `cacheReadInputTokens` and `cacheWriteInputTokens` when the provider reports them; the three add up to the input.

```ts
import type Anthropic from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import type { LanguageModel, streamText } from "ai";

type StreamParams = Anthropic.MessageStreamParams;

/** The one method the adapter calls: `new Anthropic()` fits it. */
export interface AnthropicAdapterClient {
  messages: {
    stream(
      body: StreamParams,
      options?: { signal?: AbortSignal | null },
    ): AsyncIterable<Anthropic.RawMessageStreamEvent> & {
      finalMessage(): Promise<Anthropic.Message>;
    };
  };
}

export interface AnthropicAdapterOptions {
  /** A client you construct: `new Anthropic()`, or one with its own `baseURL`. */
  client: AnthropicAdapterClient;
  /** The model id. No default. */
  model: string;
  /** `max_tokens`. Default 16_000. */
  maxTokens?: number;
  /** Prompt caching: a breakpoint after the system prompt plus automatic caching. Default true. */
  cache?: boolean;
  /** Further request fields, e.g. `output_config` or `metadata`. */
  params?: Omit<
    StreamParams,
    | "model"
    | "system"
    | "messages"
    | "tools"
    | "max_tokens"
    | "stream"
    | "cache_control"
  >;
}

type CreateParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming;

/** The one method the adapter calls: `new OpenAI()` and `new AzureOpenAI()` fit it. */
export interface OpenAiAdapterClient {
  chat: {
    completions: {
      create(
        body: CreateParams,
        options?: { signal?: AbortSignal | null },
      ): PromiseLike<
        AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
      >;
    };
  };
}

export interface OpenAiAdapterOptions {
  /** A client you construct: `new OpenAI()`, `new AzureOpenAI()`, or one with a `baseURL` for an OpenAI-compatible server. */
  client: OpenAiAdapterClient;
  /** The model id. No default. */
  model: string;
  /** `max_completion_tokens`. Default: the API's own. */
  maxTokens?: number;
  /** Further request fields, e.g. `reasoning_effort`. */
  params?: Omit<
    CreateParams,
    | "model"
    | "messages"
    | "tools"
    | "stream"
    | "stream_options"
    | "max_completion_tokens"
    | "max_tokens"
    | "n"
  >;
}

type StreamTextParams = Parameters<typeof streamText>[0];

export interface VercelAdapterOptions {
  /** An AI SDK model, e.g. `anthropic('…')` from `@ai-sdk/anthropic`, or a gateway model id. */
  model: LanguageModel;
  /** `maxOutputTokens`. Default: the provider's own. */
  maxTokens?: number;
  /** Further `streamText` options, e.g. `providerOptions` or `maxRetries`. */
  params?: Omit<
    StreamTextParams,
    | "model"
    | "system"
    | "instructions"
    | "messages"
    | "prompt"
    | "tools"
    | "abortSignal"
    | "maxOutputTokens"
  >;
}
```

### Chat attachments

`AiChatRequest.attachments` is always present: `[]` without files, otherwise `{ file: Blob, kind: "image" | "pdf", name: string }[]`. `AiProvider.chatAttachments: false | true` is a flag: `true` means the `chat` function accepts attachments; it has no effect when `chat` is `false`. The HTTP provider enables it by default when chat is on; `features: { chatAttachments: false }` disables it.

Without attachments the HTTP client sends JSON including `locale`. With attachments, `AI_GENERATE_FIELDS` names `locale`, `message`, `selectedBlockId`, `mergeTags`, `content`, `revision`, and repeated `attachment` file parts. `message` and `selectedBlockId` are text; `locale`, `mergeTags` and `content` are JSON strings; `revision` is a number as text. Absent `selectedBlockId` and `revision` become `null`. Multipart with no file parts is valid. JSON bodies always parse to `attachments: []`. Multipart `locale` is a JSON string such as `"de"`, or `null` for the default view. Omitted `locale` is accepted as `null` for older clients.

`readAiRequest` accepts MIME types `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/pdf`. It refuses, never truncates, with status 422 and no code, in this order:

| Condition | Message |
| --- | --- |
| Form cannot be parsed | Send the message as multipart form data. |
| More than 4 files | Attach at most 4 files. |
| Non-file part or unsupported MIME type | Attach PNG, JPEG, WebP or GIF images, or PDFs. |
| Any file over `5 * 1024 * 1024` bytes | Each attachment must be 5 MB or smaller. |
| Total over `10 * 1024 * 1024` bytes | Attachments must be 10 MB or smaller together. |
| File name over 255 UTF-16 units | An attachment's file name must be at most 255 characters. |

`buildChatRequest(message: string, options?: { hasAttachments?: boolean })` returns the text-only request unchanged by default; with `hasAttachments: true` it appends a blank line and `prompts/chat-attachments.md`. Map the parsed files to model attachments with `{ kind, mediaType: file.type, data: file }`, as the guide does. The prompt uses references for the requested change and replaces the whole template only when empty or explicitly requested; pictures become placeholders.

The conformance suites send one 1×1 PNG when chat attachments are on; wire conformance also requires a 422 for five attachments. History stores only `{ name, kind }` in optional user-message `attachments`; retaining the files is the backend's choice.

## Testing

Both signatures, every option and the shared result:

```ts
import type {
  AiFeatureName,
  AiMergeTags,
  AiProvider,
  TemplateContent,
} from "@templatical/types";
export type HttpAiFeatures = Partial<
  Record<AiFeatureName | "conversation", boolean>
>;

export interface AiConformanceCheck {
  name: string;
  ok: boolean;
  detail: string;
}

export interface AiConformanceReport {
  ok: boolean;
  checks: AiConformanceCheck[];
}

export interface AiConformanceOptions {
  /** The template the suite edits; the test set's welcome email by default. */
  content?: TemplateContent;
  templateId?: string;
  /** Public probe image URL; defaults to AI_CONFORMANCE_ALT_TEXT_IMAGE_URL. */
  altTextImageUrl?: string;
  mergeTags?: AiMergeTags;
  /** How long a stopped call may take to reject (D35). */
  timeoutMs?: number;
  /** An optional shorter call limit; every call is also bounded by `timeoutMs`. */
  callTimeoutMs?: number;
}

export interface AiWireConformanceOptions {
  baseUrl: string;
  features?: HttpAiFeatures;
  headers?:
    | Record<string, string>
    | (() => Record<string, string> | Promise<Record<string, string>>);
  templateId?: string;
  /** Defaults to AI_CONFORMANCE_ALT_TEXT_IMAGE_URL. */
  altTextImageUrl?: string;
  timeoutMs?: number;
  callTimeoutMs?: number;
}

export declare const AI_CONFORMANCE_ALT_TEXT_IMAGE_URL: string;

export declare function runAiProviderConformance(
  provider: AiProvider,
  options?: AiConformanceOptions,
): Promise<AiConformanceReport>;

export declare function runAiWireConformance(
  options: AiWireConformanceOptions,
): Promise<AiConformanceReport>;
```

Provider conformance uses the provider's enabled members. Wire conformance owns `headers` and `features`; omitted flags enable all features. Defaults: `templateId: "conformance"`, `content: AI_EVAL_TEMPLATES.welcome`, `mergeTags: AI_EVAL_MERGE_TAGS`, `timeoutMs: 5000` ms. `callTimeoutMs` cannot exceed `timeoutMs`.

`altTextImageUrl` defaults to `AI_CONFORMANCE_ALT_TEXT_IMAGE_URL` from `@templatical/ai-kit/testing`: `https://raw.githubusercontent.com/templatical/sdk/v${version}/packages/ai-kit/eval/images/probe.png` (`version` is the installed kit version). It shows a teal mug without text. Unreleased builds pass `altTextImageUrl` for a public image the model provider can fetch.

For Node.js 24, save the following as `check-ai-backend.ts`. Your authenticated `POST /api/templates` accepts `{ name, content }` and returns `{ id }`. Set `AI_TEST_TOKEN`; `AI_BASE_URL` and `AI_TEMPLATES_URL` override the local URLs. Run `node check-ai-backend.ts`. It creates a disposable template and turns off history and enables chat attachments to match the stateless server in the [backend guide](/guide/bring-your-own-ai#the-server).

```ts
import { createHttpAiProvider } from "@templatical/ai-kit/client";
import {
  AI_CONFORMANCE_ALT_TEXT_IMAGE_URL,
  AI_EVAL_TEMPLATES,
  runAiProviderConformance,
  runAiWireConformance,
} from "@templatical/ai-kit/testing";

const token = process.env.AI_TEST_TOKEN;
if (!token) throw new Error("Set AI_TEST_TOKEN for a disposable test account.");
const baseUrl = process.env.AI_BASE_URL ?? "http://localhost:8787/ai";
const templatesUrl =
  process.env.AI_TEMPLATES_URL ?? "http://localhost:8787/api/templates";
const headers = { Authorization: `Bearer ${token}` };
const content = structuredClone(AI_EVAL_TEMPLATES.welcome);
const logo = content.blocks.find((block) => block.type === "image");
if (!logo) throw new Error("The welcome fixture must contain an image.");
// Both conformance suites authorize this synthetic alt-text probe by block id.
content.blocks.push({
  ...logo,
  id: "conformance-image",
  src: AI_CONFORMANCE_ALT_TEXT_IMAGE_URL,
  alt: "",
});
const created = await fetch(templatesUrl, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    name: "Disposable AI conformance",
    content,
  }),
});
if (!created.ok)
  throw new Error(`Test template creation: HTTP ${created.status}`);
const template = (await created.json()) as { id: string };
if (!template.id) throw new Error("The templates endpoint must return { id }.");

// Match the guide's stateless server, whose adapters accept chat attachments.
const features = { conversation: false, chatAttachments: true } as const;
const timeoutMs = 60_000;
const provider = createHttpAiProvider({ baseUrl, headers, features });
const providerReport = await runAiProviderConformance(provider, {
  templateId: template.id,
  content,
  timeoutMs,
});
const wireReport = await runAiWireConformance({
  baseUrl,
  headers,
  features,
  templateId: template.id,
  timeoutMs,
});
for (const [suite, report] of [
  ["provider", providerReport],
  ["wire", wireReport],
] as const) {
  for (const check of report.checks) {
    console.log(suite, check.ok ? "ok" : "FAIL", check.name, check.detail);
  }
  if (!report.ok) process.exitCode = 1;
}
```

The example adds the default probe image as `blockId: "conformance-image"` so the backend can authorize it. Keep this disposable template available during both suites.

Both suites call enabled model features and spend tokens. Turn and fix checks can persist applied batches. With conversation enabled they check `list`, Undo/Redo and `settleReplay`, and clear history through `clear` when available. Wire conformance also sends invalid requests, including more than four attachments when chat attachments are enabled. Do not use a production template. Remove the test template through your store afterwards. Every failed check prints its name and detail; failure sets exit code 1.

- `createMockAiProvider(script)` plays recorded streams, for your UI tests; `AI_RECORDINGS` is the playground's.
- `runAiEvalSet({ runTurn, runAltText?, lintCheck?, loadFile?, model, cases?, minPassRate? })` runs `AI_EVAL_CASES`. `cases` replaces the set; `minPassRate` defaults to 90.
- `evalTurnWith(model, options?: { maxSteps?: number })` builds `runTurn`. A set `finding` uses `buildFixRequest(finding)`; otherwise it uses `buildChatRequest(message, { hasAttachments })` and the Blob attachments.
- `evalAltTextWith(model, options?: { imageUrl?: (file: string) => string })` builds `runAltText: (input: AiEvalAltTextInput) => Promise<string>`. It uses `buildAltTextRequest`, `locale: "en"` and `checkAltText(text, { maxLength })`. An invalid first answer gets exactly one repair retry before the final `checkAltText`.

An omitted `kind` means `edit`. File paths are relative to `eval/`. The exported types and their supporting types are:

```ts
import type { AiChatAttachment, AiFixFinding, AiMergeTags, TemplateContent } from "@templatical/types";

export type AiEvalCheck =
  | { type: "applied" | "notApplied" }
  | { type: "present" | "missing"; block: string }
  | { type: "contains" | "notContains"; block?: string; text: string }
  | { type: "lintGone"; ruleId: string }
  | { type: "field" | "fieldNot"; block: string; path: string; matches: string }
  | { type: "setting"; path: string; matches: string }
  | { type: "greater"; block: string; path: string; than: number }
  | { type: "maxText"; block: string; chars: number }
  | { type: "countType"; blockType: string; min: number }
  | { type: "order"; first: string; then: string }
  | { type: "firstContains"; text: string }
  | { type: "reply"; matches: string };

export type AiEvalAltCheck =
  | { type: "nonEmpty" | "withinLimit" }
  | { type: "containsAll" | "containsAny"; texts: string[] }
  | { type: "notMatches"; matches: string }
  | { type: "shortOrMatches"; chars: number; matches: string };

type EvalTemplate =
  "welcome" | "promo" | "injection" | "empty" | "domain-mismatch" | "low-text";

interface EvalTurnCase {
  name: string;
  template: EvalTemplate;
  /** Block ids the case may delete; every other original id must survive. */
  removed?: string[];
  checks: AiEvalCheck[];
}

export interface AiEvalAltTextInput {
  /** Path relative to the package's eval/ folder. */
  image: string;
  linkHref: string | null;
  nearbyText: string;
  maxLength: number;
}

export type AiEvalCase =
  | (EvalTurnCase & {
      kind?: "edit";
      message: string;
      selectedBlockId?: string;
      attachments?: { file: string; kind: "image" | "pdf" }[];
    })
  | (EvalTurnCase & { kind: "fix"; finding: AiFixFinding })
  | (AiEvalAltTextInput & {
      kind: "altText";
      name: string;
      checks: AiEvalAltCheck[];
    });

export interface AiEvalTurnInput {
  content: TemplateContent;
  mergeTags: AiMergeTags;
  message: string;
  selectedBlockId: string | null;
  finding: AiFixFinding | null;
  attachments: AiChatAttachment[];
}

export interface AiEvalTurnOutput {
  /** The template after the turn. */
  content: TemplateContent;
  reply: string;
  /** Whether the turn changed the template. */
  applied: boolean;
}

export interface AiEvalReport {
  model: string;
  passed: number;
  /** Cases that ran; skipped cases are excluded. */
  total: number;
  skipped: number;
  /** Whole percent, rounded down. */
  rate: number;
  ok: boolean;
  results: {
    name: string;
    ok: boolean;
    reason: string | null;
    skipped?: true;
  }[];
}
```

`lintCheck?: (content: TemplateContent) => string[]` returns rule IDs after the turn. The check `{ type: "lintGone"; ruleId: string }` requires the ID to be absent. `{ type: "fieldNot"; block: string; path: string; matches: string }` negates `field`; the block must exist. The domain case checks that the `href` stays unchanged and visible text contains no `www.` or `://`.

Text comparisons in `AiEvalAltCheck` are case-insensitive. Lengths use `string.length`; `withinLimit` checks the case's `maxLength`.

Without `runAltText`, alt-text cases are skipped; without `lintCheck`, cases with `lintGone` are skipped. `AiEvalReport.skipped` counts them, and their result has `skipped?: true`. `passed`, `total` and `rate` exclude them. `rate` is rounded down and is 0 when no case ran. A thrown error fails a case that ran.

`loadFile?: (file: string) => Promise<Blob>` loads attachments. Its default fetches `https://raw.githubusercontent.com/templatical/sdk/v${version}/packages/ai-kit/eval/${file}`. `evalAltTextWith` uses the same URL for images, reading `version` from the package at build time. For unreleased builds, set `imageUrl`; for local Node runs, `loadFile` reads the installed `eval/` folder. The [complete Node example](/guide/bring-your-own-ai#backend-conformance-and-model-evaluation) connects both runners to `@templatical/quality` and file loading.

The [conformance suites](#testing) check non-empty alt text within `maxLength`, abort handling and `altText: false`.

## Translation {#translation}

The independent translation contract does not need `AiProvider`. Import `createHttpTranslationProvider({ url, headers?, fetch? })` from `@templatical/ai-kit/client`; it returns `TranslationProvider`. Import `translateFields({ request, model, signal? })` and `readTranslationRequest(request)` from `@templatical/ai-kit/server`. The server validates call bounds, collects model text with no tools, repairs once and returns passing fields or `translation_failed` when none pass. It does not sanitize HTML.

`createMockTranslationProvider({ translations?, delayMs? })` and `runTranslationConformance(provider, options?: { locale?: string })` are exported from `@templatical/ai-kit/testing`. Records are keyed by target locale then field id; absent records get a target-language prefix. Conformance checks exact placeholders, allowed HTML tag names, partial results, `content_too_large` and aborts. See [Translation](/backend/translation) for exact types, the CI-run server handler and the runnable conformance check.

AI requests also carry the active `locale` (`null` in the default view); alt text always uses a concrete language. `content` is the projection in chat/Fix, and history's `AiChatMessage.locale` is optional for older entries. Rewrite's Translate preset uses its chosen target language. `checkRewrite` requires the exact merge and logic tag multiset, including chips and declared tag values for `custom`. See [language-aware AI](/backend/ai#ai-in-language-views).

## Files

`@templatical/ai-kit/adapters/anthropic`, `@templatical/ai-kit/adapters/openai` and `@templatical/ai-kit/adapters/vercel` are the first-party model adapter entry points.

`@templatical/ai-kit/prompts/*.md`, `@templatical/ai-kit/prompts/*.json`, `@templatical/ai-kit/schema/*.json` and `@templatical/ai-kit/eval/*` (with `MANIFEST.sha256`) are the prompts, schemas and test set as files, for a backend in another language. The editing system prompt is `editing.md`, then `"\n\n# Operations\n\n"` and `operations.md`, then `"\n\n# Template content schema\n\n"` and `template-content.schema.json`.

## The wire

Every request and response carries `Templatical-AI-Wire: 1`; turns, alt text, Undo, Redo and replay settlement carry `X-Templatical-Session`. A cross-origin backend allows `Templatical-AI-Wire`, `X-Templatical-Session` and its auth header in `Access-Control-Allow-Headers` for preflight, and exposes `Templatical-AI-Wire` in `Access-Control-Expose-Headers`. Routes are relative to your base URL; `{t}` is the template id.

| Route | Request | Response |
| --- | --- | --- |
| `POST /templates/{template}/translate` | JSON `TranslationRequest` | JSON `TranslationResult`; `Templatical-AI-Wire: 1` |
| `POST /templates/{t}/ai/generate` | JSON `{ locale, message, selectedBlockId, mergeTags, content, revision }`, or multipart with repeated `attachment` parts | SSE |
| `POST /templates/{t}/ai/fix-finding` | JSON `{ locale, finding, mergeTags, content, revision }` | SSE |
| `POST /templates/{t}/ai/rewrite-text` | JSON `{ locale, html, preset, language, instruction, mergeTags }` | SSE: `text`*, `done { html }` |
| `POST /templates/{t}/ai/alt-text` | JSON `{ blockId, image: { url }, currentAlt, linkHref, nearbyText, locale, maxLength }` | SSE: `done { alt }` |
| `GET /templates/{t}/ai/conversation-messages` | — | JSON `{ data: AiChatMessage[] }` |
| `POST /templates/{t}/ai/messages/{m}/undo`, `…/redo` | — | JSON `{ data: { operations, replayId } }` or `{ data: { batchId, revision } }` |
| `POST /templates/{t}/ai/messages/{m}/replays/{replayId}` | JSON `{ direction, applied }`, `X-Templatical-Session` | 204 |
| `DELETE /templates/{t}/ai/conversation` | — | 204 |

**Events** are `data: {json}` lines, each with a `type`; `: ping` comments keep the connection alive. `text { text }`; `operations { ops }` (the editor applies them); `applied { batchId, revision, operationCount }`; `done { messageId }` (turns: a string, or `null` without history), `done { html }` (rewrite), `done { alt }` (alt text); `error { code, message }`. `done` or `error` is the last event.

**Errors before the stream** are JSON `{ message, code, …details }` with the status of the code: `feature_unavailable` and `limit_reached` 403, `rate_limited` 429 with `Retry-After`, `ai_turn_running` 409, `content_too_large` 413, `ai_output_invalid` 422, `ai_provider_error` 502, `not_found` 404, an operation's code 409 (`invalid_operation` 422). A validation failure is a 422 `{ message }`.

A message's `batches` are `{ batchId, revision, operationCount }`; a batch the editor applied itself has no backend revision, so store `revision: null` for it.


`AI_LIMITS.contentMaxBytes` caps JSON-serialized template content at 200,000 UTF-8 bytes; `readAiRequest` refuses larger content with `content_too_large` (413). `selectedBlockId` and the session id are limited to 200 characters. These character limits count Unicode code points; lint-finding field limits are described under [Lint fixes](#lint-fixes).
