PlayableAd Studio runs entirely in your browser. There is no backend server handling LLM requests, no API key stored on our servers, and zero infrastructure cost. Users bring their own LLM API keys (BYOK), and four providers are supported through a unified interface — switching from DeepSeek to OpenAI to Anthropic is a single dropdown change.
This post walks through the BYOK (Bring Your Own Key) LLM layer in a browser-only SPA, and why this architecture is ideal for marketing teams that need flexibility, privacy, and zero vendor lock-in.
The Problem: Why Vendor Lock-In Hurts Ad Creative Teams
Ad creative teams face constraints that make LLM vendor lock-in uniquely painful. API pricing changes overnight — a 2× hike doubles per-ad cost instantly. Single-provider dependency means one outage stops all generation. Model quality varies: one model writes great JS but poor CSS; another nails visuals but generates broken MRAID wrappers. Some providers are unavailable or slow in SEA markets (Vietnam, Thailand, Indonesia) where playable ads see heavy use. And ad creatives contain unreleased game mechanics — sending those to a third-party server is a genuine privacy risk.
Most ad generation tools solve zero of these problems. They pick one provider and funnel everything through a shared backend. If the provider goes down or raises prices, the user has no recourse.
The Solution: BYOK Architecture — Each User Brings Their Own Key
The BYOK model flips traditional SaaS architecture. Users provide their own API key and pay their provider directly. The platform never sees the key — it stays in localStorage and is transmitted only to the provider's API endpoint. This means zero API cost to the platform, no keys on our servers, and full user choice: DeepSeek for Vietnamese-language ads, OpenAI for English, Anthropic for complex logic.
Architecture: Browser-Only SPA on Cloudflare Pages
The app is Vanilla JS deployed as static assets on Cloudflare Pages. The entire UI, state, LLM calling, bundler, and network packaging run client-side.
| Layer | Technology |
|---|---|
| Framework | Vanilla JS (no React, no Vite) |
| Bundler | esbuild |
| Hosting | Cloudflare Pages (free tier) |
| State | localStorage |
| Packaging | JSZip |
| Ad format | MRAID 3.0 HTML5 |
Infrastructure cost: effectively zero. The user pays only their LLM provider's usage fees.
```
src/
main.js # SPA views and render cycle
store.js # localStorage state (API keys, history)
llm.js # BYOK LLM integration (4 providers + image gen)
bundler/ # MRAID bundler with per-network adapters
network-pack.js # Per-network output packaging
prompt-pipeline/ # Multi-step LLM pipeline orchestration
```
How src/llm.js Handles 4 Providers
The provider catalog in `shared/llm-providers.mjs` defines each LLM provider as a config object with `baseUrl`, `defaultModel`, `models[]`, `headers(key)`, and optional `formatBody()` / `extractContent()` for non-standard APIs.
```javascript
const LLM_PROVIDERS = {
openai: {
label: 'OpenAI', defaultModel: 'gpt-4o',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1-mini'],
baseUrl: 'https://api.openai.com/v1/chat/completions',
headers: (key) => ({ 'Authorization': `Bearer ${key}` }),
},
anthropic: {
label: 'Anthropic Claude', defaultModel: 'claude-sonnet-4-20250514',
models: ['claude-sonnet-4-20250514', 'claude-3.5-haiku-20241022'],
baseUrl: 'https://api.anthropic.com/v1/messages',
headers: (key) => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01' }),
formatBody: (messages, system, maxTokens, model) => ({
model, max_tokens: maxTokens || 4096, system,
messages: messages.filter(m => m.role !== 'system'),
}),
extractContent: (data) => data.content?.[0]?.text || '',
},
deepseek: {
label: 'DeepSeek', defaultModel: 'deepseek-v4-pro',
models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],
baseUrl: 'https://api.deepseek.com/v1/chat/completions',
formatBody: (messages, system, maxTokens, model, stream) => ({
model, max_tokens: maxTokens || 4096, stream: !!stream,
messages: system ? [{ role: 'system', content: system }, ...messages] : messages,
thinking: { type: 'disabled' },
}),
},
openrouter: {
label: 'OpenRouter', defaultModel: 'openai/gpt-4o',
models: ['openai/gpt-4o', 'openai/gpt-4o-mini', 'anthropic/claude-sonnet-4',
'deepseek/deepseek-chat', 'google/gemini-2.0-flash-001'],
baseUrl: 'https://openrouter.ai/api/v1/chat/completions',
headers: (key) => ({ 'Authorization': `Bearer ${key}`, 'HTTP-Referer': 'https://playable-ad-studio.pages.dev' }),
},
};
```
The shared `callLLM()` function selects the provider, builds the request body (using `formatBody` or an OpenAI-compatible fallback), adds auth headers, calls `fetch()` directly from the browser, and extracts the response. This works with any browser that supports the Fetch API — no polyfills required.
Image Generation
The same pattern extends to image generation. Four providers are supported: OpenAI DALL·E (DALL·E 3 and 2), OpenRouter (Flux 1.1 Pro, Flux Schnell, SD 3.5 Large, SDXL), DeepSeek, and Stability AI. Each provider config defines `formatBody(prompt, model)` and `extractImages(data)` to handle DALL·E's base64 JSON responses and OpenRouter's markdown image URLs uniformly.
Multi-Step Pipeline
Beyond single calls, a 4-step pipeline produces higher quality: **Plan** generates a structured JSON game design, **Skeleton** produces full JavaScript (Kontra.js game loop + MRAID lifecycle), **Polish** refines animations and responsive design, and **Critique** reviews the output for issues. Each step uses the same `callLLM()` with the user's chosen provider. The pipeline tracks timing and token usage in a structured trace available in the UI.
Bundling for 8 Networks
Once the LLM generates the playable, the bundler wraps it into MRAID-compliant HTML and packages for eight ad networks:
| Network | Format |
|---|---|
| AppLovin | Single HTML |
| Mintegral | Single HTML |
| Unity Ads | Single HTML (MRAID 3.0) |
| ironSource | Single HTML (MRAID 3.0) |
| Vungle | ZIP (DAPI mode) |
| Meta | ZIP (FbPlayableAd) |
| Google AdMob | ZIP (UAC) |
| TikTok/Pangle | ZIP (index + config) |
Key Takeaways
**Zero backend cost.** The operating cost is the Cloudflare Pages free tier plus the user's LLM API fees. No servers, no proxy infrastructure.
**Data privacy.** API keys and generation prompts never hit a platform-controlled server. Unreleased game mechanics and campaign strategies stay in the browser.
**Provider agnosticism.** A new provider is a single config object — roughly 20 lines — handling auth, request shape, response parsing, and error format differences.
**Pipeline quality.** The 4-step pipeline (plan → scaffold → polish → critique) beats single-shot generation significantly, with the critique step catching hallucinations before they reach the output.
**Network flexibility.** Eight output formats from one generated code base. The bundler handles per-network MRAID quirks, ZIP structures, and manifest requirements transparently.
> **The takeaway for engineering teams:** A browser-only architecture with BYOK LLM integration is production-viable — not just a demo. A clean provider abstraction, localStorage state, and esbuild bundling are all you need. When you don't proxy LLM calls through your server, you eliminate an entire class of cost, privacy, and operational concerns at once.