AI design

Let a user attach a reference PDF and say "make this" — and get back real, editable blocks.

@storvexa/pdf-builder Chapter 11 of 14
Documentation

How it works

A user opens the AI dialog, types a prompt or attaches a screenshot or PDF, and asks for a design. The package builds a request describing exactly what it can render, hands it to your callback, and validates whatever you return before showing it for review.

user prompt / reference file │ ▼ the package builds an AiDesignRequest │ (including a schema of every block YOUR registry has) ▼ onAiDesign(request) ←── your code, your provider, your key │ ▼ AiDesignResult { document, warnings?, confidence?, unsupported? } │ ▼ migrate → validate → prune unknown blocks → review screen → one undoable commit
The package calls no model, no OCR service and no file converter. It has no API key, no endpoint and no provider preference. Anthropic, Gemini, OpenAI, a model you host yourself — the package cannot tell and does not care.

Turning it on

Passing the callback is the feature switch.

<PdfBuilder onAiDesign={async (request) => { const res = await fetch('/api/ai/design', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request), }) return res.json() }} />
No callback means no button and no dialog. A button that opens a dialog with nothing behind it is worse than no button, so the whole feature simply is not there until you wire it up.

The request

interface AiDesignRequest { mode: 'create' | 'recreate' | 'improve' prompt?: string files?: File[] // the reference screenshot or PDF currentDocument?: PdfDocument // present for 'improve' locale?: string countryCode?: string currency?: string availableDesignSchema: AiDesignSchema }
ModeMeans
create"Make me a premium two-column quotation with a dark navy header."
recreate"Here is a PDF. Rebuild it as an editable document."
improve"Clean up the spacing and give it a stronger hierarchy." Includes the current document.
files holds browser File objects. They do not survive JSON.stringify. Send them as FormData, or read and base64 them yourself before posting — see the server example below.

The design schema

availableDesignSchema is the single most important part of the request. It is a flattened description of your live registry — every block, every field, every design, every template, the page settings and the token vocabulary.

Put it in your prompt. A model given the schema produces blocks that exist. A model without it invents a "chart" block you do not have, and that part of the result is dropped.

It is deliberately flattened, because it crosses a network to a language model:

  • showIf predicates are removed — a function cannot be serialised.
  • React icons are removed.
  • Label keys are resolved to real words, so a model reads "City" rather than party.field.city.
  • Groups are lifted out, because a model cannot write a value into a container.
It is built per request, not cached. So the blocks you registered are offered too. A fixed list would make exactly the blocks you configured invisible to the feature meant to compose them.

You can build the same schema server-side:

import { buildDesignSchema, createBuiltinRegistry } from '@storvexa/pdf-builder' const schema = buildDesignSchema({ registry: createBuiltinRegistry(), locale: 'en-US' })

Where the API key goes

The short answer: on your server, in an environment variable, and never anywhere the browser can reach. The long answer is worth reading, because the usual way people get this wrong does not look wrong.

The shape

browser your server provider ──────────────────────────────────────────────────────────────────────── onAiDesign(request) ──POST──► /api/ai/design ──key──► Anthropic / Gemini │ ◄──JSON──── AiDesignResult no key here key lives here ever only
onAiDesign runs in the browser, so it must not hold the key. It calls your endpoint. Your endpoint holds the key and calls the provider. That one hop is the entire security design.

1 · Put the key in your environment

# .env.local — and make sure this file is gitignored ANTHROPIC_API_KEY=sk-ant-... # or GEMINI_API_KEY=AIza...
Never prefix it with VITE_, NEXT_PUBLIC_, REACT_APP_ or PUBLIC_. Those prefixes exist precisely to inline the value into the browser bundle. The name looks harmless, the app works perfectly, and your key is sitting in a JavaScript file anyone can download. This is the single most common way keys leak.
A key that has ever been in a git commit, a chat message, a screenshot or a log is burned. Rotate it. Providers make this one click, and scrapers find committed keys within minutes.

2 · Symfony endpoint

# config/services.yaml parameters: anthropic_api_key: '%env(ANTHROPIC_API_KEY)%'
<?php // src/Controller/AiDesignController.php final class AiDesignController extends AbstractController { public function __construct( private HttpClientInterface $http, #[Autowire('%anthropic_api_key%')] private string $apiKey, ) {} #[Route('/api/ai/design', name: 'ai_design', methods: ['POST'])] public function design(Request $request): JsonResponse { // YOUR auth and YOUR plan check — the package cannot do either. $user = $this->getUser(); if (!$user || !in_array('ai-design', $user->getPlanFeatures(), true)) { return $this->json(['error' => 'Upgrade required'], 402); } $payload = json_decode($request->getContent(), true); $response = $this->http->request('POST', 'https://api.anthropic.com/v1/messages', [ 'headers' => [ 'x-api-key' => $this->apiKey, // ← the key, server-side only 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json', ], 'json' => [ 'model' => 'claude-opus-5', 'max_tokens' => 16000, 'system' => "Reply ONLY with document JSON.\nUse ONLY these blocks:\n" . json_encode($payload['availableDesignSchema']), 'messages' => [[ 'role' => 'user', 'content' => $payload['prompt'] ?? 'Recreate this design.', ]], ], ]); $text = $response->toArray()['content'][0]['text']; return $this->json(['document' => json_decode($text, true)]); } }

3 · Node / Express endpoint

import Anthropic from '@anthropic-ai/sdk' // Reads process.env.ANTHROPIC_API_KEY. Server-side module — never imported by client code. const client = new Anthropic() app.post('/api/ai/design', requireAuth, async (req, res) => { if (!req.user.features.includes('ai-design')) { return res.status(402).json({ error: 'Upgrade required' }) } const message = await client.messages.create({ /* … */ }) res.json({ document: JSON.parse(message.content[0].text) }) })

4 · The browser side stays keyless

<PdfBuilder onAiDesign={async (request) => { // Files are File objects — they do NOT survive JSON.stringify. const body = new FormData() body.append('payload', JSON.stringify({ ...request, files: undefined })) for (const file of request.files ?? []) body.append('files[]', file) const res = await fetch('/api/ai/design', { method: 'POST', body, credentials: 'same-origin', // your session cookie authenticates it }) if (res.status === 402) throw new Error('Your plan does not include AI design.') if (!res.ok) throw new Error('The design service could not complete this request.') return res.json() }} />
Notice there is no key, no provider name and no model in that block. Swapping Anthropic for Gemini, or adding a fallback, changes only your server. The browser code never learns which provider you use.

5 · Local development

If your front end runs on a dev server separate from your backend, proxy the call rather than putting the key in the front end "just for now" — that is how keys reach production.

// vite.config.ts export default defineConfig({ server: { proxy: { '/api/anthropic': { target: 'https://api.anthropic.com', changeOrigin: true, rewrite: (p) => p.replace(/^\/api\/anthropic/, ''), configure: (proxy) => { proxy.on('proxyReq', (proxyReq) => { // process.env — NOT import.meta.env. This runs in Node, not the browser, // so the key never enters the bundle. proxyReq.setHeader('x-api-key', process.env.ANTHROPIC_API_KEY ?? '') proxyReq.setHeader('anthropic-version', '2023-06-01') }) }, }, }, }, })
Fail loudly when the key is missing. If you forward the request without a key, the provider answers 401 invalid x-api-key and you will spend an hour suspecting your own code. Check for the variable first and return a 503 that says "ANTHROPIC_API_KEY is not set" — then the message tells you the fix.
Google Gemini takes its key as a query parameter, not a header?key=…. Sending it as a header returns a confusing auth error rather than a clear one.

6 · Verify the key is not in your bundle

Do this once, before you ship. It takes ten seconds and it is conclusive.

npm run build grep -r "sk-ant-\|AIza" dist/ public/build/ # must print NOTHING
Add that grep to CI. A prefix added months later by someone who did not read this page will be caught by a failing build instead of by a stranger's bill.

Cost control

Check the plan firstReturn 402 before calling the provider, not after. See Licensing.
Rate-limit per accountA reference-file recreation is an expensive call. Cap it per user per hour.
Cap the upload sizeA 40-page PDF costs far more than a one-page invoice, and rarely produces a better result.
Log spend per accountYou cannot price the feature without knowing what it costs you.
Set a provider budget alertThe one safeguard that works while you are asleep.

A server implementation

Your endpoint holds the key. Below is the shape with Claude; the structure is the same for any provider.

// server-side only — the key never reaches the browser import Anthropic from '@anthropic-ai/sdk' const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }) export async function design(request, referenceFile) { const message = await client.messages.create({ model: 'claude-opus-5', max_tokens: 16000, system: [ 'You design printable business documents.', 'Reply ONLY with a document JSON matching the schema below.', 'Use ONLY these block types:', JSON.stringify(request.availableDesignSchema), ].join('\n'), messages: [{ role: 'user', content: [ referenceFile && { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: referenceFile }, }, { type: 'text', text: request.prompt ?? 'Recreate this design.' }, ].filter(Boolean), }], }) return { document: JSON.parse(message.content[0].text) } }
Never put the API key in the browser. Anything reachable from client JavaScript is public, whatever the bundler prefixes it with. The callback runs in the browser; the provider call must not.

The result

interface AiDesignResult { document: PdfDocument warnings?: string[] // YOUR text, shown to the user verbatim confidence?: number // 0–1 unsupported?: string[] // things you could not express }
Use warnings and unsupported honestly. "The original uses a font we could not match" is far better than a silently different document. The user can accept it or adjust — but only if you told them.

What we check before showing it

normalizeAiResult runs on whatever you return, and never throws — a bad answer degrades, it does not crash the editor.

Migration runs firstA version-2 answer is a correct answer to an out-of-date question, so it is migrated rather than rejected.
Unknown block types are droppedNever substituted. A model asking for a chart wants a chart; quietly putting a text block there produces a document that looks finished and is not.
Validated and normalisedSame path as any loaded document.
Findings split in twonotices are our label keys and translate; warnings are your text, shown verbatim — we cannot translate a sentence we did not write.

Check a result yourself before returning it:

import { normalizeAiResult } from '@storvexa/pdf-builder' const { document, notices, warnings } = normalizeAiResult(fromModel, { registry })
A generated document replaces the user's work, so it is held for review. Nothing is applied until they accept — and accepting is one undoable step, so a single Ctrl+Z gets their old document back.

Fidelity, honestly

Never promise 100% reproduction. Not to your users, not in your marketing. Different fonts, missing assets, an inaccessible source structure, OCR errors and unsupported effects all prevent exact reproduction. Aim high, measure the difference, and report what you could not do.

A reference underlay helps users judge it: the original sits behind the canvas at adjustable opacity while they correct the result. It is a review aid only — it is never saved into the document, and it is hidden during PDF capture so it cannot be drawn into the file.

The result is real blocks, always. Never a flattened screenshot pretending to be a document. Every generated value appears in the normal Content, Style and Designs tabs, and is draggable, translatable, undoable and saveable like anything else.

Privacy

The reference document goes to your provider's servers. Check whether your plan permits them to train on it before you point this at real customer invoices. Free tiers frequently do. This is your decision to make and your users' data to protect — the package cannot make either call for you.

Gating AI behind a paid plan is one line — see Licensing.