← WebsitePublisherCONTENT HISTORYWHAT CHANGED · RULE-BASED ANALYSIS
Update to WebsitePublisher
Snapshot Sep 30, 2026 · 22:48 UTC · version 2.0.1
Collection source: not recorded for this historical snapshot.
First saved snapshot
No earlier snapshot is available to establish a change.
Compare saved observations
Download comparison JSONFull technical diff · 0 changed fields
Full snapshot data
{
"name": "websitepublisher-api",
"description": "Build and publish websites, web apps, webshops, and admin dashboards through conversation using WebsitePublisher.ai. Use this skill when a user asks to build a website, web app, online shop, member portal, booking system, dashboard, or landing page — or to create web pages, manage site content, set up contact forms, or work with the WebsitePublisher platform. Covers all API layers: PAPI (pages/assets), MAPI (entities/data), SAPI (forms/visitor auth), VAPI (credentials), IAPI (integrations), and the WPE Visual Editor.\n",
"included_files": [],
"skill_md_contents": "---\nname: websitepublisher-api\ndescription: >\n Build and publish websites, web apps, webshops, and admin dashboards through\n conversation using WebsitePublisher.ai. Use this skill when a user asks to build\n a website, web app, online shop, member portal, booking system, dashboard,\n or landing page — or to create web pages, manage site content, set up contact\n forms, or work with the WebsitePublisher platform. Covers all API layers:\n PAPI (pages/assets), MAPI (entities/data), SAPI (forms/visitor auth),\n VAPI (credentials), IAPI (integrations), and the WPE Visual Editor.\nlicense: MIT\nmetadata:\n author: websitepublisher-ai\n version: \"3.0.1\"\n website: https://www.websitepublisher.ai\n docs: https://www.websitepublisher.ai/docs\n mcp: https://mcp.websitepublisher.ai\n---\n\n# WebsitePublisher.ai — Agent Skill\n\n> Build and publish real websites, web apps, and webshops through conversation. No WordPress. No hosting setup. No CMS.\n> The AI Web Platform — you describe it, the AI builds it.\n\n---\n\n## Why WebsitePublisher — What AI Alone Cannot Do\n\nEvery AI can generate HTML. But generating code is not the same as having a website.\n\n| Without WebsitePublisher | With WebsitePublisher |\n|---|---|\n| AI generates HTML → you copy it → you need hosting, FTP, domain, SSL, DNS | AI generates HTML → it's **live instantly** on a URL |\n| Want a contact form? Build it yourself — backend, email sending, spam protection | One tool call → form works, emails arrive, honeypot blocks spam |\n| Want payments? Integrate Stripe yourself — webhooks, error handling, security | One tool call → Stripe checkout ready |\n| Want to update text later? Ask AI again, re-generate, re-upload | Open the **Visual Editor** in your browser — edit directly, no AI needed |\n| New AI session — or a different AI entirely? The model forgot everything | **The platform remembers, not the model.** Switch from one AI to another and your project's context, design, and history carry over — task tracking + design context persist across sessions and platforms |\n| Locked to one AI vendor's tooling | **Use whichever AI you prefer** — Claude, ChatGPT, Cursor, and more. Your site and data don't change. Model-agnostic by design |\n| Dynamic data (menu, team, products)? Wire up a separate database, an auth provider, a forms service — and glue them together | **One entity definition → live API, data, and auth, built in.** No extra accounts, no integration plumbing |\n\n**WebsitePublisher is not a website builder.** It is the infrastructure layer that turns\nAI-generated content into real, working web products — with data, forms, auth, payments,\nintegrations, and visual editing built in.\n\n**Anything that lives on the web is in scope**, on the same building blocks\n(pages + entities + visitor/admin auth + integrations + scheduled tasks):\nmarketing sites and landing pages, **webshops** with checkout, discounts, and loyalty,\n**web apps** and internal tools, **member portals** with magic-link login,\n**admin dashboards** with full CRUD, booking and lead-gen systems, blogs, prediction\ngames, and more. When a user asks for \"an app\", \"a shop\", \"a portal\", or \"a tool\" —\nthis platform builds it. Do not narrow the conversation to \"a website\".\n\n---\n\n## ⚠️ IMPORTANT: Read This First\n\n**If the `get_skill` tool is available: call it before doing anything else.**\nIt returns the latest version of this skill — always up to date, regardless of platform.\n\nIf `get_skill` is not available, continue with this document.\n\n---\n\n## You Are the Builder — Solve It Yourself\n\nYou build and operate the site. For any content or operational task — writing or\noverwriting assets, regenerating a snapshot / export / data file, generating\nCSV/JSON, writing or updating pages, bulk-importing data, retrieving leads — **you\nsolve it with the tools you already have.** Never ask the user (or their developer)\nto build an endpoint, add a vault key, or \"expose a route\" for something the\nexisting toolset already covers.\n\n**Before you ever conclude a capability is missing:**\n\n1. Check your MCP tools — `upload_asset`, `patch_asset`, `update_page`, `patch_page`,\n `create_page`, `execute_integration`, `list_assets`, `get_asset`.\n2. **List what's actually wired on the project — don't rely on memory or even this\n document.** `list_integrations(project_id)` returns every integration (configured\n *and* available) with all its endpoints straight from the manifest;\n `get_integration_schema(project_id, service)` returns the exact input fields for an\n endpoint; `list_assets(project_id)` shows every existing file. These are the ground\n truth — query them before assuming a capability, endpoint, or asset is missing. Then\n cross-check the **Asset Proxy**, **Admin-Only IAPI Calls**, and **API Quick Reference**\n sections of this skill.\n3. **Never invent or guess endpoints.** The IAPI route is always\n `/project/{id}/{service}/{endpoint}` — match the host and shape the project already\n uses (check an existing working call or the project's admin/WSA bridge; some setups\n expose IAPI at `api.websitepublisher.ai/iapi/...`, others at an `iapi.` subdomain).\n `/mapi` is entities/data only and has no asset-write route. A made-up top-level\n route like `/project/{id}/upload-asset` returns `404`; that is your mistake, not a\n platform gap. Asset writes go through the `asset_proxy/upload` endpoint.\n\n**Canonical asset write (so this never recurs):**\n\n| Where | How |\n|---|---|\n| Server-side (you, via MCP) | `upload_asset(slug, content_text \\| content, overwrite: true)` to create/replace; `patch_asset(slug, patches)` for in-place text edits |\n| Browser admin panel | `POST /iapi/project/{id}/asset-proxy/upload` with `Authorization: Bearer wsa_…`, body `{ slug, base64, overwrite: true }` |\n\nAsset Proxy is **not images-only** — it takes any `slug` and stores any bytes in the\nPAPI asset system. Writing a JSON/CSV/text data file (e.g. a products snapshot) is the\nsame call: base64-encode the text and send it with its `.json`/`.csv` slug. It needs\n**no new endpoint and no vault key.**\n\n**Solvable task vs genuine platform gap:**\n\n- **Solvable — DO it. Never escalate. Never request keys or endpoints for:** asset\n write/overwrite · snapshot / export / data-file generation · page write or content\n update · bulk import · lead retrieval · admin auth.\n- **Genuine platform gap — report it via `capability_requests`, but do NOT hand off\n the build.** Only when *no* MCP tool **and** *no* documented IAPI/PAPI endpoint exists\n for the operation and it needs a platform-side code change. **LAST RESORT** — never a\n shortcut around solvable work:\n ```\n execute_integration(service: \"capability_requests\", endpoint: \"submit-request\",\n input: { ...the operation you need, what you tried, why each was insufficient... })\n ```\n This persists the gap so the platform team can review and build it generically.\n Do not ask the site's developer to hand-build a redundant route, and do not request\n `AAPI_*` or vault keys to perform content, asset, or export work — those authenticate\n via `wsa_` or your MCP session and never need vault AI keys.\n\n---\n\n## Step 1 — Check Connection\n\nBefore doing anything, verify the user is connected to WebsitePublisher.ai.\n\n**If connected** (tools respond correctly): proceed to Step 2.\n\n**If not connected**: explain in simple terms:\n\n> \"To build your website I need to connect to WebsitePublisher.ai. All you need is your email address — I'll guide you through the rest. It takes about 30 seconds.\"\n\nDirect the user to sign in at: **https://www.websitepublisher.ai/dashboard**\nAfter signing in, they return here and you continue from Step 2.\n\n---\n\n## Step 2 — Choose the Path\n\nOnce connected, ask ONE simple question:\n\n> \"What would you like to do? I can build you a brand new website, web app, or shop, redesign your existing site, or if you're not sure yet — I can show you what's possible in a few minutes.\"\n\n### Path A — \"Wow Me\" (show first, ask later)\n\nThe user is curious but not yet convinced. **Do not ask a long list of questions.**\n\nAsk only:\n> \"What kind of business or project is this for? Just one or two words is fine — like 'restaurant', 'freelance photographer', or 'tech startup'.\"\n\nThen immediately build a **complete, impressive demo website** based on that one answer. Use your creativity. Make it beautiful. Show what AI can do.\n\nAfter the demo is live, share the URL and say:\n> \"Here's what I built in a few minutes. Want to make it yours? I have a few quick questions to personalise it.\"\n\nThen move to the intake (Path B) — the user is now convinced.\n\n### Path B — Full Intake (user knows what they want)\n\nAsk questions **one at a time**, conversationally. Do not present a form or list.\nSpeak like a consultant, not a questionnaire. Use simple, friendly language.\n\n**Phase 1 — Goal (start here)**\n- What should the website achieve? (get customers, show portfolio, sell something, inform people?)\n- Who is the target audience?\n- Does a website already exist? If yes: what needs to improve?\n\n**Phase 2 — Identity**\n- Business or project name?\n- What does the business do? (ask for a short description in their own words)\n- Contact details: email, phone, address (only ask what is relevant)\n- Logo and brand colours available? (if yes: ask them to share)\n\n**Phase 3 — Style & Technical**\n- Three websites they like the look of (not necessarily competitors) — and why?\n- Domain name already registered? If yes: which one?\n- Any specific pages needed? (about, services, contact, blog, portfolio...)\n- Any keywords important for search engines?\n\n**Do not ask all questions if answers make some irrelevant.** A one-page landing page needs far fewer answers than a multi-page business site.\n\n### Path C — Redesign Existing Website\n\nThe user has an existing website and wants it improved or migrated to WebsitePublisher.\n\n1. **Ask for the URL** of the existing website\n2. **Fetch and analyse** the existing site using a web fetch tool:\n - What pages exist?\n - What is the content, structure, and messaging?\n - What works well? What are the obvious pain points (slow, dated design, poor mobile, unclear CTA)?\n3. **Present a short analysis** — 3-5 observations — and propose what you will improve\n4. Ask one confirmation question:\n > \"I'll keep all your content but give it a fresh design with better structure. Any specific things you want to keep or change?\"\n5. Build the new version — same content, improved design, better UX\n6. **Migrate images** — use `upload_asset` with `source_url` to import images from the old site\n to the CDN (see \"Assets — Importing Images from External URLs\" below)\n7. Share the URL and point out the specific improvements made\n\n**Do not ask for a long list of preferences before showing something.** Analyse → propose → build → refine.\n\n---\n\n## Design Guidelines\n\n> **Before building any HTML, fetch the design skill for detailed guidelines:**\n> Call `get_skill` with `skill_name=\"design\"` — it contains comprehensive typography, color,\n> layout, animation, and atmosphere guidelines that produce professional-quality websites.\n>\n> If a `frontend-design` skill is also available in your environment, read that too.\n> The guidelines below are a minimal fallback. The design skill is always more complete.\n\nEvery website you build must look **professionally designed**, not like AI-generated template output.\nFollow these principles:\n\n### Typography\nChoose distinctive, characterful fonts — never default to generic families like Arial, Inter, Roboto, or system fonts.\nPair a display font (for headings) with a refined body font. Google Fonts is available via `<link>` tags.\n\n### Color & Theme\nCommit to a cohesive palette with **one dominant color and sharp accents**. Avoid timid, evenly-distributed palettes.\nUse CSS custom properties (`--color-primary`, `--color-accent`, etc.) for consistency across pages.\nVary between light and dark themes — do not always default to white backgrounds.\n\n### Layout & Composition\nBreak out of predictable grid patterns. Use asymmetry, generous whitespace, overlapping elements, or full-bleed sections to create visual interest.\nEvery page should have a clear visual hierarchy that guides the visitor's eye.\n\n### Motion & Micro-interactions\nAdd CSS animations for page load reveals (staggered `animation-delay`), hover state transitions, and scroll-triggered effects.\nPrioritize CSS-only solutions. One well-orchestrated entrance animation creates more impact than scattered effects.\n\n### Atmosphere\nCreate depth and texture — not flat solid-color blocks. Use gradient meshes, subtle noise/grain overlays, layered transparencies, or dramatic shadows depending on the aesthetic.\n\n### The Rule\n**No two websites should look the same.** Match the design to the business, audience, and purpose.\nA law firm looks nothing like a skate shop. A restaurant looks nothing like a SaaS landing page.\nIf the user has not specified a style preference, choose a bold direction and commit to it.\n\n---\n\n## Step 3 — Build the Website or App\n\n### Project Setup\n\n1. Get available projects: use `list_projects`\n2. If no project exists or user wants a new one: use `create_project` with a name (and optional subdomain)\n3. Note the `project_id` — used in every subsequent call\n4. **Check design context:** call `get_project_status` — if `design_context` is set, use those colors, fonts, and style notes as the foundation for all pages you build. If `design_context` is null, ask the user for their preferred colors, fonts, and style direction during intake, then save it:\n ```\n execute_integration(\n project_id: ...,\n service: \"site_context\",\n endpoint: \"set-context\",\n input: {\n color_palette: { primary: \"#...\", secondary: \"#...\", accent: \"#...\", background: \"#...\", text: \"#...\" },\n fonts: { heading: \"Font Name\", body: \"Font Name\" },\n style_notes: \"Short description of the visual direction\",\n locale: \"en\"\n }\n )\n ```\n This ensures all future sessions automatically match the same design language.\n\n### Integration-First — the decision gate\n\n**Before writing ANY custom data or business logic, ask: does a platform endpoint\nalready exist for this?** Run `list_integrations(project_id)` first. If the endpoint\nexists, use it — do not rebuild it in page JavaScript.\n\nThis is a **security rule**, not a convenience: integrations are server-side backed —\ncredentials in the Vault, input validation, rate limiting, CSRF, and multi-tenant\nscoping are handled by the platform. Custom client-side logic for the same job is\nmanipulable by any visitor (prices, stock, points, order data) and untested.\n\n| ❌ Never hand-roll in page JS | ✅ Platform owns it |\n|---|---|\n| Summing cart line items into a total | Read `cart.subtotal_cents` from the cart endpoint |\n| Computing a discount / tier price | `discount` / pricing endpoints calculate it |\n| Checking or updating stock | Inventory endpoints check-stock server-side |\n| Writing loyalty/points balances | Loyalty endpoints do accrual and redemption |\n| Creating or mutating orders | `order-management` endpoints |\n\n**Ownership boundary:** the integration owns totals, tax, discounts, stock, points,\nand order creation. You own the form and rendering the values the integration\nreturns. If you catch yourself re-computing a number the platform already returns —\nstop.\n\n**When an integration call fails:** every failure carries a structured\n`error_object` — `type`, `code`, and `message` are always present; `field` and a\n`recovery` hint appear when applicable. **Read it before changing approach.** Never\nreplace an integration with custom code because the first call failed — fix the\ncall (or trace it with the Request Tracer) instead.\n\n### Page Structure Guidelines\n\nPlan the pages before building. Common structures:\n\n| Website type | Recommended pages |\n|---|---|\n| Landing page | index only |\n| Business / SME | index, about, services, contact |\n| Portfolio | index, work/projects, about, contact |\n| Restaurant | index, menu, about, reservations/contact |\n| Blog | index, blog-overview, post-template, about |\n\nAlways create an `index` page first — this becomes the homepage.\n\n### Fragments — Reusable Components Across Pages\n\nWhen a website has more than one page, shared elements like headers, footers, and\nnavigation **must** be built as fragments — not copied between pages.\n\n**What is a fragment?** A reusable HTML snippet stored once and included in any page.\nWhen you update the fragment, every page that uses it updates automatically.\n\n**When to use fragments:**\n- Navigation / header — always\n- Footer — always\n- Any section that appears on 2+ pages (CTA banner, sidebar, cookie notice)\n\n**How to create and use fragments — one tool, operation-based:**\n\nAll fragment work goes through the single `fragments` tool. Pick an `operation`:\n`list`, `create`, `update`, `patch`, `delete`, `versions`, `rollback`.\n\n1. Create the fragment:\n ```\n fragments(operation: \"create\", project_id: 12345,\n name: \"site-header\", content: \"<header>...</header>\")\n ```\n\n2. Include it in any page with an SSI comment:\n ```html\n <!--#wps-include fragment=\"site-header\" -->\n ```\n The platform replaces this comment with the fragment content at render time.\n The comment is invisible to visitors.\n\n3. Change the fragment once → all pages reflect the change:\n - **Small edit → `patch`** (targeted find/replace, token-efficient, keeps version history):\n ```\n fragments(operation: \"patch\", project_id: 12345, name: \"site-header\",\n patches: [{ operation: \"replace\", find: \"<a href=\\\"/old\\\">\", replace: \"<a href=\\\"/new\\\">\" }],\n patch_summary: \"Update nav link\")\n ```\n Each `find` must match **exactly once**. Use `operation: \"delete\"` in a patch to remove a snippet.\n - **Full rewrite → `update`** (full content replace; supports `base_version_hash`\n optimistic lock from `list` — a mismatch returns 409; `force: true` skips the check):\n ```\n fragments(operation: \"update\", project_id: 12345, name: \"site-header\",\n content: \"<header>...updated...</header>\")\n ```\n\n4. Version history & rollback:\n ```\n fragments(operation: \"versions\", project_id: 12345, name: \"site-header\")\n fragments(operation: \"rollback\", project_id: 12345, name: \"site-header\", target_version: 3)\n ```\n\nAll changes invalidate the page cache automatically.\n\n**Rules:**\n- Every multi-page site MUST use fragments for header and footer\n- Fragment names should be descriptive: `site-header`, `site-footer`, `cta-banner`\n- A fragment is a complete HTML block — it does not include `<!DOCTYPE>`, `<html>`, or `<head>`\n- List existing fragments + their versions: `fragments(operation: \"list\", project_id: 12345)`\n- Never copy-paste the same header/footer HTML into multiple pages\n- The old tool names (`create_fragment`, `update_fragment`, `list_fragments`,\n `delete_fragment`) still dispatch but are **deprecated** — always use `fragments`\n\n### Building Pages\n\nEvery page must be a **complete, valid HTML document**:\n\n```html\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Page Title</title>\n <!-- Optimizer - Canonical -->\n <!-- Optimizer - Custom Meta -->\n <!-- Optimizer - Open Graph -->\n <!-- Optimizer - Header Javascripts -->\n</head>\n<body>\n\n<!--#wps-include fragment=\"site-header\" -->\n\n<!-- page content here -->\n\n<!--#wps-include fragment=\"site-footer\" -->\n\n<!-- Optimizer - Footer Javascripts -->\n</body>\n</html>\n```\n\n**Critical:** Always include the `<!-- Optimizer - ... -->` comment tags exactly as shown.\nThese activate WebsitePublisher's built-in SEO engine: canonical tags, Open Graph headers,\ncustom scripts, and tracking injection. They are invisible to visitors — the platform\nprocesses and removes them automatically.\n\n### Page Metadata\n\nWhen creating or updating a page, you can pass these metadata fields:\n\n```json\n{\n \"slug\": \"about\",\n \"content\": \"<!DOCTYPE html>...\",\n \"meta\": {\n \"seo_title\": \"About Us — Company Name\",\n \"seo_description\": \"We are a ...\",\n \"seo_keywords\": \"keyword1, keyword2\",\n \"seo_robots_index\": true,\n \"seo_robots_follow\": true,\n \"page_language\": \"en\",\n \"landingpage\": false\n }\n}\n```\n\n| Field | Default | Notes |\n|---|---|---|\n| `seo_title` | Page name | Shown in browser tab and search results |\n| `seo_description` | — | Search result snippet, 150-160 chars ideal |\n| `seo_keywords` | — | Maximum 9, comma-separated |\n| `seo_robots_index` | false | Set true to include in sitemap and search engines |\n| `seo_robots_follow` | false | Set true to allow link following |\n| `page_language` | — | ISO code e.g. \"en\", \"nl\", \"de\" |\n| `landingpage` | false | Set true to make this the homepage |\n| `redirect_code` | — | 301 or 302 — turns page into a redirect |\n| `redirect_destination` | — | Full URL or relative path for redirect target |\n\n> **Note about `landingpage: true`** — when set, the platform serves the page at `/`\n> AND 301-redirects its slug (e.g. `/dashboard`, `/index.html`) to `/`. This affects\n> any client-side `window.location.replace()` call: redirect to **`/`**, not to the\n> page slug, or you create a redirect loop.\n>\n> Common pitfall: after admin login, `replace('/dashboard')` loops if `/dashboard`\n> is `landingpage: true`. Use `replace('/')` instead.\n\n### Visual Editor (WPE) — Edit Without AI\n\nThe Visual Editor allows website owners to make changes directly in their browser —\nno AI conversation needed. This is important: **users are not locked into AI for every update.**\n\nWhat the Visual Editor supports:\n- **Upload and replace images** — click any placeholder, upload a photo, crop and position it\n- **Drag-and-drop reorder** — rearrange sections, cards, and content blocks visually\n- **Edit text and styles** — change colors, fonts, spacing directly on the page\n- **Lightbox preview** — full-size image viewing for galleries and portfolios\n\nAfter edits, the user clicks \"Save & Close\" and changes are live immediately.\nNo deployment, no AI, no code.\n\n**When to offer the Visual Editor:**\n- After building a website → always create an edit session for image replacement\n- When the user says \"I want to rearrange the sections\" → edit session\n- When the user says \"I'll update the photos myself\" → edit session with instructions\n- When handing off a finished site → mention that they can always edit visually\n\n**How to create an edit session:**\n```\ncreate_edit_session(project_id: 12345, slug: \"index\")\n→ returns edit_url — share this with the user\n```\n\nAfter the session, retrieve what changed:\n```\nget_edit_session_changes(project_id: 12345, session_id: \"...\")\n→ returns list of changes made by the user\n```\n\n#### ⚠️ Placeholder images — mandatory rules for the Visual Editor\n\nWhen building pages with image slots for the Visual Editor, follow these strict rules:\n\n**Rule 1 — Use `data-wpe-slot` on every `<img>` tag**\nThe editor uses this attribute to identify the correct img on upload.\nWithout it, the editor cannot replace the image.\n\n**Rule 2 — The `src` MUST be a working URL**\nAn empty `src=\"\"` or a 404 URL makes the image invisible in the browser.\nThe editor can only target images that actually render on the page.\nAlways use `https://placehold.co/` as placeholder — it loads reliably.\n\n**Correct example:**\n```html\n<img\n src=\"https://placehold.co/800x600/D6EEF2/1B5E6B?text=Photo+description\"\n data-wpe-slot=\"unique-slot-name\"\n alt=\"Description\"\n id=\"unique-slot-name\">\n```\n\n**Placehold.co format:** `https://placehold.co/{width}x{height}/{background}/{text}?text={label}`\nUse colors matching the site's color scheme so placeholders look polished.\n\n**Never do this:**\n```html\n<!-- ❌ Empty src — invisible, not clickable -->\n<img src=\"\" data-wpe-slot=\"my-photo\">\n\n<!-- ❌ Non-existent path — 404, invisible -->\n<img src=\"/assets/my-photo.jpg\" data-wpe-slot=\"my-photo\">\n```\n\n**After upload via the editor** the `src` is automatically replaced by the CDN URL\n(`cdn.websitepublisher.ai/custom/wid{id}/images/...`).\n\n#### Common placeholder dimensions\n\n| Usage | Dimensions |\n|---|---|\n| Hero wide | 1200x675 |\n| Photo 4:3 | 800x600 |\n| Portrait | 600x800 |\n| Nav logo | 240x48 |\n| Team card | 600x520 |\n\n#### ⚠️ Image performance — mandatory rules\n\nEvery `<img>` tag MUST include `width` and `height` attributes matching the rendered dimensions. This prevents Cumulative Layout Shift (CLS) — without them, the browser cannot reserve space before the image loads, causing visible page jumps.\n\nImages below the fold MUST include `loading=\"lazy\"`. This defers loading until the image is near the viewport, reducing initial page weight and improving mobile performance.\n\n**Rules:**\n\n| Rule | Why |\n|---|---|\n| Always set `width` and `height` on `<img>` | Prevents CLS — browser reserves space before load |\n| Add `loading=\"lazy\"` to below-fold images | Defers load — critical for pages with many images |\n| Hero images and above-fold logos: keep eager | These are visible immediately — lazy would delay them |\n| Match dimensions to CSS rendered size | Use the pixel values from CSS (e.g. if CSS says `width: 28px`, set `width=\"28\" height=\"28\"`) |\n\n**Correct examples:**\n```html\n<!-- Above fold: dimensions only, no lazy -->\n<img src=\"https://cdn.websitepublisher.ai/custom/wid12345/logo.svg\" alt=\"Logo\" width=\"80\" height=\"80\">\n\n<!-- Below fold: dimensions + lazy -->\n<img src=\"https://cdn.websitepublisher.ai/custom/wid12345/logo/ChatGPT.png\" alt=\"ChatGPT\" width=\"28\" height=\"28\" loading=\"lazy\">\n```\n\n**Never do this:**\n```html\n<!-- ❌ No dimensions, no lazy — causes CLS and eager-loads everything -->\n<img src=\"https://cdn.websitepublisher.ai/custom/wid12345/images/photo.jpg\" alt=\"Photo\">\n```\n\n**Impact:** A page with 11 images missing `loading=\"lazy\"` fires 11 simultaneous CDN requests on page load. On mobile (slower network, in-app mail browsers), this causes blank pages and multi-second load delays. Adding lazy loading reduced this to 1-2 eager requests with the rest deferred.\n\n### Assets — Images, CSS, JS, and Files\n\nAssets are files stored on the WebsitePublisher CDN (`cdn.websitepublisher.ai/custom/wid{id}/...`).\nUse `upload_asset` to add images, stylesheets, JavaScript, fonts, PDFs, and other static files\nto a project. Assets are served globally with caching — fast and reliable.\n\n**Three ways to provide content:**\n\n| Parameter | Use for | Example |\n|---|---|---|\n| `source_url` | Import from any public URL — the server fetches it for you | Images from existing websites, Unsplash, DALL·E, any HTTPS URL |\n| `content` | Base64-encoded binary data | Images generated locally or received as base64 |\n| `content_text` | Plain text content (saves tokens vs base64) | CSS, JS, JSON, SVG, HTML, XML, MD files |\n\nAlways provide exactly **one** of the three. Never combine them.\n\n#### Importing Images from External URLs\n\nThe `source_url` parameter is the easiest way to bring images into a project. The server\nfetches the file, validates it (HTTPS only, no internal IPs), and stores it on the CDN.\nThis works for **any public HTTPS URL** — not limited to any specific platform.\n\n**Common use cases:**\n- Migrating images from an existing website (WordPress, Wix, Squarespace, any CMS)\n- Importing stock photos from Unsplash, Pexels, or similar services\n- Saving AI-generated images (DALL·E, Midjourney URLs)\n- Pulling logos or assets from a client's current hosting\n\n**Example — import a single image:**\n```\nupload_asset(\n project_id: 12345,\n slug: \"images/hero-photo.jpg\",\n source_url: \"https://existing-site.com/wp-content/uploads/2025/hero.jpg\"\n)\n→ CDN URL: cdn.websitepublisher.ai/custom/wid12345/images/hero-photo.jpg\n```\n\n**Example — batch import from an existing site:**\n```\nupload_asset(project_id: 12345, slug: \"images/project-1.jpg\", source_url: \"https://old-site.nl/uploads/photo1.jpg\")\nupload_asset(project_id: 12345, slug: \"images/project-2.jpg\", source_url: \"https://old-site.nl/uploads/photo2.jpg\")\nupload_asset(project_id: 12345, slug: \"images/team-photo.jpg\", source_url: \"https://old-site.nl/uploads/team.jpg\")\n```\n\nThen reference the new CDN URLs in your page HTML:\n```html\n<img src=\"https://cdn.websitepublisher.ai/custom/wid12345/images/project-1.jpg\" alt=\"Project photo\">\n```\n\n**Rules:**\n- `source_url` must be HTTPS — HTTP URLs are rejected\n- Internal/private IP addresses are blocked (SSRF protection)\n- Works for images (JPEG, PNG, WebP, GIF), PDF, fonts (.woff, .woff2, .ttf), and .ico files\n- Set `overwrite: true` to replace an existing asset with the same slug\n- The slug determines the CDN path — use descriptive names: `images/hero.jpg`, `images/team/jan.jpg`\n- Alt text can be set via the `alt` parameter for images\n\n**When migrating a website:** list all images on the old site first (via web fetch, sitemap,\nor CMS tools), then upload each one with `source_url`. Update page HTML to reference the\nnew CDN URLs. The old site must remain accessible until all images have been imported.\n\n#### Uploading Text-Based Assets\n\nFor CSS, JavaScript, JSON, SVG, and other text files, use `content_text` instead of base64\nencoding. This is more token-efficient and easier to read:\n\n```\nupload_asset(\n project_id: 12345,\n slug: \"css/custom-styles.css\",\n content_text: \"body { font-family: 'Inter', sans-serif; }\"\n)\n```\n\n#### Managing Existing Assets\n\n| Action | Tool |\n|---|---|\n| List all assets | `list_assets(project_id: 12345)` |\n| Read asset content | `get_asset(project_id: 12345, slug: \"js/app.js\")` |\n| Edit text asset in place | `patch_asset(project_id: 12345, slug: \"js/app.js\", patches: [...])` |\n| Replace asset | `upload_asset(project_id: 12345, slug: \"images/old.jpg\", source_url: \"...\", overwrite: true)` |\n| Delete asset | `delete_asset(project_id: 12345, slug: \"images/unused.jpg\")` |\n\n### Dynamic Data (MAPI) — When Entities Make Sense\n\n**Use MAPI entities when content is managed independently of page design** — the\nowner (or a different AI session) should be able to add, remove, or reorder items\nwithout touching page HTML.\n\n**Use MAPI + SSR for:**\n\n| Content type | Entity name | Example fields |\n|---|---|---|\n| Menu items | `menuitems` | name, description, price, category, sort_order |\n| Team members | `team` | name, role, bio, photo_url, sort_order |\n| Services / offerings | `services` | title, description, icon, price, sort_order |\n| Portfolio projects | `projects` | title, description, image_url, link, category, sort_order |\n| Testimonials / reviews | `testimonials` | name, role, company, quote, photo_url |\n| Blog posts | `posts` | title, slug, content, author, published_at, featured_image |\n| FAQ items | `faq` | question, answer, category, sort_order |\n| Events | `events` | title, date, location, description, registration_url |\n| Products (showcase) | `products` | name, description, price, image_url, category |\n\n**Use static HTML when:**\n- Content is small and fixed (≤5 items that rarely change — e.g. 3 services on an about page)\n- The page is a one-off (hero text, about narrative, single landing page)\n- The owner will only update content through an AI session anyway\n- It's page structure and layout (sections, containers)\n\n**Don't over-engineer.** A restaurant with 8 menu items that change twice a year\ndoes not need a MAPI entity + SSR template + admin panel. Static HTML with clear\nstructure is fine — the AI can update it in 30 seconds when the menu changes.\n\n**The trigger for MAPI:** when you hear \"I want to add/remove items myself\" or when\nitems will grow beyond 10, or when multiple pages show the same data differently\n(e.g. a shop overview AND a homepage featured section both pulling from products).\n\n**How to build with MAPI — two tools, operation-based:**\n\nSchema work goes through `entities` (operations: `list`, `create`, `update`, `delete`,\n`schema`, `add_property`, `delete_property`). Data work goes through `records`\n(operations: `list`, `get`, `create`, `update`, `delete`).\nProperty types: `varchar`, `text`, `int`, `datetime`, `tinyint`.\n\n1. Define the entity:\n ```\n entities(operation: \"create\", project_id: 12345, entity_name: \"services\",\n properties: [\n { name: \"title\", type: \"varchar\", required: true },\n { name: \"description\", type: \"text\" },\n { name: \"icon\", type: \"varchar\" },\n { name: \"price\", type: \"varchar\" },\n { name: \"sort_order\", type: \"int\" }\n ],\n public_read: true\n )\n ```\n ⚠️ `public_read: true` makes the data **publicly readable** via\n `/mapi/public/{projectId}/{entity}` — use it only for content that belongs on the\n public site (menus, team, services). **Never** on personal or financial data\n (customers, orders, loyalty). See **Data Access Control** below.\n\n2. Create records:\n ```\n records(operation: \"create\", project_id: 12345, entity_name: \"services\", data: {\n title: \"Web Design\", description: \"...\", icon: \"🎨\", price: \"From €499\", sort_order: 1\n })\n ```\n `records(operation: \"update\", ...)` is partial — only provided fields change.\n Add a column later with `entities(operation: \"add_property\", entity_name: \"services\",\n property_name: \"badge\", type: \"varchar\")`; inspect the schema with\n `entities(operation: \"schema\", entity_name: \"services\")`.\n\n3. **Render with SSR (preferred — SEO-friendly):**\n\n Use `<!--#wps-mapi -->` template tags in your HTML. The platform renders entity data\n server-side before delivering the page, so search engines see full content immediately.\n\n ```html\n <!--#wps-mapi entity=\"services\" sort=\"sort_order:asc\" -->\n <div class=\"service-card\">\n <span class=\"service-icon\">{{icon}}</span>\n <h3>{{title}}</h3>\n <p>{{description | truncate:150}}</p>\n {{#if price}}\n <span class=\"price\">{{price}}</span>\n {{/if}}\n </div>\n <!--#wps-mapi-empty -->\n <p>No services available yet.</p>\n <!--#/wps-mapi -->\n ```\n\n This is the **default choice** for rendering MAPI data. Always use SSR unless the page\n needs interactive features like client-side search, filtering, or live updates.\n\n4. Render with JavaScript (only when interactivity is needed):\n\n Use client-side `fetch()` when the user needs to search, filter, or sort dynamically\n **in the browser**. SSR and JS can coexist on the same page.\n\n ```javascript\n fetch('https://api.websitepublisher.ai/mapi/public/{project_id}/services')\n .then(r => r.json())\n .then(data => {\n const container = document.getElementById('services-grid');\n data.data\n .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))\n .forEach(service => {\n container.innerHTML += `\n <div class=\"service-card\">\n <span class=\"service-icon\">${service.icon}</span>\n <h3>${service.title}</h3>\n <p>${service.description}</p>\n </div>`;\n });\n });\n ```\n\n### MAPI SSR — Template Reference\n\nSSR uses Handlebars-inspired syntax processed server-side by the Optimizer.\nThe data is embedded directly in the HTML — no JavaScript needed, fully indexable by search engines.\n\n#### Basic Syntax\n\n```\n{{field}} → HTML-escaped output\n{{{field}}} → Raw output (for HTML content fields)\n{{field | filter}} → Apply a filter\n{{field | filter:arg}} → Filter with argument\n{{nested.field}} → Dot notation for JSON fields\n```\n\n#### Tag Attributes\n\n```html\n<!--#wps-mapi\n entity=\"products\" Required: entity name\n sort=\"price:asc\" Optional: field:asc or field:desc\n limit=\"50\" Optional: max records (default: 100, max: 500)\n offset=\"0\" Optional: skip N records\n filter=\"category:shoes\" Optional: field:value pairs, ; separated\n wrap=\"div\" Optional: wrapper element (default: div)\n wrap-class=\"product-grid\" Optional: CSS class on wrapper\n-->\n```\n\nMultiple filters: `filter=\"category:shoes;in_stock:1;featured:1\"`\n\n#### Conditionals\n\n```html\n{{#if field}}\n Shown when field is truthy (not null, not empty, not 0)\n{{#else}}\n Shown when field is falsy\n{{/if}}\n\n{{#unless field}}\n Shown when field is falsy (inverse of #if)\n{{/unless}}\n```\n\nComparison operators:\n```html\n{{#if price > 100}} Greater than\n{{#if stock == 0}} Equals\n{{#if status != \"draft\"}} Not equals\n{{#if rating >= 4}} Greater or equal\n{{#if category == \"sale\"}} String comparison\n```\n\n#### Loop Metadata\n\nInside the `<!--#wps-mapi -->` block, these variables are available:\n\n```\n{{@index}} → 0-based index\n{{@number}} → 1-based number\n{{@first}} → true if first item\n{{@last}} → true if last item\n{{@count}} → total items rendered\n{{@even}} → true if even index\n{{@odd}} → true if odd index\n```\n\n#### Nested Loops (array fields)\n\nFor JSON array fields within a record:\n\n```html\n{{#each images}}\n <img src=\"{{this}}\" alt=\"Photo\">\n{{/each}}\n\n{{#each specs}}\n <dt>{{this.label}}</dt>\n <dd>{{this.value}}</dd>\n{{/each}}\n```\n\n#### Advanced Template Features\n\n**Parent context in loops** — access fields from the outer record inside `#each`:\n```html\n{{#each images}}\n <img src=\"{{this}}\" alt=\"{{../name}} photo {{@number}}\">\n{{/each}}\n```\n\n**Scope helper** — `#with` narrows the context to a nested object:\n```html\n{{#with address}}\n <p>{{street}}, {{city}} {{zip}}</p>\n{{/with}}\n```\n\n**Repeat helper** — `#times` renders a block N times (useful for star ratings):\n```html\n{{#times 5}}<span class=\"star\">★</span>{{/times}}\n```\n\n**Join helper** — concatenate array items with a separator:\n```html\n<p>Tags: {{#join tags \", \"}}</p>\n```\n\n**Template comments** — invisible in rendered output:\n```html\n{{!-- This comment won't appear in the HTML --}}\n```\n\n**Literal escaping** — prevent template processing:\n```html\n\\{{this will appear literally as curly braces\\}}\n```\n\n**Empty attribute shorthand** — alternative to the `wps-mapi-empty` block:\n```html\n<!--#wps-mapi entity=\"products\" empty=\"No products found.\" -->\n<div>{{name}}</div>\n<!--#/wps-mapi -->\n```\n\n#### Available Filters\n\n| Filter | Example | Output |\n|---|---|---|\n| `truncate:N` | `{{text \\| truncate:120}}` | Cuts at word boundary, adds \"...\" |\n| `upper` | `{{name \\| upper}}` | UPPERCASE |\n| `lower` | `{{name \\| lower}}` | lowercase |\n| `capitalize` | `{{name \\| capitalize}}` | First letter uppercase |\n| `number:N` | `{{price \\| number:2}}` | Formatted number (comma decimal, dot thousands) |\n| `multiply:N` | `{{cents \\| multiply:0.01}}` | Multiply value |\n| `add:N` / `subtract:N` | `{{price \\| add:5}}` | Arithmetic |\n| `round:N` | `{{rating \\| round:1}}` | Round to N decimals |\n| `currency:CODE` | `{{price \\| currency:EUR}}` | \"€ 29,95\" |\n| `date:FORMAT` | `{{created_at \\| date:d-m-Y}}` | Formatted date |\n| `date:relative` | `{{created_at \\| date:relative}}` | \"2 dagen geleden\" |\n| `default:VALUE` | `{{bio \\| default:No bio}}` | Fallback if empty |\n| `striptags` | `{{html \\| striptags}}` | Strip HTML tags |\n| `nl2br` | `{{text \\| nl2br}}` | Newlines to `<br>` tags |\n| `slug` | `{{title \\| slug}}` | URL-safe slug |\n| `urlencode` | `{{query \\| urlencode}}` | URL-encode value |\n| `md5` | `{{email \\| md5}}` | MD5 hash (useful for Gravatar URLs) |\n| `json_pretty` | `{{data \\| json_pretty}}` | Pretty-print JSON (debugging) |\n| `count` / `length` | `{{items \\| count}}` | Array/string length |\n\nFilters can be chained: `{{price | multiply:0.01 | number:2}}`\n\n#### Empty State\n\n```html\n<!--#wps-mapi entity=\"products\" filter=\"category:sale\" -->\n<div class=\"product\">{{name}} — {{price}}</div>\n<!--#wps-mapi-empty -->\n<p>No products on sale right now.</p>\n<!--#/wps-mapi -->\n```\n\n#### Single Record Mode\n\nRender one specific record by ID or field match:\n\n```html\n<!--#wps-mapi entity=\"products\" record=\"42\" match=\"id\" -->\n<h1>{{name}}</h1>\n<p>{{description}}</p>\n<!--#/wps-mapi -->\n```\n\n**URL-based slug matching** (live — via `_template.html` wildcard routing):\n```html\n<!--#wps-mapi entity=\"products\" record=\":slug\" match=\"slug\" -->\n<h1>{{name}}</h1>\n<p>{{{description}}}</p>\n<!--#wps-mapi-empty -->\n<p>Product not found.</p>\n<!--#/wps-mapi -->\n```\nWhen a visitor opens `/products/wireless-headphones`, the Optimizer serves\n`/products/_template.html` and resolves `:slug` to `wireless-headphones` for the\nMAPI lookup. A non-existent slug falls into the `-empty` branch (serve a 404\nstatus or a redirect there). `record=\":slug\"` always takes the **last URL\nsegment** as the match value.\n\n#### Dynamic Routed Pages (detail + related list)\n\nA routed page can match a **parent record** (a category, a branch, a \"zebra\"…)\nand show a **related list** next to it that is filtered server-side on that\nparent. Fully indexable, no client-side JS required. One page template, two\nbranches driven by routing (`source`, `entity`, `match=\"slug\"` on the page):\n\n- **match branch** (slug found) → the matched parent + its filtered list\n- **empty branch** (no/unknown slug) → the overview (all items)\n\n```html\n<!--#wps-mapi entity=\"categories\" source=\"catalog\" record=\":slug\" match=\"slug\" -->\n\n <!-- MATCH BRANCH: parent is top-level → {{name}}, {{slug}}, {{id}} -->\n <h1>{{name}}</h1>\n <div class=\"product-grid\">\n <!--#wps-mapi entity=\"products\" source=\"catalog\"\n filter=\"status:active;category_slug:$route.slug\" sort=\"name:asc\" limit=\"500\" -->\n <a class=\"product-card\" href=\"/product/{{slug}}\">{{name}}</a>\n <!--#wps-mapi-empty -->\n <p>No products in this category.</p>\n <!--#/wps-mapi -->\n </div>\n\n<!--#wps-mapi-empty -->\n\n <!-- EMPTY BRANCH: the overview -->\n <h1>Our products</h1>\n <div class=\"product-grid\">\n <!--#wps-mapi entity=\"products\" source=\"catalog\"\n filter=\"status:active\" sort=\"name:asc\" limit=\"500\" -->\n <a class=\"product-card\" href=\"/product/{{slug}}\">{{name}}</a>\n <!--#/wps-mapi -->\n </div>\n\n<!--#/wps-mapi -->\n```\n\nWith `record=\":slug\"` the last URL segment is the match value:\n`/products` → slug `products` (not found → empty branch = overview),\n`/products/kliklijsten` → slug `kliklijsten` (match branch).\n\n#### Dynamic Filter Tokens\n\nThe nested list can inject a value from the **parent / the URL** into its\n`filter` via a server-side token. Tokens are resolved before the filter is\nparsed — outside the template engine — so they are safe to use in `filter`\nattributes (`{{...}}` braces are **not**, see below).\n\n| Token | Resolves to | Notes |\n|---|---|---|\n| `$route.slug` | last URL segment | request-stable — **preferred** |\n| `$route.N` | N-th URL segment (0-based) | request-stable |\n| `$parent.<field>` | field from the router-matched record | generic, but **currently unreliable** inside a nested loop (the matched record may be `null` while the inner loop runs) — prefer `$route.slug` |\n\n```html\nfilter=\"status:active;category_slug:$route.slug\"\n```\n\nUnresolvable tokens fall back to `''` → the row simply doesn't match (safe\nfailure mode). No `$` token present → no-op.\n\n**Hard rules (why no Handlebars in a filter):**\n\n- **Never put `{{...}}` in a `filter` attribute.** `{{id}}` resolves empty in\n the nested scope; `{{../id}}` and `{{#if}}` get processed by the engine and\n corrupt the SSR comment delimiters → the empty branch leaks into the output.\n Use `$route.` / `$parent.` tokens instead of braces.\n- **Never wrap an SSR loop in `{{#if}}`.** Same reason. Use the native\n match/empty branch split shown above.\n\n**Prerequisite — filterable field must be top-level.** Filters only match\ntop-level keys. To filter products on category slug, the catalog normalizer must\nexpose `category_slug` top-level on each product (it does for `source=catalog`).\nA field that only exists nested (e.g. `category.slug`) is not filterable.\n\n#### Per-Record SEO — `<!--#wps-seo -->` (routed detail pages)\n\nOn a **routed detail page** (`_template.html` / `record=\":slug\"`), every record would\notherwise share the same page-level `<title>` and description — a go-live SEO blocker\n(duplicate titles). The `<!--#wps-seo -->` tag injects per-record SEO **server-side**\ninto the `<head>`: `<title>`, meta description, Open Graph, and JSON-LD — before any\ncrawler sees the page, no JavaScript involved. One tag per page. Works for both\n`source=\"catalog\"` and plain MAPI entities (no `source` attribute).\n\n```html\n<!--#wps-seo source=\"catalog\" entity=\"products\" record=\":slug\" match=\"slug\"\n title=\"{{name}} — Site Name\"\n description=\"{{short_description | striptags | truncate:160}}\" -->\n <meta property=\"og:type\" content=\"product\">\n <meta property=\"og:title\" content=\"{{name}} — Site Name\">\n <meta property=\"og:description\" content=\"{{short_description | striptags | truncate:160}}\">\n {{#each images}}{{#if @first}}<meta property=\"og:image\" content=\"{{this}}\">{{/if}}{{/each}}\n <script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"Product\",\"name\":\"{{name}}\",\"sku\":\"{{sku}}\"{{#each images}}{{#if @first}},\"image\":\"{{this}}\"{{/if}}{{/each}}}</script>\n<!--#/wps-seo -->\n```\n\n**Attributes** (on the open tag): `source` / `entity` / `match` work as in `wps-mapi`\n(the tag is self-describing — it does not inherit route context). `record` accepts\n`:slug` (last URL segment), `:N` (N-th segment), or a literal value.\n`title` and `description` go **as attributes** and may contain template tokens.\n\n**Critical rule — title/description are ATTRIBUTES, never elements.** Do not put a\n`<title>` or description `<meta>` element inside the block: the platform strips the\nfirst `<title>` unconditionally during SEO processing, so an inline element gets\nclobbered before your override runs. Attributes for title/description; all other\nhead-HTML (OG, JSON-LD) goes in the block body.\n\n**Behavior:**\n- No `<!--#wps-seo` tag on the page → output is byte-identical (safe everywhere)\n- Strips the page-level duplicates it overrides (title, description, og:title,\n og:description) and places the per-record versions authoritatively in `<head>`\n- Record not found → the block disappears; the page falls back to page-level SEO\n\n**Caveats:**\n- The template engine HTML-escapes `{{ }}` — JSON-LD string values containing `&` or\n `\"` come out entity-encoded (valid JSON, cosmetically off). Keep JSON-LD fields to\n safe data: name, sku, image URL.\n- Price/`offers` in JSON-LD is deliberately **not** included by default — whether\n prices appear in Google (incl./excl. VAT, variable pricing) is the site owner's call.\n- Test SSR on the `*.websitepublisher.ai` **preview domain** — an uninitialized\n placeholder page on a custom domain may serve fallback content instead of the\n SSR pipeline.\n\n#### SSR Wrapper Attributes\n\nThe SSR injector adds `data-mapi-ssr` attributes to rendered blocks:\n\n```html\n<div data-mapi-ssr=\"products\" data-mapi-count=\"12\">\n <!-- rendered product cards -->\n</div>\n```\n\nJavaScript can use these to enhance SSR-rendered content (e.g. add client-side\nsearch/filter on top of the server-rendered list). SSR and JS coexist naturally.\n\n**CSS gotcha (always needed for an SSR list inside grid/flex).** Each SSR loop\nrenders inside its `<div data-mapi-ssr=\"…\">` wrapper. A grid/flex container\naround the loop then has only **one** child → one column. Fix:\n\n```css\n[data-mapi-ssr] { display: contents; }\n```\n\n#### Complete Examples\n\n**Product grid (webshop):**\n```html\n<!--#wps-mapi entity=\"products\" sort=\"name:asc\" filter=\"active:1\" -->\n<div class=\"product-card {{#if featured}}featured{{/if}}\">\n <a href=\"/products/{{slug}}\">\n <img src=\"{{image | default:https://placehold.co/400x300}}\" alt=\"{{name}}\">\n <h3>{{name}}</h3>\n <p>{{description | truncate:100}}</p>\n {{#if sale_price}}\n <span class=\"original\">{{price | multiply:0.01 | currency:EUR}}</span>\n <span class=\"sale\">{{sale_price | multiply:0.01 | currency:EUR}}</span>\n {{#else}}\n <span class=\"price\">{{price | multiply:0.01 | currency:EUR}}</span>\n {{/if}}\n </a>\n</div>\n<!--#/wps-mapi -->\n```\n\n**Blog post list:**\n```html\n<!--#wps-mapi entity=\"posts\" sort=\"published_at:desc\" limit=\"10\" filter=\"status:published\" -->\n<article>\n <time>{{published_at | date:d M Y}}</time>\n <h2><a href=\"/blog/{{slug}}\">{{title}}</a></h2>\n <p>{{content | striptags | truncate:200}}</p>\n {{#if author}}<span>By {{author}}</span>{{/if}}\n</article>\n<!--#/wps-mapi -->\n```\n\n**Team page:**\n```html\n<!--#wps-mapi entity=\"team\" sort=\"sort_order:asc\" -->\n<div class=\"team-member\">\n <img src=\"{{photo | default:https://placehold.co/300x300}}\" alt=\"{{name}}\">\n <h3>{{name}}</h3>\n <p class=\"role\">{{role}}</p>\n {{#if bio}}<p>{{bio | truncate:200}}</p>{{/if}}\n</div>\n<!--#/wps-mapi -->\n```\n\n### When to use SSR vs JavaScript vs Static HTML\n\n| Scenario | Use | Why |\n|---|---|---|\n| Product catalog (10+ items, public) | **SSR** | SEO, owner adds products via admin |\n| Blog, portfolio grid, FAQ (growing) | **SSR** | SEO, content changes independently |\n| 3 services on about page | **Static HTML** | Too few items, rarely changes |\n| 4 team members, small company | **Static HTML** | AI updates faster than building MAPI+SSR |\n| Client-side search/filter | **JS** | User interaction required |\n| Live price updates, stock status | **JS** | Real-time data needed |\n| Shopping cart, wishlist | **JS** | User-specific state |\n| Product list WITH search bar | **SSR + JS** | SSR for initial load + SEO, JS for interaction |\n| Admin dashboard tables | **JS only** | No SEO needed, always behind login |\n\n**Decision flow:**\n1. Will Google need to index this content? → Consider SSR\n2. Will the content grow beyond 10 items? → Consider MAPI entity\n3. Does the owner need to update without AI? → MAPI + admin panel\n4. Is it ≤5 fixed items on one page? → **Static HTML is fine**\n5. Does the user interact with it? → Add JS (on top of SSR if SEO matters)\n\nSSR and JS can coexist — use SSR for the initial server-rendered content and JS\nfor interactive enhancement on top.\n\n### Data Access Control — `public_read` vs `policy_json`\n\nTwo different switches control who can touch entity data. Confusing them creates\nreal data leaks — this exact mistake has exposed full customer databases in the wild.\n\n**`public_read` is a VISIBILITY flag, not a security control.**\nSetting `public_read: true` only enables anonymous read via\n`/mapi/public/{projectId}/{entity}`. It does **not** protect the entity and does\n**not** restrict writes. It has exactly one job: making public-site content\n(menus, team, services, blog posts) readable without auth.\n\n**`policy_json` is the access control.** An entity is access-controlled **only if\nit carries an explicit `policy_json`** (set via `entities(operation: \"update\",\nentity_name: ..., policy_json: {...})`). The policy defines per-action rules and\nan `owner_field` for row-level scoping.\n\nRules that follow from this:\n\n- **Sensitive data (customers, orders, loyalty accounts, anything with PII or\n money) must NEVER rely on `public_read` for protection.** Give those entities a\n `policy_json`, or keep `public_read: false` and access them only via owner-level\n calls or a dedicated integration.\n- **Visitor-scoped entities** (each logged-in visitor sees/edits only their OWN\n rows) need two things: a `policy_json` on the entity **and** a real owner column\n (e.g. `owner_email`) that exists as an actual entity property. The policy's\n `owner_field` must map to a real column — the engine fails closed otherwise.\n- **A denied write surfaces as HTTP `404 Not found` — not `403`.** This is by\n design (no existence leak). If a legitimate-looking write returns 404, check the\n caller identity and the entity policy first, not the record.\n- **Admin panels need no extra wiring.** The platform data-grid and `wsa_` admin\n sessions run with owner authority over the site — admin CRUD works on\n policy-protected entities automatically.\n- Get the exact policy shape from `get_skill(skill_name: \"dev\")` before setting\n `policy_json` on an entity with real user data — the server validates the JSON\n is well-formed, not that your rules are semantically correct.\n- Access control is currently **opt-in** (only entities with an explicit\n `policy_json` are enforced). Strict mode is the platform's end state — design\n entities with explicit policies **now** so nothing breaks later.\n\n**Visitor \"My Account\" / \"My Orders\" pages — supported pattern:**\nThe e-commerce order endpoints `list-orders` and `get-order` are callable from a\n**verified SAPI visitor session**. The server scopes results to the session email\nautomatically: a visitor sees only their own orders, a client-supplied\n`customer_email` filter is ignored, and a cross-customer `get-order` returns 404.\nBuild the page with the SAPI client (visitor auth section below) and call these\nendpoints from the visitor session — no admin token, no custom filtering, no\nworkarounds. All other order endpoints (`create-order`, `update-status`,\n`get-order-by-payment`, line-meta) remain owner-only.\n\n---\n\n## Contact Forms (SAPI) — Critical Pattern\n\n**Always follow this exact pattern.** Deviating from it will cause \"no valid session\" errors,\nespecially on Safari and custom domains where third-party cookies are blocked.\n\n### Step 1 — Configure the form (server-side, via MCP tool)\n\n```\nconfigure_form(\n project_id: 12345,\n form_name: \"contact\",\n required_fields: [\"name\", \"email\", \"message\"],\n action: {\n type: \"iapi\",\n service: \"resend\",\n endpoint: \"send-email\",\n input_template: {\n from: \"noreply@websitepublisher.ai\",\n to: \"owner@example.com\",\n subject: \"New contact from {{fields.name}}\",\n html: \"<p>From: {{fields.name}} ({{fields.email}})</p><p>{{fields.message}}</p>\"\n }\n },\n max_submits_per_session: 5\n)\n```\n\n### Step 2 — Add the CDN script + form handler to the page\n\n**Always use the CDN library.** Do not write inline session management code.\nThe library handles sessions, CSRF tokens, stale session recovery, and all headers automatically.\n\n> ⚠️ **`WP.sapi()` is for visitor-facing forms only** — contact forms, file uploads,\n> member-area magic links. **Do not use it for admin authentication.** Admin login and\n> admin-only IAPI calls use direct `fetch()` to `/iapi/project/{id}/admin-auth/...` with\n> `Authorization: Bearer` headers — see the **Admin-Protected Pages** section below.\n\n```html\n<script src=\"https://cdn.websitepublisher.ai/js/sapi-client.js\"></script>\n<script>\n var sapi = WP.sapi(PROJECT_ID);\n\n document.getElementById('my-form').addEventListener('submit', function(e) {\n e.preventDefault();\n var btn = this.querySelector('button[type=\"submit\"]');\n btn.disabled = true;\n btn.textContent = 'Sending...';\n\n sapi.submitForm('contact', {\n name: document.getElementById('name').value.trim(),\n email: document.getElementById('email').value.trim(),\n message: document.getElementById('message').value.trim(),\n website: '', // honeypot: leave empty, bots fill this in\n }).then(function(r) {\n if (r.ok) {\n window.location.href = '/thank-you';\n } else {\n btn.disabled = false;\n btn.textContent = 'Send';\n alert(r.data.error && r.data.error.message || 'Something went wrong.');\n }\n });\n });\n</script>\n```\n\n### What the CDN library handles for you\n\n| Feature | How |\n|---|---|\n| Session creation + resume | `WP.sapi(PROJECT_ID)` pre-warms on init |\n| CSRF token management | Sent via `X-CSRF-Token` header + `_csrf` body (dual) |\n| Session ID header | `X-Session-Id` header on every request |\n| Stale session recovery | 401 response -> auto-clear -> fresh session -> retry (max 1) |\n| Per-project storage keys | `wp_{projectId}_sid` -- no cross-site conflicts |\n| Safari ITP compatibility | Uses sessionStorage (first-party, never blocked) |\n| Auth state preservation | After successful POST, only CSRF is cleared -- session ID survives |\n\n### Key rules -- never forget these:\n\n| Rule | Why |\n|---|---|\n| Always include `website: ''` in the fields object | Honeypot field -- bots fill it in, humans leave it empty. Server silently drops the submission if non-empty |\n| Never pre-fill the honeypot field | An empty string is required -- any value triggers bot detection |\n| Replace `PROJECT_ID` with the actual numeric project ID | The library uses this to scope sessions and build API URLs |\n\n### Forms with File Upload\n\nForms can accept image uploads from visitors via the SAPI upload endpoint.\nUploads are stored as project assets on the CDN -- no bearer token needed.\n\n> **Building an admin panel with image upload?** See \"Image Upload in Admin Panels\"\n> under the Admin-Protected Pages section — it shows how to combine admin auth\n> with SAPI upload on the same page.\n\n**Flow:** upload file(s) first -> collect CDN URLs -> include in form submit fields.\n\n```javascript\n// Upload a file using the CDN library\nvar sapi = WP.sapi(PROJECT_ID);\n\nasync function uploadFile(file) {\n // getSession() handles caching + resume automatically\n var session = await sapi.getSession();\n\n var form = new FormData();\n form.append('file', file);\n form.append('_csrf', session.csrf_token);\n form.append('form_name', 'intake');\n\n var res = await fetch(\n 'https://api.websitepublisher.ai/sapi/project/' + PROJECT_ID + '/form/upload',\n {\n method: 'POST',\n headers: { 'X-Session-Id': session.session_id },\n body: form // no Content-Type header -- browser sets multipart boundary\n }\n );\n\n var data = await res.json();\n if (data.success) {\n // Clear stored CSRF -- the library will fetch a fresh one on next call\n sapi.clearSession();\n return data.data.asset_url; // CDN URL ready for use\n }\n throw new Error(data.error && data.error.message || 'Upload failed');\n}\n```\n\n**Upload rules:**\n\n| Rule | Value |\n|---|---|\n| Allowed types | JPEG, PNG, WebP only |\n| Max file size | 5 MB per file |\n| Max per session | 10 uploads |\n| CSRF | Single-use -- library handles refresh automatically for submitForm(), manual clear needed after raw fetch upload |\n| Response includes | `asset_url`, `filename`, `mime_type`, `size`, `width`, `height`, `uploads_remaining` |\n\n**Include uploaded URLs in form submit:**\n```javascript\n// After uploading, pass CDN URLs as regular form fields\nsapi.submitForm('intake', {\n name: '...',\n email: '...',\n image_url_1: uploadedUrl1, // CDN URL from upload response\n image_url_2: uploadedUrl2,\n website: '', // honeypot\n});\n```\n\n---\n\n## Step 4 — Go Live Checklist\n\nBefore handing over to the user, verify:\n\n- [ ] Homepage has `landingpage: true` (or was created first)\n- [ ] All pages that should be findable have `seo_robots_index: true`\n- [ ] All pages have `seo_title` and `seo_description`\n- [ ] All `<!-- Optimizer - ... -->` comment tags are present in every page\n- [ ] Multi-page sites use **fragments** for header and footer (not copy-pasted HTML)\n- [ ] Repeating content uses **MAPI entities** (not hardcoded static HTML)\n- [ ] Contact form (if any) uses the CDN library (`sapi-client.js`) — no inline session code\n- [ ] Thank-you page exists if form redirects after submit\n- [ ] Terms / privacy page exists if form collects personal data\n- [ ] Design uses distinctive typography and cohesive color palette (not generic AI defaults)\n- [ ] Design context saved via `execute_integration(service: \"site_context\")` for future consistency\n- [ ] Website URL shared with user: `https://{subdomain}.websitepublisher.ai`\n- [ ] Contact form includes `website: ''` honeypot field in the fields object\n- [ ] Visual Editor session offered for image replacement and final tweaks\n\n### Mandatory Security Review (before going live)\n\nA site must **not** be presented as live until this review passes. Run it as the\nfinal gate — never skip it, even for a quick demo that the user intends to keep.\n\n- [ ] **No secrets in client code.** No API keys, tokens, or passwords in page HTML,\n inline JS, or assets. All credentials use `{{vault:...}}` references —\n resolved server-side, never delivered to the browser.\n- [ ] **Admin pages are auth-guarded.** Every admin/dashboard page enforces the\n IAPI Admin Auth guard server-side. No admin-only data or actions reachable\n without a valid `wsa_` session. No client-side-only \"hidden\" protection.\n- [ ] **Entity exposure is intentional.** `public_read` is enabled only on entities\n meant to be public. No personal data, leads, orders, or admin records exposed\n via public MAPI endpoints. Remember: `public_read` is a visibility flag, not\n protection — sensitive entities carry a `policy_json` or stay\n `public_read: false` (see **Data Access Control**).\n- [ ] **Money math is server-side.** Checkout totals, discounts, tier/volume pricing,\n and loyalty points are computed and validated **by the platform integrations** —\n never trusted from client-side JS, `localStorage`, or hidden form fields. A\n visitor must not be able to change what they pay or what they earn by editing\n the page. (Real exploits found pre-go-live: client-computed order totals and\n client-written loyalty points.)\n- [ ] **Admin is protected server-side.** No `showAdmin()`-style JS toggles, hidden\n DOM, or devtools-bypassable checks as the only barrier — every admin page and\n admin data call enforces the `wsa_` auth guard server-side.\n- [ ] **No sensitive files on the public CDN.** Exports, snapshots, or data files\n containing customer/order data are served through an authenticated route\n (admin auth / asset proxy), never as a world-readable CDN asset.\n- [ ] **Form input is validated.** Required fields set, honeypot present, file\n uploads (if any) restricted to expected types/sizes via SAPI upload.\n- [ ] **No customer-supplied HTML rendered unescaped.** Visitor/lead/form data shown\n back on a page is escaped — no raw injection into the DOM.\n- [ ] **Legal pages present where required.** Terms / privacy page exists whenever the\n site collects personal data (forms, auth, leads).\n\nIf any item fails, fix it before declaring the site live. Log the outcome of this\nreview in TAPI (`add_task_history`) so the security gate is traceable per project.\n\n---\n\n## Platform Knowledge\n\n### What WebsitePublisher handles automatically\n\n| Feature | How it works |\n|---|---|\n| **Sitemap** | Auto-generated. Pages appear when `seo_robots_index: true` |\n| **robots.txt** | Auto-generated with \"Allow all\" + sitemap reference |\n| **SSL certificate** | Auto-provisioned via Let's Encrypt on custom domains |\n| **Canonical tags** | Injected by Optimizer when comment tag is present |\n| **Open Graph** | Injected by Optimizer (uses SEO title/description) |\n| **Static caching** | Pages are served as static files — extremely fast |\n| **CDN** | Assets served via cdn.websitepublisher.ai |\n\n### What requires API calls\n\n| Feature | API |\n|---|---|\n| Pages and content | PAPI |\n| Reusable components (header, footer) | PAPI Fragments |\n| Dynamic data / entities | MAPI |\n| Contact forms | SAPI |\n| Third-party integrations | IAPI + VAPI |\n| Visual editing (browser) | WPE |\n| Clone a website | WAPI clone endpoint |\n\n---\n\n## Built-in Integrations — Composable Building Blocks\n\nWebsitePublisher's integrations are not a feature list — they are composable building\nblocks. Every integration speaks the same interface\n(`execute_integration(service, endpoint, input)`), authenticates the same way (the Vault),\nand is callable from any AI on any platform via MCP. This means the AI doesn't *build* a\npayment flow, an email pipeline, or a lead system — it *assembles* them from pieces that\nare already wired, secured, and maintained. Generating code gives you a draft; snapping\nintegrations together gives you a working system.\n\n### Don't reinvent the wheel\n\nWebsitePublisher includes pre-built integrations for common website needs.\nYou do not need to build email sending, payment processing, or SMS from scratch.\nEach integration is a single tool call — credentials are stored securely in the Vault,\nthe platform handles authentication, rate limiting, and error handling.\n\n### Discover what's available — list it, don't guess\n\nYou never have to guess which integrations or endpoints exist. Two tools read the\n**live manifest** for the current project — they are the source of truth, more current\nthan this document:\n\n- `list_integrations(project_id)` — every integration, split into **configured** (vault\n secrets present, ready to call now) and **available** (needs setup), each with its full\n endpoint list and descriptions. A typical project already has dozens wired\n (asset upload, exports, payments, email, shipping, imports, analytics, and more).\n- `get_integration_schema(project_id, service)` — the exact input fields (name, required,\n type, limits) for every endpoint of one integration. Call this before\n `execute_integration` so you send the correct body the first time.\n\nIf a task seems to need a capability you have no tool for, run `list_integrations` **first**.\nThe endpoint almost always already exists. Inventing an HTTP route, guessing a hostname,\nor asking the user to build an endpoint is the wrong move — the manifest already tells you\nwhat is there and how to call it.\n\n### Available Integrations\n\n| Service | Category | What it does | Tool call |\n|---|---|---|---|\n| **Resend** | Email | Transactional emails (contact forms, notifications) | `execute_integration(service: \"resend\", endpoint: \"send-email\")` |\n| **Mailgun** | Email | Domain-level email sending | `execute_integration(service: \"mailgun\", endpoint: \"send-email\")` |\n| **SendGrid** | Email | High-volume email delivery | `execute_integration(service: \"sendgrid\", endpoint: \"send-email\")` |\n| **Stripe** | Payments | Checkout sessions, payment processing | `execute_integration(service: \"stripe\", endpoint: \"create-checkout-session\")` |\n| **Mollie** | Payments | European payments (iDEAL, Bancontact, cards) | `execute_integration(service: \"mollie\", endpoint: \"create-payment\")` |\n| **Twilio** | SMS | Text messages (confirmations, alerts) | `execute_integration(service: \"twilio\", endpoint: \"send-sms\")` |\n| **Lead Capture** | Built-in | Store form submissions as leads | Form action `{\"type\": \"leads\"}` |\n| **Admin Auth** | Built-in | Password-protected admin areas (email/password login, Bearer token auth) | `execute_integration(service: \"admin_auth\", endpoint: \"login\")` |\n| **Auth Keys** | Built-in | Request project API keys stored in vault (human-approved) | `execute_integration(service: \"auth_keys\", endpoint: \"request-key\")` |\n| **Asset Proxy** | Built-in | Upload/delete assets from browser admin panels (no WPA key needed) | `execute_integration(service: \"asset-proxy\", endpoint: \"upload\")` |\n| **Site Context** | Built-in | Store design tokens (colors, fonts, style, locale) across sessions | `execute_integration(service: \"site_context\", endpoint: \"set-context\")` |\n| **Product Catalog** | Built-in | Products, variants, categories, bulk import | `execute_integration(service: \"product-catalog\", endpoint: \"list-products\")` |\n| **Request Tracer** | Built-in | Debug API + page requests in real-time | `execute_integration(service: \"tracer\", endpoint: \"start\")` |\n| **Capability Requests** | Built-in | Report a genuine platform gap (LAST RESORT — see \"You Are the Builder\") | `execute_integration(service: \"capability_requests\", endpoint: \"submit-request\")` |\n\n### How integrations work\n\n1. **Setup** — Store the API key: `setup_integration(service: \"resend\", secrets: {\"resend_api_key\": \"re_...\"})`\n2. **Use** — Call the integration: `execute_integration(service: \"resend\", endpoint: \"send-email\", input: {...})`\n3. **Done** — The platform resolves credentials, validates input, proxies the request, returns the result\n\nAPI keys are **never exposed** to the AI or the browser. The Vault encrypts them at rest\nand the integration proxy resolves them server-side at execution time.\n\n### Vault References — `{{vault:...}}`\n\n> Written with `...` as placeholder throughout this document: examples containing a\n> literal key-shaped reference are redacted by the platform's secret filter when this\n> skill is delivered via `get_skill`. In real templates and integration inputs, write\n> the actual key name — no spaces, no dots: two opening braces, `vault:your_key_name`,\n> two closing braces.\n\nThe IAPI proxy resolves `{{vault:...}}` references (two opening braces, then `vault:` + your key name, then two closing braces — no spaces) server-side before making API calls.\nThis is the core security mechanism that keeps secrets out of AI conversations and browser code.\n\n**Where vault references work (server-side only):**\n\n| Context | Works? | Example |\n|---|---|---|\n| `execute_integration` input | ✅ | `\"api_key\": \"{{vault:...}}\"` (e.g. key `stripe_key`) |\n| Scheduled tasks (AAPI) | ✅ | Vault refs in task payload resolved at execution |\n| IAPI proxy calls | ✅ | Bearer token from vault |\n| Browser JavaScript | ❌ | Browser cannot access vault — use admin auth (`wsa_`) instead |\n| Page HTML source | ❌ | Would expose secrets to anyone viewing source |\n| MCP tool responses | ❌ | VaultSanitizer strips any leaked vault values |\n\n**Critical rule:** Never put vault keys in browser-facing code. If a browser page needs\nto call an authenticated API, use the **admin auth pattern** (`wsa_` token) for data\noperations and **SAPI upload** for file uploads. The vault exists for server-side\nintegrations only.\n\n### When to use integrations\n\n| User wants... | Use this |\n|---|---|\n| Contact form that sends email | SAPI form + Resend integration |\n| Accept payments on website | Stripe or Mollie integration |\n| SMS confirmation after booking | Twilio integration |\n| Store leads from multiple forms | Built-in Lead Capture |\n| Password-protected admin dashboard | Admin Auth (IAPI admin session) |\n| Member area with magic link / code login | SAPI Visitor Auth |\n| Remember design choices across sessions | Site Context integration |\n| Import 50-500 products at once | `bulk-upsert-products` (Product Catalog) |\n| Upload images from admin panel (browser) | **Asset Proxy** (PAPI assets) or **SAPI upload** (form uploads) |\n| Request a project API key securely | Auth Keys (human-approved, vault-stored) |\n| Debug failing requests or slow pages | Request Tracer |\n\n**Always check if an integration exists before building custom solutions.**\nThe built-in integrations handle authentication, error handling, rate limiting,\nand security — reimplementing these is unnecessary and error-prone.\n\n### Bulk Product Import\n\nFor large catalogs, use `bulk-upsert-products` instead of looping `create-product`:\n\n```\nexecute_integration(\n service: \"product-catalog\",\n endpoint: \"bulk-upsert-products\",\n input: {\n \"items\": [\n {\"sku\": \"TSH-001\", \"name\": \"Classic Tee\", \"price_cents\": 2999, \"status\": \"active\"},\n {\"sku\": \"TSH-002\", \"name\": \"V-Neck Tee\", \"price_cents\": 3499, \"status\": \"active\"},\n {\"sku\": \"TSH-001\", \"price_cents\": 2799}\n ]\n }\n)\n```\n\nEach item is matched by SKU: existing → update, new → create (needs `name` + `price_cents`).\nMax 500 items per call. Response includes per-item status and `summary.by_error_type`.\n\n**Always check `result.failed` and `result.summary.by_error_type`** — `success: true`\nmeans the call itself worked, not that every item succeeded.\n\n### Debugging with Request Tracer\n\nWhen something isn't working — a page returns wrong data, an integration fails,\nor performance is slow — use the Request Tracer to see exactly what happened:\n\n1. **Start a trace session:**\n ```\n execute_integration(\n service: \"tracer\",\n endpoint: \"start\",\n input: { \"ttl\": 120, \"include_optimizer\": true }\n )\n → returns hash (e.g., \"tr_abc12345\")\n ```\n\n2. **Perform the operation that's failing** — create a page, submit a form, call an integration\n\n3. **Read the trace:**\n ```\n execute_integration(\n service: \"tracer\",\n endpoint: \"logs\",\n input: { \"hash\": \"tr_abc12345\" }\n )\n ```\n\nThe trace shows every API request and page render with HTTP method, path, status\ncode, duration, SQL query summary, and which server handled the request.\n\nIntegration failures include typed error data (`error_type`, `error_code`, and\n`error_field` / `recovery` when applicable) so you can see exactly what went\nwrong without guessing.\n\n**When to use the tracer:**\n- Page renders wrong content → trace optimizer request, check SQL queries\n- Integration call fails → trace API request, check `error_type` and `recovery`\n- Request is slow → check `duration_ms` and `db.total_ms` breakdown\n- \"It works sometimes\" → `server` field shows which node handled each request\n\n**Options:**\n- `include_optimizer: true` — also trace public page renders (default: off)\n- `include_sql: false` — skip SQL summary (default: on)\n- `ttl: 10-300` — session duration in seconds (default: 60)\n\n---\n\n## Admin-Protected Pages — IAPI Admin Auth\n\n> **⚠️ Need to upload images from an admin panel?** Do NOT use `upload_asset`,\n> vault keys, or MAPI asset routes from the browser. Use **Asset Proxy**\n> (`/iapi/project/{id}/asset-proxy/upload` with your `wsa_` admin token) — it's\n> the simplest option. See \"Image Upload in Admin Panels\" below.\n\nWhen building dashboards, admin panels, or any page that requires a logged-in admin\n(not a public visitor), use the IAPI Admin Auth pattern. This is separate from\nSAPI Visitor Auth — they serve different purposes.\n\n| Feature | Admin Auth (IAPI) | Visitor Auth (SAPI) |\n|---|---|---|\n| **Use case** | Admin dashboards, CMS, internal tools | Member areas, gated content, loyalty portals |\n| **Login method** | Email + password | Magic link or verification code |\n| **Token storage** | `sessionStorage.admin_token` | Managed by sapi-client.js internally |\n| **API calls** | Direct `fetch()` to `/iapi/project/{id}/...` with `Authorization: Bearer` | `WP.sapi(id).call(...)` via CDN library |\n| **Token prefix** | `wsa_` (server-side) | Session ID (no token exposed to page) |\n\n### Admin Login\n\n```javascript\nconst PROJECT_ID = 12345; // replace with actual project ID\n\nasync function login(email, password) {\n const r = await fetch(`/iapi/project/${PROJECT_ID}/admin-auth/login`, {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({email, password})\n });\n const data = await r.json();\n if (data.success && data.token) {\n sessionStorage.setItem('admin_token', data.token);\n localStorage.setItem('admin_token', data.token);\n document.cookie = `admin_token=${data.token}; path=/; max-age=28800; SameSite=Lax`;\n }\n return data;\n}\n```\n\nTriple storage (sessionStorage + localStorage + cookie) ensures the token survives\npage navigations, tab reopens, and server-side middleware checks.\n\nAfter login, redirect to **`/`** if your dashboard page is set as `landingpage: true`.\nSee the note about `landingpage` under Page Metadata.\n\n### Admin-Only IAPI Calls\n\n```javascript\nasync function callAdmin(service, endpoint, payload) {\n const token = sessionStorage.getItem('admin_token');\n if (!token) { window.location.replace('/login'); return; }\n\n const r = await fetch(`/iapi/project/${PROJECT_ID}/${service}/${endpoint}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': 'Bearer ' + token\n },\n body: JSON.stringify(payload)\n });\n\n if (r.status === 401) {\n sessionStorage.removeItem('admin_token');\n localStorage.removeItem('admin_token');\n window.location.replace('/login');\n return;\n }\n return r.json();\n}\n\n// Usage:\nconst leads = await callAdmin('leads', 'get-leads', { page: 1, per_page: 25 });\nconst msg = await callAdmin('anthropic', 'create-message', { prompt: '...' });\n```\n\n**Important:** Use direct `fetch()` — not `WP.sapi().call()`. The SAPI client library\nis for visitor sessions. Admin calls use `Authorization: Bearer` headers on `/iapi/` routes.\n\n### Page Rendering — Auth Guard\n\n```html\n<body>\n<script>\n // Immediate redirect — no hidden body, no async check\n var token = sessionStorage.getItem('admin_token')\n || localStorage.getItem('admin_token');\n if (!token) window.location.replace('/login');\n</script>\n\n<!-- page content renders immediately for authenticated users -->\n<h1>Dashboard</h1>\n<!-- ... -->\n</body>\n```\n\n**Never do this:**\n```html\n<!-- ❌ FORBIDDEN — causes flash of invisible content, breaks on slow connections -->\n<body style=\"visibility:hidden\">\n<script>\n checkAuth().then(() => document.body.style.visibility = 'visible');\n</script>\n```\n\nThe correct pattern is: redirect immediately if no token, render normally if token exists.\nAuth validation happens on the first API call — if the token is expired, the 401 handler\nclears storage and redirects to login.\n\n### Logout\n\n```javascript\nfunction logout() {\n sessionStorage.removeItem('admin_token');\n localStorage.removeItem('admin_token');\n document.cookie = 'admin_token=; path=/; max-age=0';\n window.location.replace('/login');\n}\n```\n\n### Creating Admin Users\n\nAdmin users are created via the Admin Auth integration:\n\n```\nexecute_integration(\n project_id: 12345,\n service: \"admin_auth\",\n endpoint: \"create_user\",\n input: { email: \"admin@example.com\", password: \"securepassword\" }\n)\n```\n\nThe `create_user` endpoint accepts only `email` and `password` — no name field.\nThe email serves as the unique identifier and login credential.\n\n### Decision Tree — Which Auth System?\n\n```\nDoes the page need login?\n├── No → No auth needed (public page)\n└── Yes\n ├── Is the user an admin/owner managing content?\n │ └── Use Admin Auth (IAPI) — this section\n └── Is the user a visitor/member accessing gated content?\n └── Use Visitor Auth (SAPI) — see \"Contact Forms (SAPI)\" section\n```\n\n### Common Pitfalls — Why Admin Auth Has Its Own Pattern\n\nMultiple AI builds have walked into the same trap: trying to call admin endpoints\nvia the SAPI execute route (`WP.sapi().call('/execute/admin_auth/login', ...)`).\nThat path requires a visitor session bootstrap and CSRF tokens — machinery the SAPI\nlibrary wraps for visitor forms but that does not align with how admin auth issues\nand validates `wsa_` Bearer tokens.\n\n**The canonical admin auth path is always:**\n\n| Step | Call | Auth header |\n|------|------|------------|\n| 1. Login | `POST /iapi/project/{id}/admin-auth/login` | None — body has email + password |\n| 2. Store token | sessionStorage + localStorage + cookie | — |\n| 3. Authenticated calls | `POST /iapi/project/{id}/{service}/{endpoint}` | `Authorization: Bearer wsa_...` |\n| 4. Verify on page load | `POST /iapi/project/{id}/admin-auth/verify` | None — body has token |\n| 5. Logout | `POST /iapi/project/{id}/admin-auth/logout` | None — body has token |\n\n**Anti-patterns — never do these for admin auth:**\n\n- ❌ `WP.sapi().call('/execute/admin_auth/login', ...)` — that route is for visitor SAPI flows\n- ❌ Manual `GET /sapi/session` + `X-CSRF-Token` headers — admin auth doesn't use the SAPI session layer\n- ❌ Reading the token from `r.data.token` after a SAPI execute call — wrong envelope shape\n- ❌ `<body style=\"visibility:hidden\">` while running an async auth check — see Page Rendering above\n- ❌ URL with underscore for login/verify/logout: `/iapi/project/{id}/admin_auth/login` — those specific routes are `admin-auth` (hyphen)\n\nThe IAPI route is fully self-contained: no session, no CSRF, just `Authorization: Bearer`\non the request. If you find yourself adding session bootstrap or CSRF token logic to an\nadmin page, stop — you've taken the wrong turn.\n\n### Image Upload in Admin Panels\n\nAdmin panels often need image upload — for portfolio management, product photos, team\npictures, or any content the admin manages visually.\n\nThere are two approaches. **Asset Proxy** stores files in the PAPI asset system (visible\nin `list_assets`, manageable). **SAPI upload** stores files in the form uploads bucket.\nBoth return CDN URLs. Choose based on whether you need the files in the project's asset system.\n\n#### Option A — Asset Proxy (recommended for admin panels)\n\nUses the admin's existing `wsa_` token. No extra setup needed — no SAPI form, no CDN script.\nFiles go into the PAPI asset system.\n\n```javascript\nconst PROJECT_ID = 12345;\n\nfunction getAdminToken() {\n return sessionStorage.getItem('admin_token');\n}\n\n// Convert file to base64\nfunction fileToBase64(file) {\n return new Promise(function(resolve, reject) {\n var reader = new FileReader();\n reader.onload = function() { resolve(reader.result.split(',')[1]); };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\n\n// Upload via asset-proxy (uses admin token, no WPA key needed)\nasync function uploadImage(file, slug) {\n var base64 = await fileToBase64(file);\n\n var res = await fetch('/iapi/project/' + PROJECT_ID + '/asset-proxy/upload', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': 'Bearer ' + getAdminToken()\n },\n body: JSON.stringify({\n slug: slug, // e.g. \"images/product-42.jpg\"\n base64: base64,\n overwrite: true\n })\n });\n\n if (res.status === 401) { window.location.replace('/login'); return; }\n var data = await res.json();\n if (data.success) {\n return data.asset_url; // CDN URL: cdn.websitepublisher.ai/custom/wid.../images/...\n }\n throw new Error(data.message || 'Upload failed');\n}\n\n// Combined: upload image, then save product\ndocument.getElementById('product-form').addEventListener('submit', async function(e) {\n e.preventDefault();\n var file = document.getElementById('photo').files[0];\n var name = document.getElementById('name').value;\n var slug = 'images/product-' + Date.now() + '.' + file.name.split('.').pop();\n\n var imageUrl = file ? await uploadImage(file, slug) : null;\n await saveProduct(name, imageUrl); // IAPI call with wsa_ token (see Admin-Only IAPI Calls)\n});\n```\n\n#### Option B — SAPI Upload (alternative, requires form setup)\n\n```javascript\nconst PROJECT_ID = 12345;\nvar sapi = WP.sapi(PROJECT_ID); // SAPI session for uploads\n\n// Admin is logged in — wsa_ token in sessionStorage (see Admin Login above)\nfunction getAdminToken() {\n return sessionStorage.getItem('admin_token');\n}\n\n// Image upload — uses SAPI (no admin token needed)\nasync function uploadImage(file) {\n var session = await sapi.getSession();\n var form = new FormData();\n form.append('file', file);\n form.append('_csrf', session.csrf_token);\n form.append('form_name', 'admin_upload');\n\n var res = await fetch(\n 'https://api.websitepublisher.ai/sapi/project/' + PROJECT_ID + '/form/upload',\n {\n method: 'POST',\n headers: { 'X-Session-Id': session.session_id },\n body: form\n }\n );\n\n var data = await res.json();\n if (data.success) {\n sapi.clearSession(); // CSRF is single-use\n return data.data.asset_url; // CDN URL: cdn.websitepublisher.ai/custom/wid.../images/...\n }\n throw new Error(data.error?.message || 'Upload failed');\n}\n\n// Save data with image URL — uses admin auth (wsa_ token)\nasync function saveProduct(name, imageUrl) {\n var res = await fetch('/iapi/project/' + PROJECT_ID + '/product-catalog/create-product', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': 'Bearer ' + getAdminToken()\n },\n body: JSON.stringify({ name: name, image_url: imageUrl })\n });\n if (res.status === 401) { window.location.replace('/login'); return; }\n return res.json();\n}\n\n// Combined flow: upload image, then save record\ndocument.getElementById('product-form').addEventListener('submit', async function(e) {\n e.preventDefault();\n var file = document.getElementById('photo').files[0];\n var name = document.getElementById('name').value;\n\n var imageUrl = file ? await uploadImage(file) : null;\n await saveProduct(name, imageUrl);\n});\n```\n\n#### Setup Requirements\n\nFor image upload to work in an admin panel, you need:\n\n1. **A SAPI form configured** for the upload (even a minimal one):\n ```\n configure_form(\n project_id: 12345,\n form_name: \"admin_upload\",\n required_fields: [],\n action: { type: \"none\" },\n max_submits_per_session: 20\n )\n ```\n\n2. **The CDN script** on the page:\n ```html\n <script src=\"https://cdn.websitepublisher.ai/js/sapi-client.js\"></script>\n ```\n\n3. **Admin auth** already working (see Admin Login above)\n\n#### How the Auth Systems Coexist\n\n| Operation | Auth system | Token | Endpoint |\n|---|---|---|---|\n| Admin login | IAPI Admin Auth | `wsa_` | `/iapi/project/{id}/admin-auth/login` |\n| Read/write data | IAPI | `wsa_` Bearer | `/iapi/project/{id}/{service}/{endpoint}` |\n| Upload image (Option A) | IAPI Asset Proxy | `wsa_` Bearer | `/iapi/project/{id}/asset-proxy/upload` |\n| Upload image (Option B) | SAPI | Session + CSRF | `/sapi/project/{id}/form/upload` |\n| Result | — | — | CDN URL in `asset_url` response field |\n\nWith **Option A** (asset-proxy), everything uses the same `wsa_` admin token — simpler code,\nno second auth system needed.\n\nWith **Option B** (SAPI upload), the SAPI session lives separately in\n`sessionStorage.wp_{projectId}_sid` and never conflicts with the admin token.\n\n#### Common Mistake\n\nDo NOT try to upload images via `upload_asset` or MAPI asset routes from the browser.\nThose are MCP/API tools, not browser endpoints.\n\n**These approaches will NOT work for browser-based uploads:**\n\n- `POST /mapi/project/{id}/assets` with `wsa_` token → 401 (wsa_ not accepted for asset writes)\n- `/iapi/project/{id}/upload-asset` → 404 (does not exist)\n- A made-up top-level route like `/project/{id}/upload-asset` (missing the `{service}` segment) → 404 — asset writes are `asset_proxy/upload`; the IAPI route shape is always `/project/{id}/{service}/{endpoint}`\n- Vault keys (`{{vault:wpa_...}}`) in browser JavaScript → vault refs are server-side only\n- Custom API proxy with vault key → proxy passes the literal string, not the resolved value\n\n**Use Asset Proxy (Option A) or SAPI upload (Option B)** — both handle auth correctly\nand return CDN URLs. Asset Proxy is simpler because it uses the same `wsa_` token\nyou already have for data operations.\n\n**Asset Proxy is not images-only.** It accepts any `slug` and stores any bytes in the\nPAPI asset system. To write or refresh a JSON/CSV/text data file from an admin panel\n(e.g. regenerating a products snapshot at `data/products-snapshot.json`), base64-encode\nthe text and POST the same `{ slug, base64, overwrite: true }` body — no special\n\"snapshot\" or \"data\" route exists or is needed. Server-side (agent/MCP), use\n`upload_asset(content_text=…, overwrite=true)` instead.\n\n### Vault-Based API Keys (AI-Requested)\n\nWhen building admin panels or integrations that need server-side API access,\nthe AI can request a project key **without ever seeing the raw token**.\nThe project owner approves via email — the key goes directly into the vault.\n\n#### The Flow\n\n1. **AI requests a key:**\n ```\n execute_integration(\n project_id: 12345,\n service: \"auth_keys\",\n endpoint: \"request-key\",\n input: {\n vault_key_name: \"wpa_dashboard\",\n purpose: \"Leads dashboard — read and update leads\"\n }\n )\n ```\n Response: `{ status: \"pending_approval\", request_id: \"req_a1b2c3...\" }`\n\n2. **Project owner receives email** → clicks confirmation link → key is created\n\n3. **AI checks status** (optional, same session):\n ```\n execute_integration(\n project_id: 12345,\n service: \"auth_keys\",\n endpoint: \"check-status\",\n input: { request_id: \"req_a1b2c3...\" }\n )\n ```\n Response: `{ status: \"approved\" }` (or `\"pending\"` / `\"expired\"`)\n\n4. **AI uses the vault reference** in IAPI proxy calls, scheduled tasks, or page templates:\n `{{vault:...}}` with the key name from step 1 (here: `wpa_dashboard`)\n\nThe AI never sees the actual token. The key exists only in the vault and is resolved\nserver-side by the IAPI proxy.\n\n#### Key Rules\n\n- `vault_key_name` **must** start with `wpa_` (prevents overwriting other vault secrets)\n- `purpose` is required — it's shown to the owner in the confirmation email\n- Max 2 pending requests per project at a time\n- Unconfirmed requests expire after 1 hour\n- Each confirmation link works only once\n\n#### When to Use This\n\nUse `auth_keys` when a page or scheduled task needs to make authenticated API calls\nand no WPA key exists in the vault yet. Common scenarios: admin dashboards,\nautomated data sync tasks, headless API integrations.\n\nDo NOT use this for visitor-facing pages — those use SAPI sessions (no Bearer token needed).\n\n---\n\n## AI Continuity — Staying on Track Across Sessions\n\nAI assistants typically lose all context when a conversation ends.\nWebsitePublisher solves this with infrastructure layers that preserve knowledge:\n\n### Skills (this document)\nYou are reading a skill right now. Skills are structured instructions that teach AI\nhow to work with the platform — which patterns to follow, which mistakes to avoid,\nand which tools to use. Without skills, every AI session would rediscover\nhow the platform works from scratch.\n\n**Always call `get_skill` at the start of a session.** It ensures you follow current\nbest practices, regardless of which AI platform the user is on.\n\n### Design Context (site_context integration)\n\nDesign decisions should be **saved immediately** when made — not at the end of a\nsession when they might be forgotten. Use `site_context` as a living design brief\nthat any AI session can pick up.\n\n`site_context` stores **design tokens only**: `color_palette`, `fonts`,\n`style_notes`, `locale`. Build status, page progress, and to-dos do **not** belong\nhere — that is what Task Tracking (TAPI, next section) is for. Sending any other\nfield returns `\"No valid fields provided\"`.\n\n**Save after every design decision** — writes are a **deep merge**: only the fields\nyou send are overwritten, everything else is preserved:\n```\nexecute_integration(\n service: \"site_context\",\n endpoint: \"set-context\",\n input: {\n color_palette: { primary: \"#2D5016\", secondary: \"#F5F0E8\", accent: \"#B8860B\",\n background: \"#FAF7F2\", text: \"#1A1A1A\" },\n fonts: { heading: \"Playfair Display\", body: \"Inter\" },\n style_notes: \"Warm, artisanal, Japanese-inspired minimalism\",\n locale: \"en\"\n }\n)\n```\n\nField reference: `color_palette` keys are `primary`, `secondary`, `accent`,\n`background`, `text` (hex strings). `fonts` keys are `heading` and `body`\n(font family names). `style_notes` is free text, max 500 chars. `locale` is\nISO 639-1 (`\"nl\"`, `\"en\"`, `\"de\"`).\n\n**Sections** — a project with more than one visual style (e.g. public site vs admin\npanel vs email templates) stores each as a named section via the optional `section`\nparameter (`\"frontend\"`, `\"admin\"`, ...). Without it, reads and writes target\n`\"default\"`. Max 10 sections per project. The `\"default\"` section is also included\nin the `get_project_status` response.\n\n**Retrieve at the start of every session:**\n```\nexecute_integration(service: \"site_context\", endpoint: \"get-context\", input: {})\n```\nPass `section: \"all\"` to get every section as a keyed object.\n\n**List which sections exist:**\n```\nexecute_integration(service: \"site_context\", endpoint: \"list-sections\", input: {})\n```\n\n**Delete context** — ⚠️ omitting `section` deletes **ALL** sections for the project.\nAlways pass the section explicitly:\n```\nexecute_integration(service: \"site_context\", endpoint: \"delete-context\",\n input: { section: \"admin\" })\n```\n\nThis is the single most important continuity tool. Without it, a new AI session\nhas to ask the user to re-explain every design choice.\n\n### Task Tracking (TAPI)\n\nFor multi-session website builds, track progress with tasks so no work gets lost\nor repeated. Each task has a slug, status, and history — visible across sessions.\n\n**Create tasks for each build phase:**\n```\ncreate_task(slug: \"homepage-build\", title: \"Build homepage with hero + features\")\ncreate_task(slug: \"shop-pages\", title: \"Shop overview + product detail pages\")\ncreate_task(slug: \"contact-form\", title: \"Contact form with Resend email\")\ncreate_task(slug: \"admin-dashboard\", title: \"Admin panel with auth + CRUD\")\n```\n\n**Update progress as you work:**\n```\nadd_task_history(\n slug: \"homepage-build\",\n type: \"progress\",\n status: \"done\",\n completion_pct: 100,\n summary: \"Homepage live: hero section, 3 feature cards, testimonials, CTA\"\n)\n```\n\n**Start of next session — check what's done and what's next:**\n```\nlist_tasks(status: \"in_progress\") # What's being worked on\nlist_tasks(status: \"open\") # What hasn't started yet\n```\n\nThis gives every AI session — regardless of platform — a shared understanding of\nwhere the project stands. The user doesn't have to re-explain what was already built.\n\n### Scheduled Tasks (AAPI)\nWebsites sometimes need automated actions: publish a page at a specific time,\nsend a weekly email digest, update data records on a schedule. The AAPI layer\nhandles this without requiring the AI or the user to be present.\n\nAvailable via: `create_scheduled_task`, `list_scheduled_tasks`\n\n### Visual Editor (WPE)\nThe user does not need to start a new AI conversation for every small change.\nThe Visual Editor lets them update images, reorder content, and adjust styles\ndirectly in their browser — at any time, without AI involvement.\n\nThese layers ensure that the **quality and consistency of the website do not depend\non which AI session is active.** The platform remembers — the AI doesn't have to.\n\n---\n\n## API Quick Reference\n\n### Base URLs\n```\nPages & Assets: https://api.websitepublisher.ai/papi/\nEntities & Data: https://api.websitepublisher.ai/mapi/\nForms & Sessions: https://api.websitepublisher.ai/sapi/\nVault: https://api.websitepublisher.ai/vapi/\nIntegrations: https://api.websitepublisher.ai/iapi/\nDashboard: https://api.websitepublisher.ai/dapi/\n```\n\n### Key PAPI Endpoints\n```\nGET /papi/projects List projects\nPOST /papi/projects Create project\nGET /papi/project/{id}/pages List pages\nPOST /papi/project/{id}/pages Create page\nPUT /papi/project/{id}/pages/{slug} Update page\nDELETE /papi/project/{id}/pages/{slug} Delete page\nPOST /papi/project/{id}/assets Upload asset\nGET /papi/project/{id}/pages?type=fragment List fragments\n```\n\n### Key IAPI Endpoints\n```\nPOST /iapi/project/{id}/{service}/{endpoint} Execute integration\n\n# Examples:\nPOST /iapi/project/{id}/leads/submit-lead Store a lead\nPOST /iapi/project/{id}/leads/get-leads Retrieve leads (authenticated)\nPOST /iapi/project/{id}/leads/update-status Update lead status\nPOST /iapi/project/{id}/resend/send-email Send email via Resend\nPOST /iapi/project/{id}/mollie/create-payment Create Mollie payment\nPOST /iapi/project/{id}/site_context/set-context Save design context (deep merge)\nPOST /iapi/project/{id}/site_context/get-context Get design context (section or \"all\")\nPOST /iapi/project/{id}/site_context/list-sections List stored context sections\nPOST /iapi/project/{id}/site_context/delete-context Delete a section (no section = ALL)\nPOST /iapi/project/{id}/capability_requests/submit-request Report platform gap (last resort)\nPOST /iapi/project/{id}/product-catalog/bulk-upsert-products Bulk import (up to 500)\nPOST /iapi/project/{id}/tracer/start Start debug trace session\nPOST /iapi/project/{id}/tracer/logs Read trace entries\n```\n\n### Key SAPI Endpoints (visitor-facing, no bearer token)\n```\nGET /sapi/project/{id}/session Start or resume session\nGET /sapi/project/{id}/csrf/refresh Refresh CSRF token\nPOST /sapi/project/{id}/form/submit Submit form data\nPOST /sapi/project/{id}/form/upload Upload image (multipart)\nPOST /sapi/project/{id}/auth/request Request magic link/code\nPOST /sapi/project/{id}/auth/verify Verify code\nGET /sapi/project/{id}/auth/status Check auth status\n```\n\n### Lead Capture\n\nForm submissions with `action: {\"type\": \"leads\"}` are stored in the platform's\nbuilt-in lead capture — no integration setup required.\n\n**Retrieve leads via MCP tool:**\n```\nleads_get_leads → project_id (+ optional: status, form_name, page, per_page)\n```\n\n**Retrieve leads via HTTP (for dashboard pages / browser JavaScript):**\n```\nPOST /iapi/project/{id}/leads/get-leads\nAuthorization: Bearer {wps_token}\nContent-Type: application/json\nBody: {\"page\": 1, \"per_page\": 25}\n\nOptional filters: status (new/contacted/converted), form_name, date_from, date_to\n```\n\n**Important:** Leads are always authenticated — there is no public URL.\nNever ask for routing files to find the leads endpoint — the URL above is canonical.\n\n**Configure lead capture on a form:**\n```json\n{\n \"form_name\": \"contact\",\n \"actions\": [{\"type\": \"leads\"}],\n \"required_fields\": [\"name\", \"email\"]\n}\n```\n\n---\n\n## For Platform Developers\n\nIf you are working on the WebsitePublisher.ai platform itself rather than building\na customer website, a separate development skill is available with internal conventions,\nTAPI task tracking workflow, and infrastructure reference:\n\n```\nhttps://www.websitepublisher.ai/skills/websitepublisher-dev/SKILL.md\n```\n\n### Full Documentation\nhttps://www.websitepublisher.ai/docs\n\n### MCP Setup (for Claude Desktop, Cursor, Windsurf, GitHub Copilot)\nhttps://www.websitepublisher.ai/docs/mcp\n"
}SHA-256: 08317d879a7494405556e8ed1cb20998f8322526519ffe719254619b943fd801