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.
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
}
Mode
Means
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.
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.
<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 first
Return 402 before calling the provider, not after. See Licensing.
Rate-limit per account
A reference-file recreation is an expensive call. Cap it per user per hour.
Cap the upload size
A 40-page PDF costs far more than a one-page invoice, and rarely produces a better result.
Log spend per account
You cannot price the feature without knowing what it costs you.
Set a provider budget alert
The 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 first
A version-2 answer is a correct answer to an out-of-date question, so it is migrated rather than rejected.
Unknown block types are dropped
Never 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 normalised
Same path as any loaded document.
Findings split in two
notices are our label keys and translate; warnings are your text, shown verbatim — we cannot translate a sentence we did not write.
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.