Skip to content

AI template design ​

People describe a report in plain words — "a quarterly client report with a holdings table grouped by asset class" — and get an ordinary, editable BroadPaper template. They can also ask for changes to the one in front of them: "make the header navy", "add a totals row". Nothing is applied until they have seen it.

The model is yours. BroadPaper supplies the prompt, the tool definition, the checks and the interface; your server calls whichever provider you choose, with your key, and hands back what the model produced. The SDK makes no network call of its own — not with this feature on, and not with it off.

A few things this is not:

  • It never runs when a document is rendered. AI is design time only. What comes out is a template like any other, and it renders the same way, deterministically, on every backend.
  • It adds nothing to the format. No AI field, no AI metadata, no new block type. A proposal that tries to add a prop a block does not read, or anything to meta.custom, is refused.
  • It changes nothing about licensing. The hook works at every licence state, exactly as the rest of the SDK does; an unlicensed build still marks its PDFs, and nothing else.

How it works ​

  1. The person types an instruction (at most 2,000 characters) in the designer's AI panel — "New report", or "Edit report", which respects the current selection.
  2. The designer builds a TemplateAiRequest and calls your ai.generate(request, signal).
  3. Your server builds a prompt with buildTemplateAiPrompt, calls the model with one tool, and returns the tool's input — the object the model produced.
  4. The designer checks it: the template schema, then validateTemplate against your blocks and data sources — with bindings to fields that do not exist treated as errors — then that no block grew props it does not have, and that a change asked of a selection stayed inside it. When it fails, the designer calls generate again with the problems listed (request.repair), at most twice.
  5. The person sees the proposal beside what changed, and Accept applies it as one step they can undo. Discard leaves their design exactly as it was.

What is sent ​

createTemplateAiRequest, which the designer uses, removes everything that should not leave:

  • The data model, without data. Field names, types, labels and descriptions. Every example is removed, and enum lists too unless you pass keepEnums — an enum you declared is schema, but values read out of somebody's spreadsheet are data. Sample data is never sent.
  • The current design, without its images. The whole template, even when something is selected, because a block changed without the page around it is how a table comes back wider than its column. Embedded images (data: URLs) are replaced with asset:N placeholders and put back afterwards. Any text a person has typed into the design is part of the design and is sent.
  • The instruction, and the ids of the selected blocks.

Your server decides what happens next: which provider, which region, what is logged, what it costs and who may use it. Treat the endpoint like any other that spends money — authenticate it, rate-limit it and cap the request size.

The browser side ​

Pass an ai provider and the AI panel appears. Leave it out and there is no panel at all.

ts
import { createReportDesigner } from "@broadpaper/editor";
import { TemplateAiError, type TemplateAiProvider } from "@broadpaper/core/ai";

const ai: TemplateAiProvider = {
  // Your own endpoint. The key never comes near the browser.
  async generate(request, signal) {
    const res = await fetch("/api/report-ai", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(request),
      signal
    });
    if (res.status === 429) throw new TemplateAiError("rate-limited", "That's a lot of requests. Try again in a minute.");
    if (!res.ok) throw new TemplateAiError("unavailable", "The design assistant isn't available right now.");
    return res.json();
  },
  // Optional: told whether the person applied what was proposed, for your own records.
  decided(requestId, decision) {
    void fetch(`/api/report-ai/${encodeURIComponent(requestId)}`, { method: "POST", body: decision });
  }
};

export const designer = createReportDesigner(document.getElementById("designer")!, { ai });

A TemplateAiError you throw is shown to the person as it is, so write its message for them. Its kind — refused, invalid, allowance, rate-limited, unavailable, cancelled or failed — decides how the panel presents it, and an action of { label, href } becomes a link beside it (an upgrade, a help page). An optional allowance() returning { used, label } draws a bar under the instruction box; used is a fraction and label is yours to word. Show counts rather than money.

The server side ​

The server builds the prompt itself. Take the instruction, data model and template from the request, but describe the designer from your own block registry rather than trusting the context the browser sent: the description goes into the system prompt. Pass the same custom blocks and theme your designer has, or the model will be told about a designer that is not the one checking its work.

ts
import { createRegistry } from "@broadpaper/blocks";
import { buildTemplateAiPrompt, describeDesigner, INSTRUCTION_LIMIT, type TemplateAiPrompt, type TemplateAiRequest } from "@broadpaper/core/ai";

// Built once. The same registry and theme always describe themselves in the same
// bytes, so the system prompt is identical from one request to the next — which is
// what lets a provider cache it.
const context = describeDesigner(createRegistry());

/** The prompt for one request, or the response refusing it. Authenticate before calling this. */
export async function readPrompt(req: Request): Promise<TemplateAiPrompt | Response> {
  const body = await req.text();
  if (body.length > 250_000) return new Response("That design is too large to send.", { status: 413 });
  const request = JSON.parse(body) as TemplateAiRequest;
  if (typeof request.instruction !== "string" || request.instruction.length > INSTRUCTION_LIMIT) {
    return new Response("Bad request", { status: 400 });
  }
  return buildTemplateAiPrompt({ ...request, context });
}

export function json(value: unknown): Response {
  return new Response(JSON.stringify(value ?? null), { headers: { "content-type": "application/json" } });
}

Each of the handlers below is a Web-standard POST(req: Request), which is what Next.js route handlers, Hono, Remix and most modern servers take directly; adapting one to Express or ASP.NET is a few lines. Every one does the same three things: send system, messages and the one tool; require the model to call that tool; and return the tool's input and nothing else. Anything the model says outside the tool is thrown away. If there is no tool call, return null — BroadPaper counts that as a failed attempt and asks again.

Anthropic ​

ts
import Anthropic from "@anthropic-ai/sdk";
import { readPrompt, json } from "./ai-server.js";

// Reads ANTHROPIC_API_KEY from the server's environment.
const client = new Anthropic();

export async function POST(req: Request): Promise<Response> {
  const prompt = await readPrompt(req);
  if (prompt instanceof Response) return prompt;

  const message = await client.messages.create(
    {
      model: "claude-sonnet-5",
      max_tokens: 16000,
      // The rules and the block catalogue do not change between requests: cache them.
      system: [{ type: "text", text: prompt.system, cache_control: { type: "ephemeral" } }],
      tools: [{ name: prompt.tool.name, description: prompt.tool.description, input_schema: prompt.tool.inputSchema as Anthropic.Tool.InputSchema }],
      // Forced: the only possible answer is a template or a refusal. Claude Opus 5.5
      // and Claude Fable 5.1 reject a forced tool_choice; with those, use
      // { type: "auto" } — the prompt already says the tool is the only way to answer.
      tool_choice: { type: "tool", name: prompt.tool.name },
      messages: prompt.messages
    },
    { signal: req.signal }
  );

  if (message.stop_reason === "refusal") return json({ outcome: "refused", reason: "That isn't something I can help with." });
  const call = message.content.find((block) => block.type === "tool_use" && block.name === prompt.tool.name);
  return json(call?.type === "tool_use" ? call.input : null);
}

OpenAI ​

ts
import OpenAI from "openai";
import { readPrompt, json } from "./ai-server.js";

// Reads OPENAI_API_KEY from the server's environment.
const client = new OpenAI();

export async function POST(req: Request): Promise<Response> {
  const prompt = await readPrompt(req);
  if (prompt instanceof Response) return prompt;

  const completion = await client.chat.completions.create(
    {
      // Whichever model you have chosen.
      model: process.env.OPENAI_MODEL!,
      messages: [{ role: "system", content: prompt.system }, ...prompt.messages],
      tools: [{ type: "function", function: { name: prompt.tool.name, description: prompt.tool.description, parameters: prompt.tool.inputSchema } }],
      tool_choice: { type: "function", function: { name: prompt.tool.name } }
    },
    { signal: req.signal }
  );

  const call = completion.choices[0]?.message.tool_calls?.find((c) => c.type === "function" && c.function.name === prompt.tool.name);
  // The arguments arrive as a JSON string; BroadPaper parses it, and a string that is
  // not valid JSON is simply a failed attempt.
  return json(call?.type === "function" ? call.function.arguments : null);
}

Azure OpenAI ​

The same call through AzureOpenAI, which takes your resource's endpoint, a deployment name and an API version. For Microsoft Entra ID rather than a key, pass azureADTokenProvider instead of apiKey.

ts
import { AzureOpenAI } from "openai";
import { readPrompt, json } from "./ai-server.js";

const deployment = process.env.AZURE_OPENAI_DEPLOYMENT!;
const client = new AzureOpenAI({
  endpoint: process.env.AZURE_OPENAI_ENDPOINT,
  apiKey: process.env.AZURE_OPENAI_API_KEY,
  apiVersion: process.env.OPENAI_API_VERSION,
  deployment
});

export async function POST(req: Request): Promise<Response> {
  const prompt = await readPrompt(req);
  if (prompt instanceof Response) return prompt;

  const completion = await client.chat.completions.create(
    {
      model: deployment,
      messages: [{ role: "system", content: prompt.system }, ...prompt.messages],
      tools: [{ type: "function", function: { name: prompt.tool.name, description: prompt.tool.description, parameters: prompt.tool.inputSchema } }],
      tool_choice: { type: "function", function: { name: prompt.tool.name } }
    },
    { signal: req.signal }
  );

  const call = completion.choices[0]?.message.tool_calls?.find((c) => c.type === "function" && c.function.name === prompt.tool.name);
  return json(call?.type === "function" ? call.function.arguments : null);
}

Doing the checks on your server too ​

The designer checks everything it is given, so a server that just relays the model's answer is enough. A server that wants to spend its own retries — to meter them, say, or to keep a failed attempt from ever reaching the browser — can run the same loop itself with runTemplateAi, which takes a generate function and the same registry and data sources, and then set maxRepairs: 0 on the browser's provider so the two loops do not repeat each other. BroadPaper Cloud works that way.

Reference ​

Everything is exported from @broadpaper/core/ai, which has no dependencies of its own and no network code:

ExportWhat it is
TemplateAiProviderThe designer's ai option: generate, and optionally allowance, decided and maxRepairs.
TemplateAiRequestWhat generate receives: requestId, mode, instruction, dataModel, context, currentTemplate, selection, locale, and repair on a retry.
buildTemplateAiPrompt(request){ system, messages, tool: { name, description, inputSchema } }, provider-neutral.
describeDesigner(registry, theme?)The block catalogue, theme tokens and text styles the prompt describes.
templateJsonSchema(context)The template as JSON Schema — the tool's input schema.
createTemplateAiRequest(options)A request with examples, enums and embedded images removed, and the map to put the images back.
checkProposal(output, options)The designer's acceptance check, for use on a server.
runTemplateAi(options)Ask, check, and ask again with the problems — at most twice.
TemplateAiErrorA failure written for the person reading it.