The block engine for AI-composable web apps.
An AI composes a SiteManifest from a fixed catalog of typed blocks — its entire
API surface — and the engine validates it and renders one self-contained static
HTML document. Snap-together “Lego bricks” for web apps: safe by construction,
provider- and host-neutral, zero runtime dependencies.
▶ Live gallery — all 54 blocks, rendered · 160 starter templates
brief ──generateSite(callModel)──▶ SiteManifest ──validate──▶ renderSite ──▶ one static HTML document
message ──editSite(callModel)────▶ EditOp[] ─────apply────▶ new SiteManifest (version++)
Letting a model emit raw HTML/CSS/JS for a whole site is powerful and unsafe: malformed output, injection, incoherent styling, and no way to change one thing later without regenerating everything. weblocks gives the AI a closed vocabulary and a typed configuration contract — a catalog of blocks. The model’s job shrinks from “write a correct website” to “pick and fill known bricks,” which is exactly what LLMs are reliable at, and the engine guarantees the result is valid and coherent.
- 🔒 Closed vocabulary — a block exists only if it’s in the catalog; the AI can never invent markup or emit raw HTML.
- 🧱 Illegal states unrepresentable — a bad edit is rejected, never applied.
- 🛡️ Total renderer — every field defaulted, all text escaped, all URLs sanitized; it cannot throw, so a broken page is structurally impossible.
- 🔀 Validity ⟂ model quality — page validity comes from the schema + renderer, not the model being right. Swap or downgrade models freely.
- 🎨 Coherent theming — one palette swap re-themes the whole app; automatic light/dark; contrast-safe fills.
- 🌐 Provider- & host-neutral — you inject the model call; the engine bundles no backend, provider, or host.
Zero runtime dependencies · pure TypeScript · ESM · Node ≥ 20.
- Live gallery · Install · Quickstart · Core concepts
- The AI contract · Block catalog
- Editing · Theming · Powered blocks & runtime · PWA
- API reference · Adding a block · Local development
npm install @bytesbrains/weblocksYou supply a callModel function (any provider — you own the key). The engine
turns a brief into a validated manifest and static HTML.
import { writeFileSync } from 'node:fs';
import { generateSite, renderSite } from '@bytesbrains/weblocks';
// Bring your own provider: (system, user) => model's text reply.
const callModel = async ({ system, user }) => {
const res = await fetch('https://api.your-provider.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.API_KEY}` },
body: JSON.stringify({
model: 'your-model',
messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
}),
});
return (await res.json()).choices[0].message.content;
};
const { ok, manifest, warnings } = await generateSite('a Lisbon bakery landing page', callModel);
if (ok) writeFileSync('index.html', renderSite(manifest)); // self-contained HTMLrenderSite(manifest) returns a complete <!doctype html>…</html> string with
the used blocks’ CSS inlined — no build step, no framework runtime.
Building an AI application? Use the latest, most capable models — the catalog is designed to be fed as a function-calling / structured-output schema. See
AGENT.mdfor a ready-to-use guide you can hand to a model.
A SiteManifest is the single source of truth the AI composes and edits — it
is never raw HTML:
type SiteManifest = {
meta: { title: string; description: string; lang: string; favicon?: string };
design: DesignTokens; // the shared baseplate (CSS custom properties)
blocks: Block[]; // ordered, typed page sections
version: number; // bumped per accepted edit → undo / history / diff
pwa?: PwaConfig; // opt-in installable PWA
seo?: SeoConfig; // opt-in <head> meta
};
type Block = { id: string; type: string; visible: boolean; config: object; overrides?: object };design— CSS custom properties every block styles from, so one edit restyles the whole app coherently.blocks— placed bricks;typecomes from the closed catalog andconfigis validated against that block’s schema before it is ever applied.
Two walls make a broken page impossible: schema validation (the strict gate an edit op passes) and the total renderer (defaults + escaping, never throws).
The catalog is the only surface the model is told it may use. It ships with the package in two forms, and is also generated at runtime:
| Form | What | Use |
|---|---|---|
catalog.json |
JSON Schema per block | Function-calling / structured output |
CATALOG.md |
Human-readable reference | Docs / review |
catalog() |
BlockCatalogEntry[] at runtime |
Programmatic |
catalogPrompt() |
Compact string | Cheap system prompt |
import { catalog, catalogPrompt } from '@bytesbrains/weblocks';
import catalogJson from '@bytesbrains/weblocks/catalog.json' with { type: 'json' };54 typed blocks. See every one of them rendered live on the
block wall; full field
reference in CATALOG.md.
| Group | Blocks |
|---|---|
| Chrome / app-shell | nav · app-shell · sidebar · announcement-bar · install-prompt · footer |
| Heroes | hero · hero-app |
| Résumé / profile | profile-header · experience · skills (live CV — avatar, dated entries, skills, Download-PDF/Share) |
| Content | features · about · rich-text · split · steps · stats · progress (value toward a target — bars, segments, rings, meters) · services-catalogue · menu · product · pricing · logos · team |
| Media | gallery · carousel · video · video-gallery · map |
| Location | directions (deep links to the visitor’s map app) |
| Structured | timeline · tabs · accordion · testimonials · reviews · faq · chat-thread (authored conversation, rich typed bubbles) |
| Collections | blog-list · blog-post · feed |
| Dynamic (powered) | booking · contact-form · newsletter · search · auth |
| Conversion / rhythm | cta · social-links · contact-details · hours · divider · spacer · copyright · credit ("Powered by / Created by / Managed by", linking out to the maker) |
| Legal | legal (terms/privacy links → safe-Markdown dialogs) |
rich-text and blog-post carry typed content nodes (headings, paragraphs,
quotes, lists) — a safe freeform-content escape hatch that is never raw HTML.
Editing is a set of validated verbs, not a regeneration. Drive them from natural
language (editSite) or emit them directly:
import { applyOp, editSite } from '@bytesbrains/weblocks';
// Natural language → validated ops → new versioned manifest:
const { manifest: edited, applied } = await editSite(manifest, 'go dark and add a gallery', callModel);
// Or emit ops directly (what a chat/inspector produces):
applyOp(manifest, { op: 'updateBlock', id: 'hero-1', config: { headline: 'New' } });
// Array-item ops edit ONE item without rewriting the block:
applyOp(manifest, { op: 'addItem', id: 'features-1', field: 'items', item: { title: 'Fast' } });
applyOp(manifest, { op: 'updateItem', id: 'features-1', field: 'items', index: 0, patch: { text: 'Now faster' } });Every op is validated before it applies; a bad op is a no-op (with errors), and
version bumps on each accepted edit — so undo / history / diff come for free.
Ops: addBlock · updateBlock · removeBlock · moveBlock · setVisible ·
addItem · updateItem · removeItem · moveItem · setDesignTokens ·
applyPreset · setOverrides · setMeta.
import { presetNames } from '@bytesbrains/weblocks';
applyOp(manifest, { op: 'applyPreset', name: 'midnight' }); // named token presets
applyOp(manifest, { op: 'setDesignTokens', patch: { radius: 'round' } }); // patch any tokens
applyOp(manifest, { op: 'setOverrides', id: 'cta-1', overrides: { primary: '#0af', radius: 'sharp' } }); // per-section
presetNames(); // ['sand', 'midnight', 'forest', 'mono', 'candy', 'ocean']- Automatic light/dark — set
design.mode = 'auto'to follow the viewer’s OS theme (supply an optionaldesign.darkPalette), or'light'/'dark'for a fixed one. - Contrast-safe fills — derived
--on-primary/--on-accenttokens keep button text legible on any palette (no hardcoded colors). - Per-section overrides — tint one block’s palette / radius / spacing without breaking overall coherence.
Blocks like contact-form, newsletter, and auth need a backend. The engine
bundles none: a powered block declares the capabilities it needs, and your
host wires them through a tiny adapter.
import { renderSite, pathRuntime, runtimeNeeds } from '@bytesbrains/weblocks';
runtimeNeeds(manifest); // e.g. [{ type: 'contact-form', capabilities: ['contact-form.submit'] }]
// Map every capability to POST /api/<capability>/<blockId> in one line:
const html = renderSite(manifest, { runtime: pathRuntime('/api') });With no runtime, powered blocks render inert-but-valid (a disabled control + a
note), keeping data-wl-* hooks so a host can enhance them client-side. Captcha,
server-side validation, delivery, abuse limits, and identity are the host’s job.
Your adapter is the one piece of host code that runs inside a render, so
renderSite wraps it (safeRuntime): if resolve throws, or hands back an
action without a usable url, that capability simply reads as unprovided and the
brick falls back to inert. One bad capability costs you one form, never the page.
Add a pwa field and the engine derives an installable app shell:
import { renderSite, emitPwa } from '@bytesbrains/weblocks';
manifest.pwa = { name: 'My App', offline: true };
writeFileSync('index.html', renderSite(manifest)); // adds manifest + SW meta to <head>
for (const [file, body] of Object.entries(emitPwa(manifest) ?? {})) writeFileSync(file, body);
// → manifest.webmanifest + sw.jsAdd the install-prompt block to tell visitors they can install it: a
dismissible toast that expands into "Add to Home Screen" steps for the visitor's
own platform (iOS Safari, Android Chrome, desktop Chrome/Edge, macOS Safari). Its
island fires the browser's native install prompt where one is offered — and on
iOS, where no such prompt exists, the written steps are the only way in.
Pages are static-first: JavaScript ships only for the interactive blocks that need
it, and only when their behaviour is on. renderSite emits a marker for each —
<script type="module" src="/_island/<name>.js"> — and the engine ships the
island scripts (zero-dependency, ~6 KB each) under a subpath:
| Block | Island | Behaviour |
|---|---|---|
gallery (lightbox: true) |
lightbox.js |
click-to-zoom, prev/next, keyboard, swipe, Esc |
carousel |
carousel.js |
arrows, dots, keyboard, optional autoplay |
video-gallery |
video.js |
click-to-play cards (load the real player inline on press) |
profile-header (Download/Share on) |
resume.js |
print-to-PDF (window.print()) + Web Share / copy-link |
announcement-bar |
announcement-bar.js |
dismiss the strip |
stats |
stats.js |
count figures up when they scroll into view |
install-prompt |
install-prompt.js |
native install prompt, platform-matched steps, sticky dismiss |
Serve them at the island URL. Copy from the package's ./islands/*.js export, e.g.:
import lightbox from '@bytesbrains/weblocks/islands/lightbox.js?url'; // bundler
// or, for a static host, copy node_modules/@bytesbrains/weblocks/lib/islands/*.js
// to /_island/ (change the base with renderSite(m, { islandBase: '/assets/js' }))Blocks whose behaviour is off (e.g. tabs, accordion — CSS-only) ship no JS at
all.
Powered blocks are the exception. contact-form, newsletter, booking and
auth declare an island the host serves alongside the runtime it wires — the
engine ships no module for them, because what that script does (live slots,
inline validation, an auth SDK) is the host's call. Their <script> tag is
therefore emitted only when the runtime you pass resolves that block's
capability; with no runtime they render inert-but-valid and ship no JS, so an
unwired page never requests a file you don't serve.
All exports are named; types are shipped (lib/index.d.ts).
| Area | Exports |
|---|---|
| Compose / edit (AI) | generateSite · editSite · buildGenerationPrompt · buildEditPrompt · parseManifestResponse · parseOpsResponse |
| Render | renderSite |
| Edit ops | applyOp · applyOps |
| Validate | validateManifest · validateBlock |
| Catalog | catalog · catalogPrompt |
| Registry | REGISTRY · getSpec · blockTypes · needsIsland |
| Theming | DEFAULT_TOKENS · normalizeTokens · tokensToCss · sectionOverrideCss · readableOn · PRESETS · presetNames · getPreset |
| Verticals | VERTICALS · verticalNames · getVertical |
| Templates | TEMPLATES · templateNames · templatesForVertical · templatesForLayout · templatesByTag · templateTags · getTemplate |
| Runtime | NOOP_RUNTIME · pathRuntime · runtimeNeeds · safeRuntime |
| PWA | buildWebManifest · buildWebManifestJson · buildServiceWorker · emitPwa |
| Schema utils | parse · escapeHtml · escapeAttr · sanitizeUrl |
Core types: SiteManifest · Block · DesignTokens · Palette ·
SectionOverrides · PwaConfig · SeoConfig · EditOp · BlockSpec ·
RuntimeAdapter · ModelCall.
ModelCall is (args: { system: string; user: string }) => Promise<string> — the
one thing you inject, so the engine never depends on a provider.
verticals.ts is a small, stable business-vertical taxonomy — one source of
truth for what kind of site is being built. Each entry maps a stable id
(persist it host-side as e.g. businessType) to a label + icon, a recommended
section set in order, a fitting preset, a copy tone, and a booking flag.
import { verticalNames, getVertical, generateSite } from '@bytesbrains/weblocks';
verticalNames(); // ['restaurant','retail','salon', … ,'other']
getVertical('salon'); // { id, label, blocks:[…], preset:'candy', booking:true, … }
// Seed compose with the vertical's recommended sections + preset (advisory):
await generateSite('a hair salon in Leeds', callModel, { vertical: 'salon' });Hosts building a category picker should render verticalNames() rather than
hardcode their own list — the same way the block editor consumes catalog.json —
so chips, the AI's section defaults, and starter templates all derive from one
list. Verticals are additive and stable: new ones are safe; existing ids are
never renamed or repurposed.
templates.ts ships named starter templates — complete, validated
SiteManifests with realistic copy and a fitting preset baked in, spanning every
vertical from restaurants and trades to creators, carers and personal blogs. They
serve two callers from one source of truth: a host renders one as an instant,
zero-LLM starter/preview, and generation seeds one as a scaffold to personalise.
Each is filterable on three independent axes, so a picker can ask different questions of the same set:
| Axis | What it answers | Values |
|---|---|---|
vertical |
What kind of business or person is this? | verticalNames() |
layout |
What shape does the page take? | classic · editorial · minimal · bold · app · profile · catalogue · showcase · landing · conversational |
tags |
Free facets for search | templateTags() — booking, pets, portfolio, one-pager, … |
import {
templatesForVertical, templatesForLayout, templatesByTag, getTemplate,
generateSite, renderSite,
} from '@bytesbrains/weblocks';
templatesForVertical('trades'); // every carpenter/electrician/roofer starter
templatesForLayout('editorial'); // the type-led ones, across all verticals
templatesByTag('booking'); // everything appointment-driven
const t = getTemplate('salon-spa')!;
t.label; // 'Salon & Spa — Booking'
t.description; // one line for the picker
t.layout; // 'classic'
t.preset; // 'candy' — recoverable, unlike design tokens alone
renderSite(t.manifest); // instant preview, no model call
// Or scaffold generation from a template — keep the structure, rewrite the copy:
await generateSite('a taco truck in Austin', callModel, { template: 'restaurant-modern' });
// A raw SiteManifest works too: { template: myManifest }. Omit → blank-slate compose.Browse them rendered and filterable at
the starter gallery, or
build them locally: npm run example:templates → templates-output/index.html.
Templates live one file per vertical under src/templates/, each declared with
tpl() from src/templates/_helpers.ts; src/templates.ts is just the registry.
To add one, append to its vertical's array — nothing else needs touching. They are
additive and stable (ids never renamed); every manifest is validateManifest-clean,
free of config keys the schema would silently drop, and free of dangling in-page
anchors — all unit-tested, with npm run check:templates as the authoring loop.
Register a BlockSpec (type + schema + css + render, optionally island
/ runtime) in registry.ts. It must clear the block definition-of-done: a
typed schema (no raw-HTML field), consumes shared tokens, renders totally
(defaults + escaping, never throws), valid regardless of neighbours. See
docs/ARCHITECTURE.md.
Also give it demo config so it appears on the
block wall — either place it in
a starter template, or add an entry to SUPPLEMENT in src/showcase.ts.
showcase.test.ts fails on any registered type without one.
npm install # dev deps only (typescript, @types/node)
npm run build # tsc → lib/
npm test # block definition-of-done + engine invariants
npm run example # render a sample landing page → example-output.html
npm run example:resume # render a live résumé/CV → resume-output.html (try its Download-PDF)
npm run example:templates # render every starter template → templates-output/index.html
npm run site # build the published gallery (wall + templates) → site/index.html
npm run emit:catalog # regenerate catalog.json + CATALOG.md from code
npm run check:templates # authoring check for starter templates (add a vertical id to scope it)Drive it end-to-end with a real model (dev harness — provider is env, not code):
PROVIDER=openai OPENAI_API_KEY=sk-… npm run ai -- generate "a Lisbon bakery landing page"
PROVIDER=openai OPENAI_API_KEY=sk-… npm run ai -- edit "make it dark, add a gallery"- Live gallery — the engine's real
output: every block rendered ·
every starter template.
Rebuilt from source on every push; run it locally with
npm run site. - Package on npm —
npm i @bytesbrains/weblocks.
For agents — the contract, fetchable without installing anything:
| URL | |
|---|---|
/llms.txt |
Index of everything below, in the convention models look for. |
/AGENT.md |
Prime directives, composing a manifest, editing with ops, guarantees. |
/catalog.json |
All 54 block types with full JSON Schema for their config. |
/catalog.txt |
The same vocabulary, one line per block — cheap to drop in a system prompt. |
/tools.json |
A ready-to-use compose_site function-calling definition. |
AGENT.md— how to use this package from an AI / agent.VISION.md— principles and direction.docs/ARCHITECTURE.md— internals.CATALOG.md— every block’s fields.CHANGELOG.md·CONTRIBUTING.md·SECURITY.md
Contributing in one line: branch off dev and open your PR against dev,
which is squash-merged — main takes tagged release PRs from dev only. See
Branches & releases.
MIT © bytesbrains