---
title: "@templatical/ai-kit"
description: Das KI-Protokoll, ein HTTP-Provider, ein Server-Kit mit Prompts und Tools und eine Konformitätssuite. 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, ohne LLM-SDK und ohne reine Node-API: Web Streams und `fetch`, sodass derselbe Code in Node, Deno, Bun, Cloudflare Workers und Browsern läuft.

## Anfrage-, Ergebnis- und Verlaufsfelder

Die vollständigen Provider-Deklarationen aus `@templatical/types` enthalten Anfragen, Ergebnisse, Handler und Verlauf einschließlich `AiChatAttachment` und `AiAltTextRequest`. `conversation.list` liefert Nachrichten vom ältesten zum neuesten Eintrag; `createdAt` ist ISO 8601. `batches` ist für Benutzernachrichten leer; `revision` ist für editorseitige Batches `null`, für serverseitige Batches eine Zahl. Nur Benutzernachrichten haben optionale Anhang-Metadaten, keine Dateien.

```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 {
  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 {
  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">;
```

### Anfragelimits

| Feld | Grenze und Darstellung |
| --- | --- |
| `content` | Serialisiertes JSON mit höchstens 200.000 UTF-8-Bytes; kein Beleg für Schema-Validität |
| `revision` | Nicht negative Ganzzahl oder `null` ohne Zusammenarbeit |
| `message` | 1–4.000 Codepoints, nicht nur Leerraum |
| `selectedBlockId`, `sessionId` | Höchstens 200 Codepoints; Auswahl kann `null` sein |
| `mergeTags` | Erforderliches `{ syntax, tags }`; `tags: []`, wenn keine deklariert sind; höchstens 500 Tags; Syntax `liquid`, `handlebars`, `mailchimp`, `ampscript` oder `custom` |
| Merge-Tag-Felder | `value` ≤ 200, `label` ≤ 100, optional `group` ≤ 100 und `description` ≤ 200 Codepoints; keine Samples oder Callbacks |
| Rewrite | `html` 1–20.000 Codepoints; `instruction` ≤ 500; mindestens `preset` oder `instruction`; `language` bei `translate` erforderlich passend zu `/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/` |
| Fix-Befund | [Lint-Fixes](#lint-fixes) nennt alle Feldlimits und ausgeschlossenen Felder |
| Alt-Text | [Alt-Text](#alt-text) nennt URL-, Kontext-, Locale- und Ausgabelimits |
| Anhänge | [Chat-Anhänge](#chat-anhange) nennt MIME-Typen, Dateinamen- und Bytegrenzen |
| Operationsbatch | 1–100 Operationen in Reihenfolge; die Batches eines Durchlaufs bilden einen Rückgängig-Schritt |

`mergeTags` enthält immer die Syntax des Editors (standardmäßig `liquid`), auch bei leerer Tag-Liste. `readAiRequest` lehnt fehlenden oder `null`-Kontext mit Status 422 und der Meldung `mergeTags must be { syntax, tags } with at most 500 tags.` ab. Die Editing-Session und `checkRewrite` lehnen erfundene Tags auch bei leerer deklarierter Liste ab; bereits in der Eingabe vorhandene Tags bleiben erlaubt. Die Syntax `custom` wird nicht geprüft, da ihre Muster nicht an das Backend gesendet werden.

## Client

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

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

| Option | |
| --- | --- |
| `baseUrl` | Wo die Routen des Protokolls liegen |
| `headers` | Ihre Auth-Header oder eine Funktion (für eine Token-Erneuerung auch asynchron), die pro Anfrage aufgerufen wird |
| `credentials` | Die `credentials` von `fetch` für Cookie-Authentifizierung |
| `features` | `false` schaltet ein Mitglied aus, auch `conversation` |

Der Provider liest den Event-Stream und lehnt mit `SdkError` ab (`ThrottledError` bei 429, `CollaborationConflict` bei einer verweigerten Operation). Da ein Stream nur in eine Richtung läuft, beendet er einen Durchlauf, dessen Operationen der Editor verweigert hat, durch Abbruch der Anfrage: Der Durchlauf lehnt mit dem Code dieser Operation und „The template changed while the AI was working.“ ab.

## Server

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

| Export | |
| --- | --- |
| `editingInstructions` | Der System-Prompt zum Bearbeiten: die Regeln, die [Operationen](/de/api/operations) und das Template-Schema |
| `rewriteInstructions` | Die anderen Prompts |
| `buildEditingPrompt`, `buildChatRequest`, `buildFixRequest`, `buildRewritePrompt`, `buildAltTextRequest` | Prompts pro Anfrage |
| `applyOperationsTool` | Das einzige Tool: `apply_operations`, dessen `ops` ein JSON-String ist |
| `createEditingSession` | Die Arbeitskopie eines Durchlaufs: prüft jeden Aufruf (Form, die Grenze von 100 Operationen, keine neuen Custom-, Slot- oder Wrapper-Blöcke, deklarierte Merge-Tags, die Engine, eine Größenbegrenzung) und antwortet mit einem Satz, auf den das Modell reagieren kann |
| `runEditingTurn` | Die Referenzschleife über einen `AiModelAdapter`, höchstens drei Modellaufrufe |
| `aiEventStream` | Die Event-Stream-`Response` für jeden Fetch-API-Server |
| `aiErrorResponse`, `aiJsonResponse` | Fehler vor dem Stream und JSON-Antworten, mit dem Header des Protokolls |
| `readAiRequest` | Eine Anfrage des Protokolls, eingelesen und geprüft |
| `checkRewrite`, `buildRewriteRepairPrompt` | Prüfungen der Ausgabe |
| `checkAltTextAnswer`, `buildAltTextRepairRequest`, `checkAltText` | Alt-Text-Prüfung und eine Reparaturanfrage |

Das Kit enthält keine HTML-Bereinigung. Der Editor bereinigt die Vorschau einer Umformulierung und das mit „Replace“ übernommene HTML. HTML-Blöcke, die die KI schreibt, behalten ihr Markup unverändert; befolgen Sie die [Hinweise zur serverseitigen Bereinigung vor dem Speichern oder Versenden](/de/guide/blocks#html).

### Lint-Fixes

`AI_FIXABLE_RULES` aus `@templatical/editor` enthält genau die unten aufgeführten Regeln. `AI_ALT_TEXT_RULES` enthält genau `a11y.img-missing-alt`, `a11y.img-alt-is-filename`, `a11y.img-alt-too-long` und `a11y.img-linked-no-context`. Beide Aktionen benötigen `@templatical/quality`, aktiviertes Lint und ein geladenes Template. Die kanonischen Kategorien, Kataloge und Optionen stehen unter [Barrierefreiheit](/de/quality/accessibility/), [Accessibility-Regelkatalog](/de/quality/accessibility/rule-catalog), [Zustellbarkeit](/de/quality/deliverability/), [Zustellbarkeits-Regelkatalog](/de/quality/deliverability/rule-catalog) und [Optionen](/de/quality/options).

`fixFinding` erhält die JSON-serialisierbare Lint-Projektion `finding: { ruleId, message, suggestion, reference, blockId, excerpt? }`; `severity`, `clients` und die lokale `fix`-Funktion gehören nicht dazu. Die Regeln und ihre Befunde sind unter [Qualität](/de/quality/) dokumentiert. Der Editor bietet Fixes für diese Regel-IDs an:

- `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)` füllt `prompts/fix.md` mit Diagnosedaten und der Anleitung aus `prompts/fix-rules.json`, exportiert als `FIX_RULE_GUIDANCE` aus `@templatical/ai-kit/server`. Unbekannte Regeln erhalten `Change only what the finding names.`. Alle Befundfelder sind Daten, keine Anweisungen; das Modell darf `reference` nicht abrufen. Bei Domain-Abweichungen bleibt das `href` unverändert, und bei zu wenig Text werden nur vorhandene Fakten als Text im E-Mail-Inhalt ergänzt.

`readAiRequest` lehnt ungültige Felder mit `SdkError` (Status 422, kein `code`) ab und kürzt nie: `ruleId` ist nicht leer und höchstens 200 Zeichen lang; `message`, `suggestion` und das optionale `excerpt` haben höchstens 1.000 Unicode-Codepoints; `reference` ist eine `http:`- oder `https:`-URL mit höchstens 2.048 Zeichen; `blockId` ist `null` oder eine Zeichenkette mit höchstens 200 Zeichen. Die Limits für IDs und URLs zählen UTF-16-Einheiten (`string.length`).

Prüfen Sie Ihren Fix-Endpunkt mit `runAiWireConformance({ baseUrl })` aus dem folgenden Beispiel; auch `runAiProviderConformance(provider)` sendet einen Lint-Befund an `fixFinding`.

### Alt-Text

`buildAltTextRequest(request: AiAltTextRequest)` liefert `{ system: string; messages: AiModelMessage[] }` mit dem Bild als `image-url`-Anhang der Nutzernachricht. `prompts/alt-text.md` verlangt den Zweck des Bildes in dieser E-Mail, Ziel oder Aktion verlinkter Bilder, sichtbaren Bildtext, die Sprache des Templates, Klartext und das `maxLength` der Anfrage. Umgebender Text und vorhandener Alt-Text sind Daten, keine Anweisungen.

`checkAltTextAnswer` prüft eine Antwort ohne Ausnahme und liefert `{ ok: true, alt }` oder `{ ok: false, reason }`. `checkAltText` ist die abschließende Prüfung und wirft bei einer ungültigen Antwort `SdkError(AI_ERROR_MESSAGES.ai_output_invalid, 502, "ai_output_invalid")`.

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

Die Normalisierung entfernt äußere Leerzeichen, ein umschließendes Paar einfacher oder doppelter Anführungszeichen und ein führendes „Image of“ oder „Picture of“ unabhängig von Groß-/Kleinschreibung, dann erneut äußere Leerzeichen. Nach dem Entfernen äußerer Leerzeichen sind Zeilenumbrüche unzulässig, auch wenn der Text kurz ist oder die Präfix-Normalisierung den Umbruch entfernen würde. Die Länge der normalisierten Zeichenkette zählt UTF-16-Einheiten (`alt.length`). Die Prüfung liefert den ersten zutreffenden Grund in dieser Reihenfolge:

| Bedingung | `reason` (unverändert) |
| --- | --- |
| Nach der Normalisierung leer | `Your answer was empty.` |
| Enthält einen Zeilenumbruch (CR, LF, U+2028 oder U+2029) | `Your answer had several lines.` |
| Enthält HTML (`/<[a-z]/i`) | `Your answer contained HTML.` |
| Länger als `maxLength` | `` `Your answer was ${alt.length} characters; the limit is ${maxLength}.` `` |

`buildAltTextRepairRequest(built, answer, reason, maxLength)` übernimmt denselben System-Prompt und die ursprünglichen Nachrichten einschließlich des Bildanhangs. Danach folgen `{ role: "assistant", content: answer }` und eine Nutzernachricht mit dem Inhalt `` `${reason} Reply again with only the alt text: one line of plain text, at most ${maxLength} characters.` ``. Übergeben Sie `maxLength` aus der ursprünglichen Anfrage als vierten Parameter. Die Funktion verändert `built` nicht und ruft kein Modell auf.

Der Server im Leitfaden und `evalAltTextWith` prüfen die erste Antwort mit `checkAltTextAnswer`. Bei Ablehnung senden sie genau eine Reparaturanfrage mit `buildAltTextRepairRequest` und prüfen danach mit `checkAltText`. Eine zweite ungültige Antwort löst `ai_output_invalid` aus; es gibt keinen dritten Modellaufruf. Prüfen Sie Ihren Endpunkt mit `runAiWireConformance({ baseUrl })` aus dem folgenden Beispiel.

`readAiRequest` validiert die Route `altText` ohne Kürzung: `blockId` ist nicht leer und höchstens 200 Zeichen lang; `image.url` ist eine mit `new URL()` geparste `http:`- oder `https:`-URL mit höchstens 2.048 Zeichen; `currentAlt` und `linkHref` sind Zeichenketten mit höchstens 2.048 Zeichen oder `null`; `nearbyText` hat höchstens 2.000 Codepoints; `locale` hat höchstens 35 Zeichen und entspricht `/^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/`; `maxLength` ist eine Ganzzahl von 1 bis 1.000. Ungültige Felder lösen `SdkError(message, 422)` ohne Code aus. Alle Zeichenlimits außer `nearbyText` zählen UTF-16-Einheiten.

Der Editor liefert seinen wirksamen Lint-Wert `altMaxLength`, Standard 125. Prüfen Sie die Antwort immer mit dem Anfragewert. Der Modellanbieter ruft `image.url` ab; Ihr Backend autorisiert Template und Block und löst private oder signierte Medien auf. [Alt-Text im Editor](/de/backend/ai#alt-text) beschreibt die Prüfung und das Flag `decorative`.

Die Union der Modellanhänge lautet:

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

### Modell-Adapter

`runEditingTurn` ruft Ihr Modell über eine Methode auf:

```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[] }
  | { role: "tool"; toolCallId: string; name: string; content: string };

export type AiModelPart =
  | { type: "text"; text: string }
  | ({ type: "tool-call" } & AiModelToolCall)
  | { type: "usage"; inputTokens: number; outputTokens: number };

export interface AiUsage {
  inputTokens: number;
  outputTokens: number;
}

/**
 * Your model, behind one method: stream a reply to `messages` with `system` as
 * the system prompt and `tools` offered. About twenty lines for the Vercel AI
 * SDK, OpenAI or Anthropic (see the guide). Honour `signal`.
 */
export interface AiModelAdapter {
  stream(input: {
    system: string;
    messages: AiModelMessage[];
    tools: AiToolDefinition[];
    signal?: AbortSignal;
  }): AsyncIterable<AiModelPart>;
}
```

[Eigenes KI-Backend anbinden](/de/guide/bring-your-own-ai#modell-adapter) enthält Adapter für das Vercel AI SDK, OpenAI und Anthropic. Alle Beispiele unterstützen Chat, Fix, Umformulieren und Alt-Text sowie Bild-URLs für Alt-Text. Nur das Anthropic-Beispiel akzeptiert Bild- und PDF-Blobs im Chat. Die Client-Konfigurationen für OpenAI und Vercel setzen `chatAttachments: false` und schalten den gespeicherten Gesprächsverlauf aus.

### Chat-Anhänge

`AiChatRequest.attachments` ist immer vorhanden: `[]` ohne Dateien, sonst `{ file: Blob, kind: "image" | "pdf", name: string }[]`. `AiProvider.chatAttachments: false | true` ist ein Flag: `true` bedeutet, dass die `chat`-Funktion Anhänge akzeptiert; bei `chat: false` hat es keine Wirkung. Der HTTP-Provider aktiviert es standardmäßig bei aktivem Chat; `features: { chatAttachments: false }` deaktiviert es.

Ohne Anhänge bleibt der JSON-Body unverändert. Mit Anhängen benennt `AI_GENERATE_FIELDS` die Felder `message`, `selectedBlockId`, `mergeTags`, `content`, `revision` und wiederholte `attachment`-Dateiteile. `message` und `selectedBlockId` sind Text; `mergeTags` und `content` sind JSON-Strings; `revision` ist eine Zahl als Text. Fehlende `selectedBlockId` und `revision` werden `null`. Multipart ohne Dateiteile ist gültig. JSON-Bodys ergeben immer `attachments: []`.

`readAiRequest` akzeptiert die MIME-Typen `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/pdf`. Es lehnt ohne Kürzung mit Status 422 und ohne Code in dieser Reihenfolge ab:

| Bedingung | Nachricht |
| --- | --- |
| Formular nicht lesbar | Send the message as multipart form data. |
| Mehr als 4 Dateien | Attach at most 4 files. |
| Kein Dateiteil oder nicht unterstützter MIME-Typ | Attach PNG, JPEG, WebP or GIF images, or PDFs. |
| Eine Datei über `5 * 1024 * 1024` Byte | Each attachment must be 5 MB or smaller. |
| Zusammen über `10 * 1024 * 1024` Byte | Attachments must be 10 MB or smaller together. |
| Dateiname über 255 UTF-16-Einheiten | An attachment's file name must be at most 255 characters. |

`buildChatRequest(message: string, options?: { hasAttachments?: boolean })` lässt die reine Textanfrage standardmäßig unverändert; mit `hasAttachments: true` hängt es eine Leerzeile und `prompts/chat-attachments.md` an. Übergeben Sie die eingelesenen Dateien als Modellanhänge `{ kind, mediaType: file.type, data: file }`, wie im Leitfaden. Der Prompt nutzt Referenzen für die angeforderte Änderung und ersetzt das ganze Template nur bei leerem Inhalt oder ausdrücklicher Aufforderung; Bilder werden Platzhalter.

Die Konformitätssuiten senden bei aktiven Chat-Anhängen ein 1×1-PNG; die Protokollsuite erwartet außerdem 422 bei fünf Anhängen. Der Verlauf speichert nur `{ name, kind }` im optionalen `attachments` der Benutzernachricht; die Dateiaufbewahrung bestimmt das Backend.

## Testen

Beide Signaturen, alle Optionen und das gemeinsame Ergebnis:

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

Die Provider-Prüfung verwendet die aktivierten Provider-Funktionen. `headers` und `features` gehören zur Wire-Prüfung; fehlende Flags aktivieren alle Funktionen. Standardwerte: `templateId: "conformance"`, `content: AI_EVAL_TEMPLATES.welcome`, `mergeTags: AI_EVAL_MERGE_TAGS`, `timeoutMs: 5000` ms. `callTimeoutMs` kann `timeoutMs` nicht überschreiten.

`altTextImageUrl` verwendet standardmäßig `AI_CONFORMANCE_ALT_TEXT_IMAGE_URL` aus `@templatical/ai-kit/testing`: `https://raw.githubusercontent.com/templatical/sdk/v${version}/packages/ai-kit/eval/images/probe.png` (`version` ist die installierte Kit-Version). Das Bild zeigt eine petrolfarbene Tasse ohne Text. Übergeben Sie bei unveröffentlichten Builds mit `altTextImageUrl` ein öffentliches Bild, das Ihr Modellanbieter abrufen kann.

Für Node.js 24 speichern Sie das folgende Beispiel als `check-ai-backend.ts`. Ihr authentifizierter `POST /api/templates` nimmt `{ name, content }` an und gibt `{ id }` zurück. Setzen Sie `AI_TEST_TOKEN`; `AI_BASE_URL` und `AI_TEMPLATES_URL` überschreiben die lokalen URLs. Führen Sie `node check-ai-backend.ts` aus. Das Beispiel erzeugt eine Wegwerf-Vorlage und deaktiviert Verlauf und Chat-Anhänge passend zum zustandslosen Server im [Backend-Leitfaden](/de/guide/bring-your-own-ai#der-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 and a text-only chat adapter.
const features = { conversation: false, chatAttachments: false } 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;
}
```

Das Beispiel fügt das Standardprüfbild als `blockId: "conformance-image"` ein, damit das Backend es autorisieren kann. Halten Sie diese Wegwerf-Vorlage während beider Suiten verfügbar.

Beide Suiten rufen aktivierte Modellfunktionen auf und verbrauchen dabei Tokens. Turn- und Fix-Prüfungen können angewendete Batches persistieren. Mit Verlauf prüfen sie `list`, Undo/Redo und `settleReplay` und leeren ihn über `clear`, sofern verfügbar. Die Wire-Suite sendet außerdem ungültige Anfragen, darunter mehr als vier Anhänge bei aktiven Chat-Anhängen. Verwenden Sie kein produktives Template. Entfernen Sie die Testvorlage anschließend über Ihren Speicher. Jede fehlgeschlagene Prüfung wird mit Name und Detail ausgegeben; Fehler setzen den Exit-Code auf 1.

- `createMockAiProvider(script)` spielt aufgezeichnete Streams für Ihre Oberflächentests ab; `AI_RECORDINGS` enthält die des Playgrounds.
- `runAiEvalSet({ runTurn, runAltText?, lintCheck?, loadFile?, model, cases?, minPassRate? })` führt `AI_EVAL_CASES` aus. `cases` ersetzt den Testsatz; `minPassRate` ist standardmäßig 90.
- `evalTurnWith(model, options?: { maxSteps?: number })` erstellt `runTurn`. Bei gesetztem `finding` verwendet es `buildFixRequest(finding)`, sonst `buildChatRequest(message, { hasAttachments })` und die Blob-Anhänge.
- `evalAltTextWith(model, options?: { imageUrl?: (file: string) => string })` erstellt `runAltText: (input: AiEvalAltTextInput) => Promise<string>`. Es verwendet `buildAltTextRequest`, `locale: "en"` und `checkAltText(text, { maxLength })`. Eine ungültige erste Antwort erhält genau einen Reparaturversuch vor der abschließenden Prüfung mit `checkAltText`.

Ein fehlendes `kind` bedeutet `edit`. Dateipfade sind relativ zu `eval/`. Die exportierten Typen und ihre Hilfstypen lauten:

```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[]` liefert Regel-IDs nach dem Durchlauf. Die Prüfung `{ type: "lintGone"; ruleId: string }` erwartet, dass die ID fehlt. `{ type: "fieldNot"; block: string; path: string; matches: string }` negiert `field`; der Block muss existieren. Der Domain-Fall prüft den unveränderten `href` und sichtbaren Text ohne `www.` oder `://`.

Textvergleiche in `AiEvalAltCheck` ignorieren Groß- und Kleinschreibung. Längen verwenden `string.length`; `withinLimit` prüft das `maxLength` des Falls.

Ohne `runAltText` werden Alt-Text-Fälle übersprungen; ohne `lintCheck` werden Fälle mit `lintGone` übersprungen. `AiEvalReport.skipped` zählt sie, und ihr Ergebnis hat `skipped?: true`. `passed`, `total` und `rate` schließen sie aus. `rate` ist abgerundet und bei keinem ausgeführten Fall 0. Ein geworfener Fehler lässt einen ausgeführten Fall fehlschlagen.

`loadFile?: (file: string) => Promise<Blob>` lädt Anhänge. Standardmäßig ruft es `https://raw.githubusercontent.com/templatical/sdk/v${version}/packages/ai-kit/eval/${file}` ab. `evalAltTextWith` verwendet dieselbe URL für Bilder und liest `version` beim Build aus dem Paket. Für unveröffentlichte Builds setzen Sie `imageUrl`; für lokale Node-Läufe liest `loadFile` das installierte `eval/`-Verzeichnis. Das [vollständige Node-Beispiel](/de/guide/bring-your-own-ai#backend-konformitat-und-modellevaluierung) verbindet beide Runner mit `@templatical/quality` und Dateizugriff.

Die [Konformitätsprüfungen](#testen) prüfen nicht leeren Alt-Text innerhalb von `maxLength`, Abbruchverhalten und `altText: false`.

## Dateien

`@templatical/ai-kit/prompts/*.md`, `@templatical/ai-kit/prompts/*.json`, `@templatical/ai-kit/schema/*.json` und `@templatical/ai-kit/eval/*` (mit `MANIFEST.sha256`) sind Prompts, Schemas und Testsatz als Dateien für ein Backend in einer anderen Sprache. Der System-Prompt zum Bearbeiten besteht aus `editing.md`, dann `"\n\n# Operations\n\n"` und `operations.md`, dann `"\n\n# Template content schema\n\n"` und `template-content.schema.json`.

## Das Protokoll

Jede Anfrage und Antwort enthält `Templatical-AI-Wire: 1`; Durchläufe, Alt-Text, Rückgängig, Wiederholen und deren Bestätigung enthalten `X-Templatical-Session`. Ein Cross-Origin-Backend erlaubt beim Preflight `Templatical-AI-Wire`, `X-Templatical-Session` und den Authentifizierungs-Header in `Access-Control-Allow-Headers` und gibt `Templatical-AI-Wire` über `Access-Control-Expose-Headers` frei. Die Routen sind relativ zu Ihrer Basis-URL; `{t}` ist die Template-ID.

| Route | Request | Response |
| --- | --- | --- |
| `POST /templates/{t}/ai/generate` | JSON `{ message, selectedBlockId, mergeTags, content, revision }`, oder Multipart mit wiederholten `attachment`-Dateiteilen | SSE |
| `POST /templates/{t}/ai/fix-finding` | JSON `{ finding, mergeTags, content, revision }` | SSE |
| `POST /templates/{t}/ai/rewrite-text` | JSON `{ 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** sind `data: {json}`-Zeilen mit jeweils einem `type`; `: ping`-Kommentare halten die Verbindung offen. `text { text }`; `operations { ops }` (der Editor wendet sie an); `applied { batchId, revision, operationCount }`; `done { messageId }` (Durchläufe: String oder `null` ohne Verlauf), `done { html }` (Umformulieren), `done { alt }` (Alt-Text); `error { code, message }`. `done` oder `error` ist das letzte Event.

**Fehler vor dem Stream** sind JSON `{ message, code, …details }` mit dem Status des Codes: `feature_unavailable` und `limit_reached` 403, `rate_limited` 429 mit `Retry-After`, `ai_turn_running` 409, `content_too_large` 413, `ai_output_invalid` 422, `ai_provider_error` 502, `not_found` 404, ein Operationscode 409 (`invalid_operation` 422). Ein Validierungsfehler ist ein 422 `{ message }`.

Die `batches` einer Nachricht sind `{ batchId, revision, operationCount }`; ein Batch, den der Editor selbst angewendet hat, hat keine Backend-Revision. Speichern Sie dafür `revision: null`.


`AI_LIMITS.contentMaxBytes` begrenzt den JSON-serialisierten Template-Inhalt auf 200.000 UTF-8-Bytes; `readAiRequest` lehnt größere Inhalte mit `content_too_large` (413) ab. `selectedBlockId` und die Sitzungs-ID sind auf 200 Zeichen begrenzt. Diese Zeichenlimits zählen Unicode-Codepunkte; die Feldlimits für Lint-Befunde stehen unter [Lint-Fixes](#lint-fixes).
