English
AI
Give the editor an AI provider and your users get an AI assistant that edits the template they're working on: a chat with Undo, Rewrite with AI in the paragraph toolbar, reviewed alt-text drafts in Issues, static localized chat starters, and a template built from a screenshot or a PDF. Your backend talks to the model, with your keys, your data-processing terms and your guardrails.
The editor owns every surface, the streaming, applying the AI's edits with Undo, and the labelling that says the content comes from AI. You own the model call: one function per feature, or a few HTTP endpoints that @templatical/ai-kit writes most of.
Quick start
ts
import { init } from '@templatical/editor';
import '@templatical/editor/style.css';
import { createHttpAiProvider } from '@templatical/ai-kit/client';
import type { Template } from '@templatical/types';
const editor = await init({
container: '#editor',
templates: {
load: async (id): Promise<Template> => {
const response = await fetch(`/api/templates/${encodeURIComponent(id)}`);
if (!response.ok) throw new Error('Template load failed.');
return response.json();
},
create: false,
save: false,
},
ai: createHttpAiProvider({
baseUrl: '/api/ai',
}),
});
await editor.load('tpl_123');createHttpAiProvider speaks a documented REST and server-sent-events wire; Bring your own AI backend builds the server side with @templatical/ai-kit/server and the AI API reference documents the wire for a backend in any language.
AI needs a loaded template. Every request names it, so the entry points appear once create() or load() has resolved. Omit ai and the feature is absent: no AI button, no toolbar button, and none of its UI is downloaded.
The contract
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">;Chat, fix and rewrite receive the editor's content after pending edits are sent, its collaboration revision (or null), templateId, sessionId and required mergeTags: { syntax, tags }. With no declared tags, the editor sends its syntax (liquid by default) and tags: []. Missing or null context returns 422. Every call receives an AbortSignal; Stop aborts it.
The declarations above and API request limits define each field. On the wire, template ID and session use the path and header; rewrite omits content and revision. The server kit checks merge tags, including empty lists; custom syntax is unchecked.
Turns: chat and fix
A turn streams its reply through on.onText and hands its edits over in one of two ways:
on.onOperations(ops): operations for the editor to apply. It applies them at once and answers{ applied: true, inverse }, or{ applied: false, code, opIndex, message }when they no longer fit the template (someone changed a block, another person holds it). A turn's batches are one undo step.on.onApplied({ batchId, revision, operationCount }): your backend already applied the batch to its copy. With a collaboration provider the editor follows the revision; with only a templates provider it reloads the template when the turn ends. With neither, it refuses the batch.
Without collaboration, an author edit made after a turn starts prevents the automatic reload, including an edit made while the reload request is pending. The editor keeps your version and shows a notice with Reload. The AI's changes remain saved on the server. Choose Reload to load them; saving your version replaces them. The AI's own editor-applied batches do not count as author edits.
chat sends message, selectedBlockId (only a block your backend has) and attachments, always an array. fixFinding sends the lint projection described below.
Chat attachments
AiChatAttachment is { file: Blob; kind: "image" | "pdf"; name: string }. AiChatRequest.attachments is always present: [] without files. chatAttachments: true means your chat function accepts attachments; the flag has no effect when chat: false. The controls need both features and a loaded template. The HTTP provider defaults the flag to true when chat is on; set features: { chatAttachments: false } for a text-only backend.
Authors can attach PNG, JPEG, WebP, GIF or PDF files with Attach files or drag files onto chat. The limits are at most 4 files per message, each at most 5 MB (5 * 1024 * 1024 bytes), at most 10 MB (10 * 1024 * 1024 bytes) together. Invalid files show validation messages. File-name chips have remove buttons; controls are disabled during a turn. Sending clears attachments; a failed turn restores the text and files.
Import from screenshot or PDF is the empty canvas's chat-attachments shortcut. The import shortcut opens a file picker first, then opens chat after a pick, attaches the files and fills an empty composer with “Build this email from the attached design.” Existing composer text is preserved. The author sends the message; nothing sends automatically. Pictures come in as placeholders; replace them after import. The prompt replaces the whole template only when empty or when the message asks for it; otherwise attachments guide the requested edit. A replacement is one undo step.
The header button labelled AI opens and closes the AI assistant.
History has kind chat and stores only optional user-message attachments: { name: string; kind: "image" | "pdf" }[]. The backend decides whether to retain files. The multipart generate wire and backend guide show parsing and model mapping.
Fix with AI
fixFinding receives finding: { ruleId, message, suggestion, reference, blockId, excerpt? }, the JSON-serializable projection of a lint finding. severity, clients and the local fix function are never sent. Every field is untrusted diagnostic data, never instructions; the model must not fetch the reference link.
AI_FIXABLE_RULES, exported from @templatical/editor, contains exactly these rule ids. The Issues panel offers Fix with AI for them when the finding has no deterministic fix, fixFinding is a function and a template is loaded:
a11y.link-vague-texta11y.button-vague-labela11y.link-emptya11y.text-all-capsa11y.missing-preheaderdeliverability.link-text-domain-mismatchdeliverability.low-text
The editor shows one button per ruleId/blockId pair, regardless of severity. Fix buttons are disabled during AI editing work; independent alt-text drafts do not block fixes; the running fix shows Fixing… and Stop. The turn remains one undo step with history kind fix. Verification reuses a lint result that already covers the fix's final content; otherwise it waits for the next re-lint. After verification, if any hit for the pair remains, the row shows Still flagged after the fix. This makes no claim about individual occurrences within a block.
For a domain mismatch, rewrite only the visible link text into descriptive words, never the href. For low text (blockId: null), add body copy from existing facts, without padding alt text or the preheader. a11y.missing-preheader sets preheaderText through updateSettings, using at most 100 characters from the opening content.
Fix with AI and Write alt text appear only when @templatical/quality is installed and lint is on (lint.disabled is not true). They also need their provider member, a matching finding and a loaded template.
AiFixFinding sends only ruleId, message, suggestion, reference, blockId and optional excerpt; severity, clients and the local fix function stay in the editor. ruleId is 1–200 UTF-16 units; blockId is null or at most 200 units. message, suggestion and excerpt are each at most 1,000 code points; reference is an http: or https: URL of at most 2,048 UTF-16 units. The server rejects invalid values with status 422 and never truncates.
The canonical contracts are Accessibility, the accessibility rule catalog, Deliverability, the deliverability rule catalog and lint options.
Alt text
altText(request, signal) returns { alt: string } for one image, with no content change or chat history. The request is AiAltTextRequest from @templatical/types:
Every field and its nullability are in AiAltTextRequest in the contract.
The image goes to the model by URL. The model provider fetches it, so your backend must authorize the template and block and resolve private or signed media. currentAlt is null when empty or decorative; linkHref comes from linkUrl. nearbyText contains the nearest plain text before and after the image in its column, falling back to its section, capped at 2,000 code points. locale is the template's locale: a BCP 47 tag of at most 35 UTF-16 units. image.url must use http: or https: and is at most 2,048 UTF-16 units; currentAlt and linkHref are each null or at most 2,048 units. blockId is 1–200 units.
maxLength is the effective accessibility lint altMaxLength threshold: an integer from 1 to 1,000, default 125 when absent, invalid or lint is disabled. It counts UTF-16 units (string.length). Use the request's value with checkAltText on your backend. The server in the backend guide and evalAltTextWith make one repair retry for empty, multiline, HTML or over-long alt text. If the second answer is invalid, the final checkAltText raises ai_output_invalid (502).
AI_ALT_TEXT_RULES, exported from @templatical/editor, contains exactly a11y.img-missing-alt, a11y.img-alt-is-filename, a11y.img-alt-too-long and a11y.img-linked-no-context. The Issues panel offers Write alt text once per image for a11y.img-missing-alt, a11y.img-alt-is-filename, a11y.img-alt-too-long and a11y.img-linked-no-context. The inline review has an editable draft. Accept writes { alt, decorative: false }; Mark decorative writes { alt: "", decorative: true }; Cancel and Escape write nothing. An empty alt alone still triggers img-missing-alt. The renderer forces alt="" and role="presentation" for decorative images.
Drafts run independently, can be stopped, and abort on template switches or editor destruction. Applying uses one AI batch and one undo step, split at 100 operations when needed; collaboration uses the user's batch with origin: 'ai'. Neither action adds chat history. altText: false hides the action; the HTTP equivalent is features: { altText: false }.
When at least two distinct images have alt-text findings, Write missing alt text ({count}) opens a bulk review in document order, with at most 3 provider calls at once. Review each thumbnail and editable draft, use the decorative toggle where needed, and select the rows to accept. Stop aborts pending drafts and leaves undrafted rows editable. Nothing is written before Apply accepted ({count}), which applies the selected rows in one batch with one undo step. Cancel and Escape write nothing.
Rewrite
rewrite gets a paragraph's html and a preset (shorten, expand, friendlier, formal, fixGrammar, translate with a language), an instruction, or both; it streams the text for a live preview and answers the final HTML.
Whole-template translation is not a separate feature. On request, the AI assistant can translate through chat and edit in place as one undo step, on a best-effort basis, with no field-coverage or merge-tag check. Rewrite's Translate preset translates one paragraph.
Chat starters
A chat without messages shows three static localized starters for a template without blocks, and four for a template with blocks. Clicking a starter sends its displayed text as a chat message. Starters require no model call and have no integrator override.
Content checks
Deterministic lint reports specific content, client and size findings. It does not predict inbox placement. See Deliverability and Compatibility.
History
conversation is optional. With it, the chat loads the stored messages when it opens, and each message's Undo and Redo call your backend, which answers the operations to apply ({ operations, replayId }) or a batch it applied ({ applied: { batchId, revision } }). Without it, the chat lives in memory for the session, and a message's Undo applies the turn's own inverse, refused when someone changed those blocks since.
After applying or refusing operations, the editor calls settleReplay(templateId, messageId, { replayId, direction, applied, sessionId }). Change undone only on applied: true; with applied: false, or no settlement, keep the message's state. Settle each replayId once; later settlements are no-ops. A server-applied { applied } answer is never settled. A settlement failure reaches onError, and the local row keeps the direction actually applied to the template.
conversation.list returns AiChatMessage[] oldest first. createdAt is ISO 8601; batches is empty for user messages. A server-applied batch has a numeric revision; an editor-applied batch has revision: null. kind is "chat" | "fix"; alt-text drafts create no history. Optional user-message attachments retain only names and kinds, never files.
Controlling features
Set a member to false and its entry point goes: chat: false hides the AI assistant, rewrite: false the toolbar button, fixFinding: false the Fix with AI buttons on lint findings, chatAttachments: false the attachment controls and import shortcuts. With createHttpAiProvider, pass features: { rewrite: false, conversation: false }.
Errors
Reject with an SdkError whose code the editor knows, and whose message the user can read:
code | When |
|---|---|
feature_unavailable | The feature is off for this user |
limit_reached | An allowance is used up; details: { limit, resetsAt } |
rate_limited | Too many requests; a ThrottledError with retryAfterMs |
ai_turn_running | A turn is already running on this template |
content_too_large | The template is too large for the model |
ai_output_invalid | The model's rewrite or alt-text output broke the rules |
ai_provider_error | The model provider failed |
Undo and Redo may reject with an operation's code (precondition_failed, …) as a CollaborationConflict. An AbortError means the user stopped the call and is never shown. The editor says each failure in words and passes it to init({ onError }) too.
A stopped or failed turn keeps the batches already applied. Undo reverts editor-applied batches as one step when the editor has their inverses. Server-applied onApplied batches need stored history with a callable conversation.undo; without that replay support, Undo is unavailable. Intervening edits by others can prevent replay. Stop does not roll back applied edits.
In the editor
- Header: the button labelled AI opens and closes the AI assistant. It appears when
chatis a function and a template is loaded. - Chat: labelled as an AI assistant ("Replies and changes come from AI."), each turn's "Applied N changes" with Undo and Redo, Stop while a turn runs, New chat, static localized starters. Cmd/Ctrl+Z undoes a whole turn.
- Paragraph toolbar: AI, with the presets, a target-language picker and a free instruction. The result shows as an AI suggestion, merge tags as tags and the markup sanitized; Replace makes it the user's own edit.
- Chat attachments: add files with the attach button or drag them into the panel, review name chips and remove files before sending. The composer has a full-width textarea above a toolbar with attach on the left and send or stop on the right. Attachment chips stay above the composer.
- The empty canvas: shortcuts to the AI assistant and to Import from screenshot or PDF.
AI content is always marked as AI, as the EU AI Act's Article 50 asks of systems that interact with people.
With collaboration
With a collaboration provider, the AI's operations travel as the user's own batches with origin: 'ai'. A backend that honours it broadcasts the batch with an ai actor, and other editors show "AI (for Ada) changed 3 blocks" with an outline on those blocks; the editor that asked shows the outline only.
Events
Failures reach init({ onError }). Batches from another session, an AI's included, reach init({ onRemoteChange }) as for any collaboration batch.
Headless use
The provider is a plain object: call chat or rewrite from your own code with the same requests, and apply the operations with applyOperations from @templatical/types. @templatical/ai-kit/testing has createMockAiProvider for UI tests and runAiProviderConformance to check a provider you wrote.
In the playground
The AI setup plays recorded turns through createMockAiProvider: no model and no keys. Ask to change the title, or double-click a paragraph and rewrite it. ?history=1 keeps a conversation; ?collaboration=1&user=Ada in two tabs shows the AI's edits arriving elsewhere.
Check it
Click the header button labelled AI to open the AI assistant, then click it again to close it. Open the AI assistant for a loaded template without blocks: it shows three starters. Add a block: it shows four. Clicking one sends its displayed text. Check your provider with the conformance suite.
Open the Issues panel with a finding from the list above that has no deterministic fix. Fix with AI sends the lint projection; after re-lint the finding disappears or shows Still flagged after the fix. One Undo reverts the whole fix.
For alt text, open an image finding and choose Write alt text. Edit the draft, accept it, and Undo: both alt and decorative return to their previous values. Mark decorative clears img-missing-alt after re-lint. Cancel leaves the image unchanged.
For attachments, choose Import from screenshot or PDF on the empty canvas, pick a file and check that chat opens without sending. Send the prefilled message and Undo its changes. Run the wire conformance suite with attachments enabled to check the upload limits.