@hostinger/mcp 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/gemini-extension.json +1 -1
  3. package/package.json +1 -1
  4. package/server.json +2 -2
  5. package/skills/headless/steps/02-refine/SKILL.md +7 -0
  6. package/skills/headless/steps/03-provision/wordpress/SKILL.md +63 -0
  7. package/skills/headless/steps/04-content-model/wordpress/SKILL.md +58 -5
  8. package/skills/headless/steps/04-content-model/wordpress/contact-form.md +227 -0
  9. package/skills/headless/steps/04-content-model/wordpress/custom-plugins.md +481 -0
  10. package/skills/headless/steps/06-frontend/SKILL.md +15 -0
  11. package/skills/headless/steps/07-validate/SKILL.md +13 -0
  12. package/skills/headless/steps/09-done/SKILL.md +5 -0
  13. package/src/core/skills.js +32 -18
  14. package/src/core/skills.ts +32 -18
  15. package/src/servers/agency-hosting.js +1 -1
  16. package/src/servers/agency-hosting.ts +1 -1
  17. package/src/servers/agent-readiness.js +1 -1
  18. package/src/servers/agent-readiness.ts +1 -1
  19. package/src/servers/all.js +1 -1
  20. package/src/servers/all.ts +1 -1
  21. package/src/servers/billing.js +1 -1
  22. package/src/servers/billing.ts +1 -1
  23. package/src/servers/dns.js +1 -1
  24. package/src/servers/dns.ts +1 -1
  25. package/src/servers/domains.js +1 -1
  26. package/src/servers/domains.ts +1 -1
  27. package/src/servers/ecommerce.js +1 -1
  28. package/src/servers/ecommerce.ts +1 -1
  29. package/src/servers/horizons.js +1 -1
  30. package/src/servers/horizons.ts +1 -1
  31. package/src/servers/hosting.js +1 -1
  32. package/src/servers/hosting.ts +1 -1
  33. package/src/servers/mail.js +1 -1
  34. package/src/servers/mail.ts +1 -1
  35. package/src/servers/reach.js +1 -1
  36. package/src/servers/reach.ts +1 -1
  37. package/src/servers/vps.js +1 -1
  38. package/src/servers/vps.ts +1 -1
  39. package/src/servers/wordpress.js +1 -1
  40. package/src/servers/wordpress.ts +1 -1
@@ -141,10 +141,10 @@ export default [
141
141
  },
142
142
  {
143
143
  "uri": "skill://hostinger-headless/steps/02-refine/SKILL.md",
144
- "digest": "sha256:46bd98a4fcb8dd69713ac2d4d3b62ae2afe9f9aab3c7caff539090731dc12a26",
145
- "size": 17492,
144
+ "digest": "sha256:f44d5a145287554d9b09e3f461d2cbb0a43f4910a39caeeeb5855f5ed1bd43a4",
145
+ "size": 18034,
146
146
  "mimeType": "text/markdown",
147
- "text": "---\nname: headless-step-refine\ndescription: \"Step 2 (refine) of the Hostinger Headless flow. Turns the user's seed — often just a domain — into a brief through conversation: asks what they want to build (purpose, audience, tone, pages, products, must-haves; images, local files, links all welcome), then *infers* the backend that fits (WordPress, Ecommerce, WordPress + Ecommerce, WordPress + WooCommerce for a one-site content+store, or Webapp for a custom app/SPA) — it never asks the user to pick a backend or a technology; the user confirms the plan in plain language, then it writes brief.md. Hostinger Ecommerce stays the default for selling; WooCommerce is chosen only when the user explicitly asks for it or wants content and a store on one WordPress site.\"\n---\n\n# headless-step-refine\n\nFind out what the user wants to build, guess which backend fits, and write it down. This is a\n**conversation, not a form**: the pasted prompt names a domain and nothing else, so everything about\nthe site comes from talking to the user.\n\n**Keep it short.** The only thing you must know is *what they are building* — enough to guess the\nbackend. Every other question is skippable: the more the user gives, the better the site, but never\nhold the build hostage to refinement. Fill gaps with sensible defaults and say so in the brief.\n\n> **Never ask the user to choose a backend or a technology.** Don't ask \"WordPress or Ecommerce?\",\n> don't present WordPress/Ecommerce/WooCommerce/Webapp as options, and don't surface the backend in a\n> multiple-choice UI. Open with **\"What do you want to build?\"** and infer the backend yourself in\n> Step 4 from what they describe (and whether they sell). The backend is a technical decision the flow\n> makes *for* the user — they confirm the **plan** (what the site is and does), not the plumbing. The\n> only exception is when the user *volunteers* a backend (\"build it on WordPress\", \"use WooCommerce\"):\n> then honour their choice and note it in the brief.\n\n**In:** the domain (the workspace directory name), plus anything the user already shared.\n**Out:** `~/.hostinger/<domain>/brief.md`.\n\nNothing in this step touches Hostinger. It only asks, decides and writes.\n\n---\n\n## Step 1 — Read the workspace, then the resume check\n\nRead everything already in `~/.hostinger/<domain>/` (`state.json`, `connect.json`, any notes, images\nor files the user dropped there) — it all counts as input.\n\nIf `brief.md` already exists for this domain, **don't overwrite it**. Show the user a three-line\nsummary (what the site is, its pages, the suggested backend) and ask: *keep it, or change something?*\nKeep → go to Step 6. Change → edit only what they asked for, then re-confirm (Step 5).\n\nIf the site is already provisioned (`site.json` exists), the backend is **fixed** — never change the\nSuggested backend on a provisioned site. Explain that switching backends means a new domain.\n\n## Step 2 — Gather what you already have\n\nBefore asking anything, collect what the user has already given you, so you don't ask for it again:\n\n- **Inspect the directory the command was run in** — this is the biggest signal for the build mode\n (Step 3). Look for an existing **frontend / webapp / commerce project**: a `package.json`, a\n framework config (`next.config.*`, `astro.config.*`, `vite.config.*`, `svelte.config.*`, `nuxt.*`),\n a `src/` or `app/` tree, `index.html`, a storefront/cart, etc. A real project here usually means the\n user **already has their own frontend** and mainly needs a Hostinger backend to wire it to — lean\n toward \"bring your own frontend\" in Step 3. If the folder has nothing site-like, assume they don't\n and lean toward \"agent builds it\".\n- Anything in the pasted prompt beyond the domain (a sentence, a link, a screenshot).\n- Files in the current project that look like site material (copy, logos, product lists, images).\n- What the agent already knows about the user from local memory or this conversation (a prior build,\n stack preferences, a frontend they always use).\n- The domain name itself — a hint only (`coral-pottery.com` suggests pottery), never a conclusion.\n\n## Step 3 — Ask\n\nIf Step 2 already answers *what are you building* and *will they sell*, skip the Round 1–3 questions\nbelow, but still ask **Build mode** before moving on.\n\nOtherwise open with one line and **wait for the user's answer**: *\"Tell me what you want to build — a\nsentence is enough. Links, screenshots or files help too. Skip anything you don't want to answer.\"*\n\n> **\"What do you want to build?\" is an OPEN question — ask it in plain prose and wait.** Never turn it\n> into a multiple-choice list of pre-baked site ideas (\"Coffee shop / Travel blog / Portfolio …\"):\n> that puts words in the user's mouth and defeats the point of asking. Let them describe it in their\n> own words. The structured question UI is only for genuinely *choice-shaped* follow-ups (selling\n> yes/no, which pages, tone adjectives, the build mode) — not for the opening question, and never for\n> the backend.\n\nAsk in **short rounds of 2–3 questions**, and adapt each round to the previous answers. If your tool\nhas a structured question UI (multiple choice with an \"other\" field), use it for the choice-shaped\nfollow-up questions (pages, selling yes/no, tone) — **never for the opening \"what do you want to\nbuild?\" and never for the backend** (that's your decision, not theirs). **At most 3 rounds**, and stop early as soon as you can guess the backend and the user\nseems done — \"just build it\", \"you decide\", or a skipped question means *use defaults*, not *ask\nagain*.\n\n**Round 1 — what it is (the only required round):**\n\n1. **What are you building?** The business or project, and what the site is for.\n2. **Will you sell anything on it?** Physical products, digital products, services with checkout —\n or nothing. *This decides the backend.* Skip it only when the answer is already unmistakable\n (\"an online shop for my candles\", \"my personal blog\"); when in doubt, ask.\n3. **Who is it for?** The audience, and how they'll find the site. *(skippable)*\n\n**Round 2 — what goes on it (optional, adapt to Round 1):**\n\n- **Content they'll publish over time** — blog posts, articles, news, recipes, portfolio pieces,\n case studies? How often? Or is it a handful of pages that rarely change?\n- **Pages** — propose a page list from what you know and ask them to add or remove.\n- If they sell: **what and how many** — a rough product count, categories, whether products have\n variants (size, colour), and the shipping regions.\n\n**Round 3 — how it should feel (optional, skip what you already know):**\n\nThis is the round that captures **all design input** — the design step reads it from `brief.md` to derive\nthe design directions, so it is **not** asked again later; gather it here:\n\n- **Reference & look** — a **site they like (URL)** and what to take from it, plus a few adjectives for\n the vibe. Screenshots welcome.\n- **Colours & brand** — brand/accent colours, logo, fonts, existing copy, photos. Note where they live.\n- **Must-haves and no-gos** — contact form, newsletter, bookings, languages, things to avoid.\n\nIf the user asks for something outside the scope (user accounts, member areas, bookings with a\ncalendar backend, user-generated content), say plainly that the headless flow doesn't support it yet\nand record it under **Out of scope** — don't promise it.\n\n### Build mode — ask once (this one *is* a user choice)\n\nThe backend is your call, but **how the frontend gets built is the user's** — because most of the time\nthe user already has their own frontend and just needs a Hostinger backend to wire it to. After the\nfolder + memory inspection (Step 2), ask **one** question (a structured choice is ideal here), seeding\nthe default from what you found:\n\n- **Bring your own frontend** *(default when Step 2 found a real project in the folder)* — \"I already\n have a frontend / I'll drive the design and we vibe-code it together.\" The agent **uses the user's\n existing project in the current folder as the source**, collaborates on design/code at the user's\n direction, and wires it to the provisioned backend. There is **no agent-generated design** — the\n design step defers to the user (see the Build mode box at the top of the design step), and the frontend step builds on their project\n rather than scaffolding a new one.\n- **Agent designs and builds it** *(default when the folder has nothing site-like)* — \"Give me ideas\n — you design and code it.\" Today's flow: the design step proposes directions and the frontend step\n scaffolds and implements them.\n\nRecord the answer as the brief's **Build mode**. This is the one frontend/technology question you *do*\nput to the user; everything else about the stack (framework, SSR/SSG) is still inferred. The deliverable\nis the same either way — a working site on the backend; only *who drives the frontend* changes.\n\n## Step 4 — Decide the backend (your call, from the brief — not a question)\n\nFrom what the user described, **you** pick the backend that fits — this is an internal decision, never\na question put back to the user. Pick the backend that fits what the user told you:\n\n| Backend | Choose when | What it gives the owner |\n|---|---|---|\n| **WordPress** | No selling. The owner publishes and edits content: pages, blog, articles, portfolio. | Headless WordPress at `/cms` — write and edit content in wp-admin. |\n| **Ecommerce** | Selling is the point, and the rest is a few static pages (About, Contact, FAQ, policies). No ongoing blog. | Hostinger Ecommerce store — products, collections, checkout, payments, shipping, orders. |\n| **WordPress + Ecommerce** | Selling **and** regular editorial content (a blog, guides, lookbooks, recipes) the owner keeps adding to, run as **two** backends (WordPress site + separate Hostinger Ecommerce store). | Both: WordPress for content, the Ecommerce store for products and checkout. |\n| **WordPress + WooCommerce** | **Only** when the user explicitly names WooCommerce, or explicitly wants content **and** a store on **one** WordPress site (one admin, one domain). The one-site alternative to WordPress + Ecommerce. | A single headless WordPress at `/cms` with the WooCommerce plugin — products, cart, coupons and checkout (Cash on Delivery) over the WooCommerce Store API, plus WP posts/pages on the same site. |\n| **Webapp** | A custom **app, SPA or web application** that brings its own code and data — a dashboard, tool, calculator, booking app, a project the user is deploying — **not** a content site or a store. No CMS and no store engine. | A plain Node.js app host (an empty website, no `/cms`), with a MySQL database when the app needs one. The app owns all its routes and data. |\n\nRules of thumb:\n\n- **Is it an app, or a site?** A custom application the user builds/brings (its own logic, its own data,\n maybe a database) → **Webapp**. A content site or a shop → one of the three above. When the user says\n \"my app / SPA / dashboard / tool\", or hands you an existing app project to deploy, that's **Webapp**.\n- **Selling decides first (for sites).** No selling → WordPress. Selling → Ecommerce or\n WordPress + Ecommerce.\n- **Then the content.** Selling plus only a few fixed pages → Ecommerce. Selling plus content the\n owner will keep publishing → WordPress + Ecommerce.\n- **Hostinger Ecommerce is the default for selling — WooCommerce is never the default and is never\n recommended on its own.** Choose **WordPress + WooCommerce** only when the user explicitly names\n WooCommerce, or explicitly wants content **and** a store on one WordPress site (one admin, one\n domain — the one-site alternative to WordPress + Ecommerce). For every other selling case, keep\n Hostinger Ecommerce. If the user wants both content and a store but hasn't said \"one site\" or\n \"WooCommerce\", offer it as an option — don't switch to it silently.\n- **Don't add WordPress \"just in case\".** A second backend is a second admin to learn. Recommend the\n combination only when the user described ongoing content.\n- Selling a single service by enquiry (no checkout, no cart) is **not** commerce → WordPress with a\n contact page.\n- **Webapp never uses WordPress or a store.** If the user wants a custom app *and* a CMS/shop, that's two\n different sites — build the app as a Webapp and tell them the content/commerce backend is a separate\n domain.\n\n**Then set rendering** (write it in the brief's **Rendering** section): **Ecommerce → SSR required**\n(carts/search need per-request rendering — any SSR framework works; Next.js is the default, not a\nrequirement); **WordPress + WooCommerce → SSR required** (same reason — cart/checkout need per-request\nrendering); **WordPress → SSG (Astro)** if the site is mostly static and the owner is fine rebuilding\nto publish, otherwise **SSR**; **Webapp → whatever the app is** (SSR for a server app, SPA for a\nclient-only app) — it deploys as a Node.js app either way. When unsure, default to **Next.js SSR**.\n\n## Step 5 — Confirm the plan in one message\n\nSend **one** message that confirms **what you'll build** — the plan, not the plumbing. Lead with the\nsite, not the backend:\n\n- **The plan** — what it is, audience, pages, products (if any), tone. Mark anything you defaulted\n (*\"assumed: warm, minimal look\"*) so the user sees what they skipped.\n- **How it's built** — state the backend as a *consequence* in one plain line, focused on what it\n means for them day to day (\"I'll build this on WordPress, so you edit pages and posts in a normal\n WordPress admin\"), **not** as a choice you're asking them to make. Don't ask \"is WordPress OK?\" or\n offer alternatives unless it was a genuine close call *and* the difference matters to the user.\n\nEnd with *\"Good to go, or anything to change?\"* A yes (or silence-equivalent like \"go\") → write\n`brief.md`. Changes → apply them and write it; don't loop back into questions. If the user\n*volunteers* a different backend, use theirs and note it in the brief.\n\n## Step 6 — Write `brief.md` and hand off\n\nWrite `brief.md` from the template below. Fill every section from the conversation, defaulting what\nthe user skipped and tagging it `(assumed)`; leave out **Products** when nothing is sold. Use the\nuser's own words where you can. Then update `state.json`:\n`\"stage\": \"provision\"`, fresh `updated_at`.\n\nThe **Suggested backend** section is the contract with `03-provision/SKILL.md`, which routes on it.\nIts first line must be exactly one of `**WordPress**`, `**Ecommerce**`, `**WordPress + Ecommerce**`,\n`**WordPress + WooCommerce**` or `**Webapp**`, followed by an em dash and the one-line reason.\n`**WordPress + WooCommerce**` is the WordPress backend with the WooCommerce store turned on — provision\nroutes it to the `wordpress` subfolder with the store enabled (it is **not** a separate backend).\n\n## `brief.md` template\n\n```markdown\n# Brief — <domain>\n\n> Brief agreed with the user on <today>.\n\n## Purpose\n<what the site is, who runs it, what it's for — 2–3 sentences>\n\n## Audience\n<who visits, how they arrive, what device they mostly use>\n\n## Tone\n<voice in a sentence or two>\n\n## Design direction\n<look & feel in a few adjectives; **reference site(s)** the user named (URLs) and what to take from them\n(layout, structure, mood); colours / brand palette; any screenshots. This is where ALL design questions\nland — the frontend step reads this and does not re-ask. Write \"Agent's choice: <short style>\" when the\nuser didn't specify.>\n\n## Pages\n- **<Page>** — <one-line purpose>\n- …\n\n## Products\n<only when selling: what is sold, rough count, categories, variants, shipping regions, payments>\n\n## Content\n<what the owner will publish over time and how often — or \"A few fixed pages; no ongoing content.\">\n\n## Must-haves\n- …\n\n## Out of scope\n- …\n\n## Assets\n<logo, colours, photos, copy the user provided and where they are; \"None yet\" if none>\n\n## Suggested backend\n**<WordPress | Ecommerce | WordPress + Ecommerce | WordPress + WooCommerce | Webapp>** — <one-line reason>.\n<\"Confirmed by the user.\" or \"User chose this over the recommended <X> because <reason>.\">\n\n## Build mode\n**<Bring your own frontend | Agent designs and builds>** — <one line>. <When \"Bring your own frontend\":\nthe path to the user's existing project in the current folder (the frontend source), and how they want\nto drive design/code. When \"Agent designs and builds\": leave the design + frontend steps to run as\nnormal.> The design step and the frontend step both read this: **Bring your own frontend** → design\ndefers to the user and frontend builds on their project; **Agent designs and builds** → both run as normal.\n\n## Rendering\n**<SSR | SSG>** — <reason>. Rules: **Ecommerce requires SSR** and **WordPress + WooCommerce requires\nSSR** (carts/checkout need per-request rendering — any SSR framework works; Next.js is the default, not\na requirement); **WordPress may be SSG (Astro)** when the site is mostly static and rarely changes,\notherwise **SSR**. Default when unsure: **Next.js SSR**.\n```\n\n## Example — WordPress + Ecommerce\n\n> A ceramicist sells around 30 handmade pieces and writes a monthly studio journal.\n\n```markdown\n## Suggested backend\n**WordPress + Ecommerce** — sells ~30 products with checkout, and the owner publishes a monthly\nstudio journal they want to edit themselves.\nConfirmed by the user.\n```\n"
147
+ "text": "---\nname: headless-step-refine\ndescription: \"Step 2 (refine) of the Hostinger Headless flow. Turns the user's seed — often just a domain — into a brief through conversation: asks what they want to build (purpose, audience, tone, pages, products, must-haves; images, local files, links all welcome), then *infers* the backend that fits (WordPress, Ecommerce, WordPress + Ecommerce, WordPress + WooCommerce for a one-site content+store, or Webapp for a custom app/SPA) — it never asks the user to pick a backend or a technology; the user confirms the plan in plain language, then it writes brief.md. Hostinger Ecommerce stays the default for selling; WooCommerce is chosen only when the user explicitly asks for it or wants content and a store on one WordPress site.\"\n---\n\n# headless-step-refine\n\nFind out what the user wants to build, guess which backend fits, and write it down. This is a\n**conversation, not a form**: the pasted prompt names a domain and nothing else, so everything about\nthe site comes from talking to the user.\n\n**Keep it short.** The only thing you must know is *what they are building* — enough to guess the\nbackend. Every other question is skippable: the more the user gives, the better the site, but never\nhold the build hostage to refinement. Fill gaps with sensible defaults and say so in the brief.\n\n> **Never ask the user to choose a backend or a technology.** Don't ask \"WordPress or Ecommerce?\",\n> don't present WordPress/Ecommerce/WooCommerce/Webapp as options, and don't surface the backend in a\n> multiple-choice UI. Open with **\"What do you want to build?\"** and infer the backend yourself in\n> Step 4 from what they describe (and whether they sell). The backend is a technical decision the flow\n> makes *for* the user — they confirm the **plan** (what the site is and does), not the plumbing. The\n> only exception is when the user *volunteers* a backend (\"build it on WordPress\", \"use WooCommerce\"):\n> then honour their choice and note it in the brief.\n\n**In:** the domain (the workspace directory name), plus anything the user already shared.\n**Out:** `~/.hostinger/<domain>/brief.md`.\n\nNothing in this step touches Hostinger. It only asks, decides and writes.\n\n---\n\n## Step 1 — Read the workspace, then the resume check\n\nRead everything already in `~/.hostinger/<domain>/` (`state.json`, `connect.json`, any notes, images\nor files the user dropped there) — it all counts as input.\n\nIf `brief.md` already exists for this domain, **don't overwrite it**. Show the user a three-line\nsummary (what the site is, its pages, the suggested backend) and ask: *keep it, or change something?*\nKeep → go to Step 6. Change → edit only what they asked for, then re-confirm (Step 5).\n\nIf the site is already provisioned (`site.json` exists), the backend is **fixed** — never change the\nSuggested backend on a provisioned site. Explain that switching backends means a new domain.\n\n## Step 2 — Gather what you already have\n\nBefore asking anything, collect what the user has already given you, so you don't ask for it again:\n\n- **Inspect the directory the command was run in** — this is the biggest signal for the build mode\n (Step 3). Look for an existing **frontend / webapp / commerce project**: a `package.json`, a\n framework config (`next.config.*`, `astro.config.*`, `vite.config.*`, `svelte.config.*`, `nuxt.*`),\n a `src/` or `app/` tree, `index.html`, a storefront/cart, etc. A real project here usually means the\n user **already has their own frontend** and mainly needs a Hostinger backend to wire it to — lean\n toward \"bring your own frontend\" in Step 3. If the folder has nothing site-like, assume they don't\n and lean toward \"agent builds it\".\n- Anything in the pasted prompt beyond the domain (a sentence, a link, a screenshot).\n- Files in the current project that look like site material (copy, logos, product lists, images).\n- What the agent already knows about the user from local memory or this conversation (a prior build,\n stack preferences, a frontend they always use).\n- The domain name itself — a hint only (`coral-pottery.com` suggests pottery), never a conclusion.\n\n## Step 3 — Ask\n\nIf Step 2 already answers *what are you building* and *will they sell*, skip the Round 1–3 questions\nbelow, but still ask **Build mode** before moving on.\n\nOtherwise open with one line and **wait for the user's answer**: *\"Tell me what you want to build — a\nsentence is enough. Links, screenshots or files help too. Skip anything you don't want to answer.\"*\n\n> **\"What do you want to build?\" is an OPEN question — ask it in plain prose and wait.** Never turn it\n> into a multiple-choice list of pre-baked site ideas (\"Coffee shop / Travel blog / Portfolio …\"):\n> that puts words in the user's mouth and defeats the point of asking. Let them describe it in their\n> own words. The structured question UI is only for genuinely *choice-shaped* follow-ups (selling\n> yes/no, which pages, tone adjectives, the build mode) — not for the opening question, and never for\n> the backend.\n\nAsk in **short rounds of 2–3 questions**, and adapt each round to the previous answers. If your tool\nhas a structured question UI (multiple choice with an \"other\" field), use it for the choice-shaped\nfollow-up questions (pages, selling yes/no, tone) — **never for the opening \"what do you want to\nbuild?\" and never for the backend** (that's your decision, not theirs). **At most 3 rounds**, and stop early as soon as you can guess the backend and the user\nseems done — \"just build it\", \"you decide\", or a skipped question means *use defaults*, not *ask\nagain*.\n\n**Round 1 — what it is (the only required round):**\n\n1. **What are you building?** The business or project, and what the site is for.\n2. **Will you sell anything on it?** Physical products, digital products, services with checkout —\n or nothing. *This decides the backend.* Skip it only when the answer is already unmistakable\n (\"an online shop for my candles\", \"my personal blog\"); when in doubt, ask.\n3. **Who is it for?** The audience, and how they'll find the site. *(skippable)*\n\n**Round 2 — what goes on it (optional, adapt to Round 1):**\n\n- **Content they'll publish over time** — blog posts, articles, news, recipes, portfolio pieces,\n case studies? How often? Or is it a handful of pages that rarely change?\n- **Pages** — propose a page list from what you know and ask them to add or remove.\n- If they sell: **what and how many** — a rough product count, categories, whether products have\n variants (size, colour), and the shipping regions.\n\n**Round 3 — how it should feel (optional, skip what you already know):**\n\nThis is the round that captures **all design input** — the design step reads it from `brief.md` to derive\nthe design directions, so it is **not** asked again later; gather it here:\n\n- **Reference & look** — a **site they like (URL)** and what to take from it, plus a few adjectives for\n the vibe. Screenshots welcome.\n- **Colours & brand** — brand/accent colours, logo, fonts, existing copy, photos. Note where they live.\n- **Must-haves and no-gos** — contact form, newsletter, bookings, languages, things to avoid.\n\nIf the user asks for something outside the scope (user accounts, member areas, bookings with a\ncalendar backend, user-generated content), say plainly that the headless flow doesn't support it yet\nand record it under **Out of scope** — don't promise it.\n\n> **A contact form *is* supported (WordPress backends).** If the user wants one, keep it in\n> **Must-haves** and name it \"Contact form\". Provision installs **Contact Form 7** on `/cms` and the\n> frontend wires a contact page whose form emails the site owner through WordPress's built-in\n> `wp_mail()` — no third-party service and no API keys. It's **opt-in**: built only when the brief asks\n> for it. (A form that needs accounts, payments or a booking calendar is still out of scope — this is a\n> plain name/email/message enquiry form.)\n\n### Build mode — ask once (this one *is* a user choice)\n\nThe backend is your call, but **how the frontend gets built is the user's** — because most of the time\nthe user already has their own frontend and just needs a Hostinger backend to wire it to. After the\nfolder + memory inspection (Step 2), ask **one** question (a structured choice is ideal here), seeding\nthe default from what you found:\n\n- **Bring your own frontend** *(default when Step 2 found a real project in the folder)* — \"I already\n have a frontend / I'll drive the design and we vibe-code it together.\" The agent **uses the user's\n existing project in the current folder as the source**, collaborates on design/code at the user's\n direction, and wires it to the provisioned backend. There is **no agent-generated design** — the\n design step defers to the user (see the Build mode box at the top of the design step), and the frontend step builds on their project\n rather than scaffolding a new one.\n- **Agent designs and builds it** *(default when the folder has nothing site-like)* — \"Give me ideas\n — you design and code it.\" Today's flow: the design step proposes directions and the frontend step\n scaffolds and implements them.\n\nRecord the answer as the brief's **Build mode**. This is the one frontend/technology question you *do*\nput to the user; everything else about the stack (framework, SSR/SSG) is still inferred. The deliverable\nis the same either way — a working site on the backend; only *who drives the frontend* changes.\n\n## Step 4 — Decide the backend (your call, from the brief — not a question)\n\nFrom what the user described, **you** pick the backend that fits — this is an internal decision, never\na question put back to the user. Pick the backend that fits what the user told you:\n\n| Backend | Choose when | What it gives the owner |\n|---|---|---|\n| **WordPress** | No selling. The owner publishes and edits content: pages, blog, articles, portfolio. | Headless WordPress at `/cms` — write and edit content in wp-admin. |\n| **Ecommerce** | Selling is the point, and the rest is a few static pages (About, Contact, FAQ, policies). No ongoing blog. | Hostinger Ecommerce store — products, collections, checkout, payments, shipping, orders. |\n| **WordPress + Ecommerce** | Selling **and** regular editorial content (a blog, guides, lookbooks, recipes) the owner keeps adding to, run as **two** backends (WordPress site + separate Hostinger Ecommerce store). | Both: WordPress for content, the Ecommerce store for products and checkout. |\n| **WordPress + WooCommerce** | **Only** when the user explicitly names WooCommerce, or explicitly wants content **and** a store on **one** WordPress site (one admin, one domain). The one-site alternative to WordPress + Ecommerce. | A single headless WordPress at `/cms` with the WooCommerce plugin — products, cart, coupons and checkout (Cash on Delivery) over the WooCommerce Store API, plus WP posts/pages on the same site. |\n| **Webapp** | A custom **app, SPA or web application** that brings its own code and data — a dashboard, tool, calculator, booking app, a project the user is deploying — **not** a content site or a store. No CMS and no store engine. | A plain Node.js app host (an empty website, no `/cms`), with a MySQL database when the app needs one. The app owns all its routes and data. |\n\nRules of thumb:\n\n- **Is it an app, or a site?** A custom application the user builds/brings (its own logic, its own data,\n maybe a database) → **Webapp**. A content site or a shop → one of the three above. When the user says\n \"my app / SPA / dashboard / tool\", or hands you an existing app project to deploy, that's **Webapp**.\n- **Selling decides first (for sites).** No selling → WordPress. Selling → Ecommerce or\n WordPress + Ecommerce.\n- **Then the content.** Selling plus only a few fixed pages → Ecommerce. Selling plus content the\n owner will keep publishing → WordPress + Ecommerce.\n- **Hostinger Ecommerce is the default for selling — WooCommerce is never the default and is never\n recommended on its own.** Choose **WordPress + WooCommerce** only when the user explicitly names\n WooCommerce, or explicitly wants content **and** a store on one WordPress site (one admin, one\n domain — the one-site alternative to WordPress + Ecommerce). For every other selling case, keep\n Hostinger Ecommerce. If the user wants both content and a store but hasn't said \"one site\" or\n \"WooCommerce\", offer it as an option — don't switch to it silently.\n- **Don't add WordPress \"just in case\".** A second backend is a second admin to learn. Recommend the\n combination only when the user described ongoing content.\n- Selling a single service by enquiry (no checkout, no cart) is **not** commerce → WordPress with a\n contact page.\n- **Webapp never uses WordPress or a store.** If the user wants a custom app *and* a CMS/shop, that's two\n different sites — build the app as a Webapp and tell them the content/commerce backend is a separate\n domain.\n\n**Then set rendering** (write it in the brief's **Rendering** section): **Ecommerce → SSR required**\n(carts/search need per-request rendering — any SSR framework works; Next.js is the default, not a\nrequirement); **WordPress + WooCommerce → SSR required** (same reason — cart/checkout need per-request\nrendering); **WordPress → SSG (Astro)** if the site is mostly static and the owner is fine rebuilding\nto publish, otherwise **SSR**; **Webapp → whatever the app is** (SSR for a server app, SPA for a\nclient-only app) — it deploys as a Node.js app either way. When unsure, default to **Next.js SSR**.\n\n## Step 5 — Confirm the plan in one message\n\nSend **one** message that confirms **what you'll build** — the plan, not the plumbing. Lead with the\nsite, not the backend:\n\n- **The plan** — what it is, audience, pages, products (if any), tone. Mark anything you defaulted\n (*\"assumed: warm, minimal look\"*) so the user sees what they skipped.\n- **How it's built** — state the backend as a *consequence* in one plain line, focused on what it\n means for them day to day (\"I'll build this on WordPress, so you edit pages and posts in a normal\n WordPress admin\"), **not** as a choice you're asking them to make. Don't ask \"is WordPress OK?\" or\n offer alternatives unless it was a genuine close call *and* the difference matters to the user.\n\nEnd with *\"Good to go, or anything to change?\"* A yes (or silence-equivalent like \"go\") → write\n`brief.md`. Changes → apply them and write it; don't loop back into questions. If the user\n*volunteers* a different backend, use theirs and note it in the brief.\n\n## Step 6 — Write `brief.md` and hand off\n\nWrite `brief.md` from the template below. Fill every section from the conversation, defaulting what\nthe user skipped and tagging it `(assumed)`; leave out **Products** when nothing is sold. Use the\nuser's own words where you can. Then update `state.json`:\n`\"stage\": \"provision\"`, fresh `updated_at`.\n\nThe **Suggested backend** section is the contract with `03-provision/SKILL.md`, which routes on it.\nIts first line must be exactly one of `**WordPress**`, `**Ecommerce**`, `**WordPress + Ecommerce**`,\n`**WordPress + WooCommerce**` or `**Webapp**`, followed by an em dash and the one-line reason.\n`**WordPress + WooCommerce**` is the WordPress backend with the WooCommerce store turned on — provision\nroutes it to the `wordpress` subfolder with the store enabled (it is **not** a separate backend).\n\n## `brief.md` template\n\n```markdown\n# Brief — <domain>\n\n> Brief agreed with the user on <today>.\n\n## Purpose\n<what the site is, who runs it, what it's for — 2–3 sentences>\n\n## Audience\n<who visits, how they arrive, what device they mostly use>\n\n## Tone\n<voice in a sentence or two>\n\n## Design direction\n<look & feel in a few adjectives; **reference site(s)** the user named (URLs) and what to take from them\n(layout, structure, mood); colours / brand palette; any screenshots. This is where ALL design questions\nland — the frontend step reads this and does not re-ask. Write \"Agent's choice: <short style>\" when the\nuser didn't specify.>\n\n## Pages\n- **<Page>** — <one-line purpose>\n- …\n\n## Products\n<only when selling: what is sold, rough count, categories, variants, shipping regions, payments>\n\n## Content\n<what the owner will publish over time and how often — or \"A few fixed pages; no ongoing content.\">\n\n## Must-haves\n- …\n\n## Out of scope\n- …\n\n## Assets\n<logo, colours, photos, copy the user provided and where they are; \"None yet\" if none>\n\n## Suggested backend\n**<WordPress | Ecommerce | WordPress + Ecommerce | WordPress + WooCommerce | Webapp>** — <one-line reason>.\n<\"Confirmed by the user.\" or \"User chose this over the recommended <X> because <reason>.\">\n\n## Build mode\n**<Bring your own frontend | Agent designs and builds>** — <one line>. <When \"Bring your own frontend\":\nthe path to the user's existing project in the current folder (the frontend source), and how they want\nto drive design/code. When \"Agent designs and builds\": leave the design + frontend steps to run as\nnormal.> The design step and the frontend step both read this: **Bring your own frontend** → design\ndefers to the user and frontend builds on their project; **Agent designs and builds** → both run as normal.\n\n## Rendering\n**<SSR | SSG>** — <reason>. Rules: **Ecommerce requires SSR** and **WordPress + WooCommerce requires\nSSR** (carts/checkout need per-request rendering — any SSR framework works; Next.js is the default, not\na requirement); **WordPress may be SSG (Astro)** when the site is mostly static and rarely changes,\notherwise **SSR**. Default when unsure: **Next.js SSR**.\n```\n\n## Example — WordPress + Ecommerce\n\n> A ceramicist sells around 30 handmade pieces and writes a monthly studio journal.\n\n```markdown\n## Suggested backend\n**WordPress + Ecommerce** — sells ~30 products with checkout, and the owner publishes a monthly\nstudio journal they want to edit themselves.\nConfirmed by the user.\n```\n"
148
148
  },
149
149
  {
150
150
  "uri": "skill://hostinger-headless/steps/03-provision/SKILL.md",
@@ -169,10 +169,10 @@ export default [
169
169
  },
170
170
  {
171
171
  "uri": "skill://hostinger-headless/steps/03-provision/wordpress/SKILL.md",
172
- "digest": "sha256:6b0fa1e6b2208d387984ff30f2490cec3210ae3ef8859446f56154c8b27c0787",
173
- "size": 15951,
172
+ "digest": "sha256:14c64232c8c816d3797538b6896cb883f549f25aa957b5921aa8d374fd97c135",
173
+ "size": 20194,
174
174
  "mimeType": "text/markdown",
175
- "text": "---\nname: headless-step-provision-wordpress\ndescription: \"Provision a headless WordPress backend for the Hostinger Headless flow: create the headless website with WordPress at /cms, verify and self-heal, then record it in site.json.\"\n---\n\n# headless-step-provision-wordpress\n\nCreate the headless WordPress backend for `<domain>` and prove it is ready. One platform call lays\nevery headless site out identically: WordPress in `public_html/cms`, Hostinger AI Assistant + MCP\nadapter, site marked `headless`. **Out:** `site.json`.\n\n> **WordPress + WooCommerce?** This same recipe serves it. When the brief's **Suggested backend** is\n> `**WordPress + WooCommerce**`, provision the WordPress site exactly as below, then run the **optional\n> WooCommerce tail in Step 5** (install + activate the `woocommerce` plugin and verify the store\n> endpoints; the catalog is seeded in content-model via the per-site WP MCP).\n> WooCommerce is a plugin on this WordPress install, **not** a separate backend: `site.json → backend`\n> stays `\"wordpress\"` and the store is recorded under `site.json → woocommerce`. For a plain WordPress\n> brief, skip Step 5 entirely.\n\nAll operations below go through the Hostinger connector (`execute`).\n\n> **Fire vs join (background provision).** Step 1, plus the queuing call — `hosting_websites_start-setup`\n> for a fresh site (Step 2) **or** `wordpress_installations_install` to swap an empty headless shell\n> (Step 2b) — are the **fire** half: once the job is queued, record `provision.in_progress` and return\n> to do prepare work (see the router `../SKILL.md`). The readiness **poll** at the end of Step 2 / 2b,\n> plus Steps 3–6, are the **join** half — run them when an instance-barrier step needs the backend (or\n> immediately, if the agent can't interleave). An already-`completed` site with WordPress at `/cms`\n> detected in Step 1 means provision is already done: record `provision.completed` and adopt it.\n\n## Step 1 — Detect before you create (never provision twice)\n\n1. `hosting_websites_list { \"domain\": \"<domain>\" }` — substring filter, so **exact-match** the row's\n `domain`. If a row exists, note `username`, `order_id`, `website_type`.\n2. **No exact row** → nothing exists on this domain → **create fresh** (Step 2).\n3. **A row exists.** If `website_type` is **not** `headless`, stop and tell the user — this domain\n already hosts a different kind of site (plain WordPress, Node.js, builder, …); never overwrite it.\n If it **is** `headless`, work out whether WordPress is already installed or the shell is still empty\n (see the router's *Onboarding handoff*): check `hosting_websites_list-setups { \"domain\": \"<domain>\" }`\n (exact row) and `wordpress_installations_list { username, domain, ownership: \"all\" }` (exact row):\n - Setup `running` → provisioning is underway → skip to Step 2's poll.\n - Setup `failed` → tell the user and point them at hPanel; do **not** call start-setup again yourself.\n - A WordPress install with `directory: \"cms\"` already present (or setup `type: headless_wordpress`)\n → **already provisioned** → skip to Step 3 (adopt it).\n - A `headless` site with **no** `/cms` install (an **empty headless shell** — setup\n `type: headless_empty`, typically from onboarding) → **swap it to WordPress** (Step 2b). Do **not**\n call `start-setup` again — the website already exists; you only install WordPress into it.\n\n## Step 2 — Create a fresh headless WordPress site (only when no site exists)\n\n1. Pick the plan: `hosting_orders_list { \"statuses\": [\"active\"] }`. One active order → use it; several\n → ask the user which.\n2. Start the setup:\n\n ```json\n { \"operation\": \"hosting_websites_start-setup\",\n \"params\": { \"order_id\": <order_id>, \"type\": \"headless_wordpress\", \"domain\": \"<domain>\" } }\n ```\n\n `domain` must be a **customer-owned** domain. For a temporary `*.hostingersite.com` subdomain,\n **omit `domain`** — the setup generates one itself. Don't call a separate subdomain op to make one\n first: there's no need, and passing a generated free subdomain back as `domain` fails with\n `422 … Free subdomains cannot be provided`. The response then carries the generated domain:\n if it differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`,\n update the domain in `brief.md`, and tell the user the new domain.\n Admin credentials are generated by the platform; never ask the user for a password.\n3. Poll `hosting_websites_list-setups { \"domain\": \"<domain>\" }` every ~15 s until `completed`\n (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel.\n\n## Step 2b — Swap an empty headless shell to WordPress (only when onboarding already made the shell)\n\nThe website already exists as an empty `headless` shell, so there is **no `start-setup`** here — you\ninstall WordPress straight into its `/cms` directory. This is the onboarding handoff path.\n\n1. Use the shell's `username` and `order_id` from Step 1's row — no new order pick, no new website.\n2. Install WordPress into `/cms`, flagged headless:\n\n ```json\n { \"operation\": \"wordpress_installations_install\",\n \"params\": { \"username\": \"<username>\", \"domain\": \"<domain>\", \"type\": \"headless\",\n \"site_title\": \"<site title from brief.md>\",\n \"credentials\": { \"login\": \"<admin login>\", \"email\": \"<account email>\",\n \"password\": \"<strong generated password>\" } } }\n ```\n\n - `type: \"headless\"` installs into `/cms` with the Hostinger headless MU plugin and marks the site\n headless-WordPress; **`directory` is not allowed with it** (it is always `cms`). The target must\n already be `headless` — the headless install rewrites the root `.htaccess`, so the API rejects it\n with `422` on any non-headless site (which is why Step 1 confirms `website_type: headless` first).\n - **You generate the admin password** (strong, 8–50 chars, mixed case + a digit) — never ask the user\n for one and never print it. The user signs in later through a one-click login link (Step 6), so the\n password only has to exist, not be remembered.\n - A new database is created for the install unless you pass `database.name` of an existing one; the\n generated database counts against the plan's limit.\n3. The call is **asynchronous** — a success response only means the install job was **queued**. This is\n the *fire*: record `provision.in_progress` and return to prepare work. Poll\n `wordpress_installations_list { username, domain, ownership: \"all\" }` (exact row) at the join; when a\n row with `directory: \"cms\"` and `is_valid: true` appears, WordPress is ready. Allow 1–2 minutes;\n `wordpress_installations_list` can lag a few seconds behind the job finishing and return `[]` — retry\n briefly before failing.\n\n## Step 3 — Verify (all must pass)\n\n| Check | How | Expect |\n|---|---|---|\n| Site is headless | `hosting_websites_list` exact row | `website_type: \"headless\"` |\n| WordPress at `/cms` | `wordpress_installations_list { \"username\": \"<username>\", \"ownership\": \"all\" }`, exact-match `domain` | row with `directory: \"cms\"`, `is_valid: true`; note its `id` |\n| REST API answers | `GET https://<domain>/cms/wp-json/wp/v2/posts?per_page=1` | 200, JSON array |\n| AI Assistant + MCP loaded | `GET https://<domain>/cms/wp-json/` → `namespaces` | contains `hostinger-ai-assistant/v1` and `mcp` |\n| JWT mintable | `wordpress_installations_jwt-token { username, software: <id> }` | returns `token`, `mcp_url` |\n\nNotes:\n\n- `wordpress_installations_list` can lag a few seconds behind `completed` and return `[]` — retry\n briefly before failing. Its `domain` filter is a substring match, so exact-match the row.\n- The install record's `url` may say `http://`; always use `https://` URLs.\n- `GET https://<domain>/` returns 403 until the frontend is deployed — expected, not a failure.\n- **Never print, log or store the JWT.** It is minted on demand (24 h lifetime) by whichever stage\n needs the per-site WP MCP; only `mcp_url` is recorded.\n\nIf a check fails: retry once after ~30 s; if it still fails, stop and point the user at hPanel.\n\n## Step 4 — SEO plugin (Yoast) *(every WordPress site — on by default)*\n\nEvery headless WordPress site gets **Yoast SEO** (`wordpress-seo`) installed and activated by default,\nso per-page title/description/Open Graph/JSON-LD come from a maintained plugin's `yoast_head_json`\nrather than being hand-generated from each item's content by the frontend. This is **join-half** work\n(needs the live `/cms` install), so run it once `provision.status == \"completed\"`, the same way as the\nWooCommerce tail. Tell the user in one line that SEO (Yoast) is being set up; if they explicitly don't\nwant it, skip this step and record `seo.enabled: false` — but the default is to install it.\n\n1. **Install + activate Yoast** (management MCP, async — poll, never blind-retry):\n - `wordpress_plugins_install { username, software: <install_id>, plugins: [\"wordpress-seo\"] }`\n - Poll `wordpress_plugins_list-installed { username, software }` until `wordpress-seo` shows\n `active` (install + activate can complete together), or activate it with\n `wordpress_plugins_activate { username, software, plugin: \"wordpress-seo\" }`.\n - **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job\n succeeded**. Treat a 500 as \"verify, don't assume\": poll `list-installed` before retrying, and\n never fire a second install blindly.\n2. **Verify** `GET https://<domain>/cms/wp-json/` → `namespaces` once more, and that a sample post/page\n response carries `yoast_head_json`. The per-item Yoast meta (focus title/description per post) is set\n later in the **content-model push** — provision only installs + activates the plugin here. Don't fail\n provision over SEO: if the install can't be confirmed after one retry, record `seo.enabled: false`,\n tell the user, and let the frontend fall back to generating tags from content.\n\n## Step 5 — WooCommerce store *(only for a `WordPress + WooCommerce` brief — skip otherwise)*\n\nRun this only when the brief's **Suggested backend** is `**WordPress + WooCommerce**`. It turns the\nheadless WordPress site into a headless **store**: WooCommerce is the `woocommerce` plugin on this same\n`/cms` install — **not a separate backend**. This is **join-half** work (needs the live instance), so run\nit once `provision.status == \"completed\"`. The authoritative data-layer reference is\n[`../../04-content-model/wordpress/woocommerce.md`](../../04-content-model/wordpress/woocommerce.md).\n\n> **No custom plugin — the catalog is driven through the per-site WP MCP.** A custom plugin can't be\n> installed into a headless `/cms` from a coding agent, so there is **no seed/config plugin and no REST\n> consumer key in this flow.** Instead, the Hostinger AI Assistant (the per-site WP MCP, same JWT session\n> content-model uses) exposes **native WooCommerce abilities** — `wc-products-*`, `wc-product-categories-*`,\n> `wc-coupons-*`, `wc-orders-*`, `media-upload-from-url`, `wp-settings-*`. Provision just **installs\n> WooCommerce and verifies the store endpoints**; the catalog + coupons are seeded in the content-model\n> push through those abilities (see the cookbook).\n\n1. **Install + activate WooCommerce** (management MCP, async — poll, never blind-retry):\n - `wordpress_plugins_install { username, software: <install_id>, plugins: [\"woocommerce\"] }`\n - Poll `wordpress_plugins_list-installed { username, software }` until `woocommerce` shows `active`\n (install + activate can complete together), or activate it with\n `wordpress_plugins_activate { username, software, plugin: \"woocommerce\" }`.\n - Confirm with `wordpress_plugins_check-if-woo-commerce-is-installed { domain: \"<domain>\" }`. Allow a\n moment for WooCommerce's installer to create its tables and the Shop/Cart/Checkout pages.\n - **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job\n succeeded** (seen on both `plugins_install` and `jwt-token`). Treat a 500 as \"verify, don't assume\":\n poll `list-installed` / re-check before retrying, and never fire a second install blindly.\n2. **Verify the store endpoints (public, no auth):**\n\n | Check | How | Expect |\n |---|---|---|\n | WooCommerce active | `wordpress_plugins_check-if-woo-commerce-is-installed { domain }` | installed + active |\n | Store API answers | `GET https://<domain>/cms/wp-json/wc/store/v1/products?per_page=1` | 200 (empty until content-model seeds — fine) |\n | Both namespaces loaded | `GET https://<domain>/cms/wp-json/` → `namespaces` | contains `wc/store/v1` **and** `wc/v3` |\n\n Pretty permalinks are on by default on a headless install, so the Store API resolves immediately; if a\n store route ever 404s, the permalink structure is the thing to check.\n\n> **Checkout needs owner setup — treat it like the Ecommerce payment gate.** A fresh WooCommerce install\n> has **no payment gateway and no shipping zone enabled**, and that configuration is **not reachable from\n> the headless tooling** (there is no payment/shipping MCP ability, and `wp-settings-update` only writes\n> standard WP settings). So **checkout is unavailable until the owner enables a payment method + shipping\n> in wp-admin** (hand them the one-click login in Step 6). Record `woocommerce.payment_enabled: false`\n> accordingly. This is **not a build failure**: the catalog, product pages, client-side cart and coupon\n> application all work headlessly — the downstream frontend renders a graceful disabled-checkout state and\n> never calls `/checkout` while `payment_enabled` is `false` (see content-model + frontend). Never ask the\n> user for payment credentials. Store currency and address (`woocommerce_*` options) likewise aren't\n> settable headlessly — they follow the install default and the owner can change them in wp-admin.\n\n## Step 6 — Record `site.json`\n\n```json\n{\n \"backend\": \"wordpress\",\n \"website_type\": \"headless\",\n \"order_id\": 1007223428,\n \"username\": \"u…\",\n \"wordpress\": {\n \"install_id\": \"30…\",\n \"directory\": \"cms\",\n \"admin_login\": \"…\"\n },\n \"seo\": {\n \"plugin\": \"wordpress-seo\",\n \"enabled\": true\n },\n \"urls\": {\n \"site\": \"https://<domain>\",\n \"cms\": \"https://<domain>/cms\",\n \"wp_admin\": \"https://<domain>/cms/wp-admin\",\n \"rest_api\": \"https://<domain>/cms/wp-json/wp/v2\",\n \"wp_mcp\": \"<mcp_url from the JWT call>\"\n },\n \"provisioned_at\": \"…\",\n \"verified_at\": \"…\"\n}\n```\n\nNo passwords or tokens in `site.json`. When the user needs wp-admin, mint a one-click link with\n`wordpress_login_create-links { username, software: <install_id> }` — never hand over a password.\n\n**For a `WordPress + WooCommerce` brief, also add a `woocommerce` block and the store URLs** (the\n`backend` field still stays `\"wordpress\"`):\n\n```json\n{\n \"woocommerce\": {\n \"enabled\": true,\n \"seeded_via\": \"per-site-wp-mcp\",\n \"currency\": \"<currency_code from GET {store_api}/products or the install default>\",\n \"payment_enabled\": false,\n \"checkout_setup\": \"owner enables a payment method + shipping in wp-admin\"\n },\n \"urls\": {\n \"store_api\": \"https://<domain>/cms/wp-json/wc/store/v1\",\n \"wc_rest_api\": \"https://<domain>/cms/wp-json/wc/v3\"\n }\n}\n```\n\n`rest_api` stays `wp/v2` (the WordPress content contract, for the blog/pages on the same site);\n`store_api` is the public storefront surface (browse/cart/coupons/checkout, no auth). `wc_rest_api`\n(`wc/v3`) is recorded only as the surface the **owner's own** integrations use with a consumer key they\ngenerate in wp-admin — **this flow never creates or stores a key** (the catalog is seeded through the\nper-site WP MCP's `wc-*` abilities; the storefront uses the public Store API). `payment_enabled` starts\n`false` and flips to `true` only once a gateway + shipping are configured — no secrets in `site.json`.\n"
175
+ "text": "---\nname: headless-step-provision-wordpress\ndescription: \"Provision a headless WordPress backend for the Hostinger Headless flow: create the headless website with WordPress at /cms, verify and self-heal, then record it in site.json.\"\n---\n\n# headless-step-provision-wordpress\n\nCreate the headless WordPress backend for `<domain>` and prove it is ready. One platform call lays\nevery headless site out identically: WordPress in `public_html/cms`, Hostinger AI Assistant + MCP\nadapter, site marked `headless`. **Out:** `site.json`.\n\n> **WordPress + WooCommerce?** This same recipe serves it. When the brief's **Suggested backend** is\n> `**WordPress + WooCommerce**`, provision the WordPress site exactly as below, then run the **optional\n> WooCommerce tail in Step 5** (install + activate the `woocommerce` plugin and verify the store\n> endpoints; the catalog is seeded in content-model via the per-site WP MCP).\n> WooCommerce is a plugin on this WordPress install, **not** a separate backend: `site.json → backend`\n> stays `\"wordpress\"` and the store is recorded under `site.json → woocommerce`. For a plain WordPress\n> brief, skip Step 5 entirely.\n\nAll operations below go through the Hostinger connector (`execute`).\n\n> **Fire vs join (background provision).** Step 1, plus the queuing call — `hosting_websites_start-setup`\n> for a fresh site (Step 2) **or** `wordpress_installations_install` to swap an empty headless shell\n> (Step 2b) — are the **fire** half: once the job is queued, record `provision.in_progress` and return\n> to do prepare work (see the router `../SKILL.md`). The readiness **poll** at the end of Step 2 / 2b,\n> plus Steps 3–6, are the **join** half — run them when an instance-barrier step needs the backend (or\n> immediately, if the agent can't interleave). An already-`completed` site with WordPress at `/cms`\n> detected in Step 1 means provision is already done: record `provision.completed` and adopt it.\n\n## Step 1 — Detect before you create (never provision twice)\n\n1. `hosting_websites_list { \"domain\": \"<domain>\" }` — substring filter, so **exact-match** the row's\n `domain`. If a row exists, note `username`, `order_id`, `website_type`.\n2. **No exact row** → nothing exists on this domain → **create fresh** (Step 2).\n3. **A row exists.** If `website_type` is **not** `headless`, stop and tell the user — this domain\n already hosts a different kind of site (plain WordPress, Node.js, builder, …); never overwrite it.\n If it **is** `headless`, work out whether WordPress is already installed or the shell is still empty\n (see the router's *Onboarding handoff*): check `hosting_websites_list-setups { \"domain\": \"<domain>\" }`\n (exact row) and `wordpress_installations_list { username, domain, ownership: \"all\" }` (exact row):\n - Setup `running` → provisioning is underway → skip to Step 2's poll.\n - Setup `failed` → tell the user and point them at hPanel; do **not** call start-setup again yourself.\n - A WordPress install with `directory: \"cms\"` already present (or setup `type: headless_wordpress`)\n → **already provisioned** → skip to Step 3 (adopt it).\n - A `headless` site with **no** `/cms` install (an **empty headless shell** — setup\n `type: headless_empty`, typically from onboarding) → **swap it to WordPress** (Step 2b). Do **not**\n call `start-setup` again — the website already exists; you only install WordPress into it.\n\n## Step 2 — Create a fresh headless WordPress site (only when no site exists)\n\n1. Pick the plan: `hosting_orders_list { \"statuses\": [\"active\"] }`. One active order → use it; several\n → ask the user which.\n2. Start the setup:\n\n ```json\n { \"operation\": \"hosting_websites_start-setup\",\n \"params\": { \"order_id\": <order_id>, \"type\": \"headless_wordpress\", \"domain\": \"<domain>\" } }\n ```\n\n `domain` must be a **customer-owned** domain. For a temporary `*.hostingersite.com` subdomain,\n **omit `domain`** — the setup generates one itself. Don't call a separate subdomain op to make one\n first: there's no need, and passing a generated free subdomain back as `domain` fails with\n `422 … Free subdomains cannot be provided`. The response then carries the generated domain:\n if it differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`,\n update the domain in `brief.md`, and tell the user the new domain.\n Admin credentials are generated by the platform; never ask the user for a password.\n3. Poll `hosting_websites_list-setups { \"domain\": \"<domain>\" }` every ~15 s until `completed`\n (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel.\n\n## Step 2b — Swap an empty headless shell to WordPress (only when onboarding already made the shell)\n\nThe website already exists as an empty `headless` shell, so there is **no `start-setup`** here — you\ninstall WordPress straight into its `/cms` directory. This is the onboarding handoff path.\n\n1. Use the shell's `username` and `order_id` from Step 1's row — no new order pick, no new website.\n2. Install WordPress into `/cms`, flagged headless:\n\n ```json\n { \"operation\": \"wordpress_installations_install\",\n \"params\": { \"username\": \"<username>\", \"domain\": \"<domain>\", \"type\": \"headless\",\n \"site_title\": \"<site title from brief.md>\",\n \"credentials\": { \"login\": \"<admin login>\", \"email\": \"<account email>\",\n \"password\": \"<strong generated password>\" } } }\n ```\n\n - `type: \"headless\"` installs into `/cms` with the Hostinger headless MU plugin and marks the site\n headless-WordPress; **`directory` is not allowed with it** (it is always `cms`). The target must\n already be `headless` — the headless install rewrites the root `.htaccess`, so the API rejects it\n with `422` on any non-headless site (which is why Step 1 confirms `website_type: headless` first).\n - **You generate the admin password** (strong, 8–50 chars, mixed case + a digit) — never ask the user\n for one and never print it. The user signs in later through a one-click login link (Step 6), so the\n password only has to exist, not be remembered.\n - A new database is created for the install unless you pass `database.name` of an existing one; the\n generated database counts against the plan's limit.\n3. The call is **asynchronous** — a success response only means the install job was **queued**. This is\n the *fire*: record `provision.in_progress` and return to prepare work. Poll\n `wordpress_installations_list { username, domain, ownership: \"all\" }` (exact row) at the join; when a\n row with `directory: \"cms\"` and `is_valid: true` appears, WordPress is ready. Allow 1–2 minutes;\n `wordpress_installations_list` can lag a few seconds behind the job finishing and return `[]` — retry\n briefly before failing.\n\n## Step 3 — Verify (all must pass)\n\n| Check | How | Expect |\n|---|---|---|\n| Site is headless | `hosting_websites_list` exact row | `website_type: \"headless\"` |\n| WordPress at `/cms` | `wordpress_installations_list { \"username\": \"<username>\", \"ownership\": \"all\" }`, exact-match `domain` | row with `directory: \"cms\"`, `is_valid: true`; note its `id` |\n| REST API answers | `GET https://<domain>/cms/wp-json/wp/v2/posts?per_page=1` | 200, JSON array |\n| AI Assistant + MCP loaded | `GET https://<domain>/cms/wp-json/` → `namespaces` | contains `hostinger-ai-assistant/v1` and `mcp` |\n| JWT mintable | `wordpress_installations_jwt-token { username, software: <id> }` | returns `token`, `mcp_url` |\n\nNotes:\n\n- `wordpress_installations_list` can lag a few seconds behind `completed` and return `[]` — retry\n briefly before failing. Its `domain` filter is a substring match, so exact-match the row.\n- The install record's `url` may say `http://`; always use `https://` URLs.\n- `GET https://<domain>/` returns 403 until the frontend is deployed — expected, not a failure.\n- **Never print, log or store the JWT.** It is minted on demand (24 h lifetime) by whichever stage\n needs the per-site WP MCP; only `mcp_url` is recorded.\n\nIf a check fails: retry once after ~30 s; if it still fails, stop and point the user at hPanel.\n\n## Step 4 — SEO plugin (Yoast) *(every WordPress site — on by default)*\n\nEvery headless WordPress site gets **Yoast SEO** (`wordpress-seo`) installed and activated by default,\nso per-page title/description/Open Graph/JSON-LD come from a maintained plugin's `yoast_head_json`\nrather than being hand-generated from each item's content by the frontend. This is **join-half** work\n(needs the live `/cms` install), so run it once `provision.status == \"completed\"`, the same way as the\nWooCommerce tail. Tell the user in one line that SEO (Yoast) is being set up; if they explicitly don't\nwant it, skip this step and record `seo.enabled: false` — but the default is to install it.\n\n1. **Install + activate Yoast** (management MCP, async — poll, never blind-retry):\n - `wordpress_plugins_install { username, software: <install_id>, plugins: [\"wordpress-seo\"] }`\n - Poll `wordpress_plugins_list-installed { username, software }` until `wordpress-seo` shows\n `active` (install + activate can complete together), or activate it with\n `wordpress_plugins_activate { username, software, plugin: \"wordpress-seo\" }`.\n - **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job\n succeeded**. Treat a 500 as \"verify, don't assume\": poll `list-installed` before retrying, and\n never fire a second install blindly.\n2. **Verify** `GET https://<domain>/cms/wp-json/` → `namespaces` once more, and that a sample post/page\n response carries `yoast_head_json`. The per-item Yoast meta (focus title/description per post) is set\n later in the **content-model push** — provision only installs + activates the plugin here. Don't fail\n provision over SEO: if the install can't be confirmed after one retry, record `seo.enabled: false`,\n tell the user, and let the frontend fall back to generating tags from content.\n\n## Step 5 — WooCommerce store *(only for a `WordPress + WooCommerce` brief — skip otherwise)*\n\nRun this only when the brief's **Suggested backend** is `**WordPress + WooCommerce**`. It turns the\nheadless WordPress site into a headless **store**: WooCommerce is the `woocommerce` plugin on this same\n`/cms` install — **not a separate backend**. This is **join-half** work (needs the live instance), so run\nit once `provision.status == \"completed\"`. The authoritative data-layer reference is\n[`../../04-content-model/wordpress/woocommerce.md`](../../04-content-model/wordpress/woocommerce.md).\n\n> **No custom plugin — the catalog is driven through the per-site WP MCP.** A custom plugin can't be\n> installed into a headless `/cms` from a coding agent, so there is **no seed/config plugin and no REST\n> consumer key in this flow.** Instead, the Hostinger AI Assistant (the per-site WP MCP, same JWT session\n> content-model uses) exposes **native WooCommerce abilities** — `wc-products-*`, `wc-product-categories-*`,\n> `wc-coupons-*`, `wc-orders-*`, `media-upload-from-url`, `wp-settings-*`. Provision just **installs\n> WooCommerce and verifies the store endpoints**; the catalog + coupons are seeded in the content-model\n> push through those abilities (see the cookbook).\n\n1. **Install + activate WooCommerce** (management MCP, async — poll, never blind-retry):\n - `wordpress_plugins_install { username, software: <install_id>, plugins: [\"woocommerce\"] }`\n - Poll `wordpress_plugins_list-installed { username, software }` until `woocommerce` shows `active`\n (install + activate can complete together), or activate it with\n `wordpress_plugins_activate { username, software, plugin: \"woocommerce\" }`.\n - Confirm with `wordpress_plugins_check-if-woo-commerce-is-installed { domain: \"<domain>\" }`. Allow a\n moment for WooCommerce's installer to create its tables and the Shop/Cart/Checkout pages.\n - **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job\n succeeded** (seen on both `plugins_install` and `jwt-token`). Treat a 500 as \"verify, don't assume\":\n poll `list-installed` / re-check before retrying, and never fire a second install blindly.\n2. **Verify the store endpoints (public, no auth):**\n\n | Check | How | Expect |\n |---|---|---|\n | WooCommerce active | `wordpress_plugins_check-if-woo-commerce-is-installed { domain }` | installed + active |\n | Store API answers | `GET https://<domain>/cms/wp-json/wc/store/v1/products?per_page=1` | 200 (empty until content-model seeds — fine) |\n | Both namespaces loaded | `GET https://<domain>/cms/wp-json/` → `namespaces` | contains `wc/store/v1` **and** `wc/v3` |\n\n Pretty permalinks are on by default on a headless install, so the Store API resolves immediately; if a\n store route ever 404s, the permalink structure is the thing to check.\n\n> **Checkout needs owner setup — treat it like the Ecommerce payment gate.** A fresh WooCommerce install\n> has **no payment gateway and no shipping zone enabled**, and that configuration is **not reachable from\n> the headless tooling** (there is no payment/shipping MCP ability, and `wp-settings-update` only writes\n> standard WP settings). So **checkout is unavailable until the owner enables a payment method + shipping\n> in wp-admin** (hand them the one-click login in Step 6). Record `woocommerce.payment_enabled: false`\n> accordingly. This is **not a build failure**: the catalog, product pages, client-side cart and coupon\n> application all work headlessly — the downstream frontend renders a graceful disabled-checkout state and\n> never calls `/checkout` while `payment_enabled` is `false` (see content-model + frontend). Never ask the\n> user for payment credentials. Store currency and address (`woocommerce_*` options) likewise aren't\n> settable headlessly — they follow the install default and the owner can change them in wp-admin.\n\n## Step 5b — Contact form (Contact Form 7) *(only when the brief's Must-haves include a contact form — skip otherwise)*\n\nRun this only when the brief's **Must-haves** include a contact form. It installs **Contact Form 7**\n(`contact-form-7`) on this same `/cms` install, so the frontend can render a contact page whose form\nemails the site owner through WordPress's built-in `wp_mail()` — no third-party service and no API keys.\nLike Yoast/WooCommerce this is **join-half** work: run it once `provision.status == \"completed\"`. The form\nitself (id + fields + recipient) — and a tiny **spam-guard plugin** that makes a honeypot work headlessly —\nare resolved/deployed in the content-model push — see\n[`../../04-content-model/wordpress/contact-form.md`](../../04-content-model/wordpress/contact-form.md).\n\n> **Why CF7 and not a bespoke mailer.** A contact form is maintained, spam-hardened functionality worth\n> reusing rather than reinventing (you *can* deploy a custom plugin to a headless `/cms` — see the\n> content-model cookbook — but you needn't write your own mailer). CF7 is a wordpress.org-slug plugin,\n> installs through the same management MCP as Yoast, and ships a **public, unauthenticated** submit\n> endpoint (`POST /cms/wp-json/contact-form-7/v1/contact-forms/<id>/feedback`). The frontend and `/cms`\n> are same-origin, so the browser posts to it directly — no proxy, no CORS, no secret on the client.\n\n1. **Install + activate CF7** (management MCP, async — poll, never blind-retry):\n - `wordpress_plugins_install { username, software: <install_id>, plugins: [\"contact-form-7\"] }`\n - Poll `wordpress_plugins_list-installed { username, software }` until `contact-form-7` shows `active`\n (install + activate can complete together), or activate it with\n `wordpress_plugins_activate { username, software, plugin: \"contact-form-7\" }`.\n - **Don't install the `contact-form-7-honeypot` add-on.** It only acts when a `[honeypot]` form-tag is\n present in the form — a wp-admin edit the headless agent can't make — so on the default form it does\n nothing. Headless spam protection instead comes from a small **spam-guard plugin deployed in the\n content-model push** (it hooks `wpcf7_spam`); see the cookbook.\n - **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job\n succeeded**. Treat a 500 as \"verify, don't assume\": poll `list-installed` before retrying, and\n never fire a second install blindly.\n2. **Verify** `GET https://<domain>/cms/wp-json/` lists the **`contact-form-7/v1`** namespace, then\n record `contact_form.enabled: true` in `site.json` (Step 6). If the install can't be confirmed after\n one retry, record `contact_form.enabled: false`, tell the user in one line, and let the frontend\n render the contact page without a form (graceful — not a build failure). Don't fail provision over it.\n\nThe owner's email is **CF7's default recipient** (`[_site_admin_email]`, the admin address from\ninstall) — fine out of the box, so \"does Hostinger set up the email?\" is **yes** for basic delivery via\n`wp_mail`. Deliverability (SPF/DKIM) is most reliable when that address is a mailbox **on the same\ndomain**; the content-model push keeps the default recipient and validate sends a test submission to\nconfirm mail actually leaves. Never ask the user for mail credentials.\n\n## Step 6 — Record `site.json`\n\n```json\n{\n \"backend\": \"wordpress\",\n \"website_type\": \"headless\",\n \"order_id\": 1007223428,\n \"username\": \"u…\",\n \"wordpress\": {\n \"install_id\": \"30…\",\n \"directory\": \"cms\",\n \"admin_login\": \"…\"\n },\n \"seo\": {\n \"plugin\": \"wordpress-seo\",\n \"enabled\": true\n },\n \"urls\": {\n \"site\": \"https://<domain>\",\n \"cms\": \"https://<domain>/cms\",\n \"wp_admin\": \"https://<domain>/cms/wp-admin\",\n \"rest_api\": \"https://<domain>/cms/wp-json/wp/v2\",\n \"wp_mcp\": \"<mcp_url from the JWT call>\"\n },\n \"provisioned_at\": \"…\",\n \"verified_at\": \"…\"\n}\n```\n\nNo passwords or tokens in `site.json`. When the user needs wp-admin, mint a one-click link with\n`wordpress_login_create-links { username, software: <install_id> }` — never hand over a password.\n\n**For a `WordPress + WooCommerce` brief, also add a `woocommerce` block and the store URLs** (the\n`backend` field still stays `\"wordpress\"`):\n\n```json\n{\n \"woocommerce\": {\n \"enabled\": true,\n \"seeded_via\": \"per-site-wp-mcp\",\n \"currency\": \"<currency_code from GET {store_api}/products or the install default>\",\n \"payment_enabled\": false,\n \"checkout_setup\": \"owner enables a payment method + shipping in wp-admin\"\n },\n \"urls\": {\n \"store_api\": \"https://<domain>/cms/wp-json/wc/store/v1\",\n \"wc_rest_api\": \"https://<domain>/cms/wp-json/wc/v3\"\n }\n}\n```\n\n**For a brief with a contact form (CF7 installed in Step 5b), also add a `contact_form` block.**\nProvision records only `enabled` + `plugin` here; the content-model push fills `form_id`,\n`feedback_url`, `fields` and `honeypot_field` once it resolves the form and deploys the spam guard:\n\n```json\n{\n \"contact_form\": {\n \"enabled\": true,\n \"plugin\": \"contact-form-7\",\n \"form_id\": null,\n \"feedback_url\": null,\n \"fields\": [],\n \"honeypot_field\": null,\n \"recipient\": \"site-admin-email\"\n }\n}\n```\n\n`form_id` / `feedback_url` / `fields` / `honeypot_field` stay `null` / `[]` until content-model resolves the form (the\nsubmit URL is `https://<domain>/cms/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`).\n`recipient` is informational — CF7's `To` is `[_site_admin_email]`; no address, token or secret is\nstored in `site.json`.\n\n`rest_api` stays `wp/v2` (the WordPress content contract, for the blog/pages on the same site);\n`store_api` is the public storefront surface (browse/cart/coupons/checkout, no auth). `wc_rest_api`\n(`wc/v3`) is recorded only as the surface the **owner's own** integrations use with a consumer key they\ngenerate in wp-admin — **this flow never creates or stores a key** (the catalog is seeded through the\nper-site WP MCP's `wc-*` abilities; the storefront uses the public Store API). `payment_enabled` starts\n`false` and flips to `true` only once a gateway + shipping are configured — no secrets in `site.json`.\n"
176
176
  },
177
177
  {
178
178
  "uri": "skill://hostinger-headless/steps/04-content-model/SKILL.md",
@@ -190,10 +190,24 @@ export default [
190
190
  },
191
191
  {
192
192
  "uri": "skill://hostinger-headless/steps/04-content-model/wordpress/SKILL.md",
193
- "digest": "sha256:2eef243dd909cf6d8b4ca78ab61b99e3b35e997a7affbd5bc9c2520c27b614ed",
194
- "size": 14853,
193
+ "digest": "sha256:eda9fa894c0ac6609c5af855df166c5273c39f0661fa6f527a26aacf6bc9f058",
194
+ "size": 19608,
195
195
  "mimeType": "text/markdown",
196
- "text": "---\nname: headless-step-content-model-wordpress\ndescription: \"Push phase of Step 4 for a headless WordPress backend: open an authenticated session to the per-site WP MCP with the installation JWT, then create the content helper's Title/Description, categories, media, pages, posts and navigation through the MCP ability tools. wp/v2 REST is the public read surface, not a write path for this token. Idempotent, instance-gated.\"\n---\n\n# headless-step-content-model-wordpress\n\n**Push phase — needs the live instance.** Run only after `provision.status == \"completed\"` (re-verify\nagainst `hosting_websites_list-setups`). It maps the backend-agnostic `content-model.md` onto real\nWordPress objects.\n\n**In:** `content-model.md`, `site.json` (both prepared earlier), generated media under\n`~/.hostinger/<domain>/media/`. **Out:** content created in WordPress; created IDs recorded back into\n`content-model.md`.\n\n## What creates what — and what authenticates it\n\nThe Hostinger **management** MCP (`wordpress_*`) is infra-only: it installs WordPress, plugins and\nthemes but has **no content endpoints**.\n\nWrites go through the **per-site WP MCP** (the Hostinger AI Assistant plugin), authenticated with the\ninstallation JWT. **The JWT authenticates the MCP endpoint only** — it is wired as the MCP server's\n`authenticate_request`, and the plugin registers no global `determine_current_user` /\n`rest_authentication_errors` filter. So a raw `POST /wp/v2/posts` with this token is **not**\nauthenticated; the admin user context (`wp_set_current_user`) exists only *inside* an MCP session.\n\n| Surface | Base (from `site.json`) | Role |\n|---|---|---|\n| **Per-site WP MCP** | `urls.wp_mcp` (`mcp_url`) | **all writes** — create content through its ability tools |\n| **WP REST API** `wp/v2` | `urls.rest_api` | **public reads only** (no auth needed) — what the Step 6 frontend consumes; do not write to it with the JWT |\n\n> **The WordPress content model is posts, pages and categories** (plus media). For **SEO**, **Yoast**\n> is already installed + activated at provision (every headless WordPress site — see provision Step 4),\n> so meta + schema come from a maintained plugin and the frontend reads each item's `yoast_head_json`.\n> This push only sets per-item Yoast meta (Step 4.7 below).\n\n> **A store on this site? See [`woocommerce.md`](woocommerce.md).** When the brief's **Suggested\n> backend** is `**WordPress + WooCommerce**` (`site.json → woocommerce.enabled: true`), this push also\n> seeds the WooCommerce catalog — products, categories and coupons — through the **same per-site WP MCP\n> session**, which exposes native `wc-products-*`, `wc-product-categories-*` and `wc-coupons-*` abilities\n> (no custom plugin, no consumer key). Do the WordPress content below as normal, then run the **WooCommerce\n> catalog** section before you hand off.\n\n## Step 1 — Open the authenticated MCP session\n\nMint a short-lived token through the Hostinger connector:\n\n```json\n{ \"operation\": \"wordpress_installations_jwt-token\",\n \"params\": { \"username\": \"<site.json username>\", \"software\": \"<site.json wordpress.install_id>\" } }\n```\n\nReturns `token` (+ `mcp_url`, already stored as `urls.wp_mcp`). Connect to `urls.wp_mcp` as an MCP\nclient (`initialize` → `tools/list` → `tools/call`) and send the token on **every** request as:\n\n- `X-Hostinger-JWT-Token: <token>` — the plugin's preferred header (checked first), **or**\n- `Authorization: Bearer <token>` — accepted as a fallback (case-insensitive `bearer ` prefix).\n\nThe token lives ~24 h — mint on demand, **never print, log, or write it to `site.json` /\n`content-model.md`**. If a call returns `401/403` with `token_invalid`/`invalid_api_key`, the token\nexpired or was revoked → re-mint and reconnect.\n\n> **Transport gotchas (streamable HTTP MCP).** The endpoint is session-based: `initialize` returns an\n> **`Mcp-Session-Id` response header** — capture it and send it on **every** subsequent request\n> (`tools/list`, `tools/call`), or they fail with `-32600 Missing Mcp-Session-Id header`. Send\n> `Accept: application/json, text/event-stream`. After `initialize`, post the `notifications/initialized`\n> notification, and **tolerate a non-2xx on it** (it can answer 404 with no body — harmless; don't let\n> it abort the session). A raw curl/HTTP client works fine for all this.\n\n## Step 2 — Discover the ability tools\n\nThe server advertises either every ability as its own tool, or three **discovery meta-tools**\n(discover → get-info → execute) to keep `tools/list` small. Call `tools/list` first and work with\nwhat's there; with discovery, list abilities, then execute by ability name. The content tools that\ncover this step (exact names come from discovery — don't hardcode them):\n\n`SettingsTools` · `CategoriesTools` · `TagsTools` · `MediaUploadTools` / `MediaTools` · `PagesTools` ·\n`PostsTools` · `MenusTools` / `NavigationTools` · `SeoTools`. A generic\n`RestEndpointTool` can reach any `wp/v2` route *through the authenticated session* if no dedicated tool\nfits.\n\nSeen on a live install (Hostinger AI Assistant, discovery mode): the meta-tools are\n`hostinger-ai-discover-abilities`, `hostinger-ai-get-ability-info { name }` and\n`hostinger-ai-execute-ability { name, parameters }`. The abilities are named\n`hostinger-ai/<thing>-<action>`, e.g. `posts-create`, `posts-update`, `media-upload-from-url`,\n`media-delete`. Call `get-ability-info` for each schema before using it. Results come back as\n`{ ability, result: { data, … } }` for lists and `{ ability, result: {…} }` for single objects — so a\ncreate answers **`200`** (not `201`) and the new object's `id` is **inside** `result` (`result.id`, or\n`result.data.id`), never at the top level. A missing top-level `id` is not a failure; read it from\n`result`.\n\n## Step 3 — Idempotency rule (makes resume safe)\n\nEvery create is **list/get-by-slug first, then create only if absent** — a re-run must not duplicate.\nUse the matching read ability (or `RestEndpointTool` → `GET /wp/v2/posts?slug=…&status=any`): a hit →\nreuse its `id`; empty → create. Capture each returned `id` and record the id map back into\n`content-model.md` so later references resolve without re-querying.\n\n## Step 4 — Create in dependency order\n\n0. **Remove WordPress's default content first.** A fresh install ships a **\"Hello world!\" post**\n (id 1), a **\"Sample Page\"**, and a default comment — they will otherwise show up on the live site.\n Delete the default post (`posts-delete { id: 1, force: true }`) and, if present, the sample page\n (find it via `pages-search` for slug `sample-page`, then `pages-delete`). This is idempotent: if\n they're already gone, skip. Leave the default `Uncategorized` category — it's harmless once no post\n uses it (the frontend only lists categories with posts).\n1. **Site Title & Description** (the helper's global part) → the Settings ability.\n2. **Categories / tags** — one per term in the helper → Categories / Tags abilities. Keep `name → id`.\n3. **Media.** Upload every image in the helper, set `alt_text`, and keep `filename → id`. See\n *Media upload* below for all sources: a Hostinger-AI image URL, an Unsplash URL, or a generated\n file in `media/`. For a Hostinger-AI image, generate it now (its URL is temporary — see the\n content-model `SKILL.md`, *Images* Option A) and upload straight away, before `expires_at`.\n4. **Pages** — each static/index page → Pages ability. Pass `title`, `content` and `excerpt` as\n **`{ \"raw\": \"…\" }` objects** (see the note below), plus `slug`, `status: publish`. Keep `slug → id`.\n5. **Posts (articles)** — each → Posts ability: `title`, `content` and `excerpt` as **`{ \"raw\": \"…\" }`**\n (note below), plus `slug`, `status: publish`, `categories: [id]`, `tags: [id…]`,\n `featured_media: <id>`, `date`. (The plugin block-converts post content on insert via\n `rest_pre_insert_post`, so HTML bodies in `content.raw` are fine.)\n\n > **Create payload gotcha (`pages-create` / `posts-create`).** These abilities take `title`,\n > `content` and `excerpt` as WordPress's core **`{ \"raw\": \"…\" }` objects**, not bare strings. Pass a\n > bare string and the field is silently dropped — the create still answers `200`, so you get a\n > *published-but-empty* page or post with nothing to key off. Always send `{ \"raw\": \"…\" }` and\n > read the new `id` from `result` (see Step 2). Confirm the exact field shape with\n > `get-ability-info` if a schema differs.\n6. **Navigation** — Menus / Navigation abilities: create the menu, then an item per nav entry\n referencing a page `id` (or a custom URL for listing routes). **Headless note:** the Step 6\n frontend renders nav from `content-model.md`, so the WP menu is for owners editing in wp-admin, not\n what the live site reads — create it, but it isn't the source of truth.\n7. **SEO** — **Yoast SEO** (`wordpress-seo`) is already installed + activated at provision (Step 4 of\n the WordPress provision recipe), so it generates meta + schema and the frontend reads\n `yoast_head_json` per post/page. Here, just set each post's Yoast title/description if an ability\n exposes it; otherwise the excerpt + title are a fine fallback. Don't fail the push over SEO fields.\n If `site.json → seo.enabled` is `false` (the owner declined Yoast, or provision couldn't confirm the\n install), skip this and let the frontend generate tags from content — don't try to install the\n plugin from the content stage.\n\n### Media upload\n\n**From a URL (a Hostinger-AI image, Unsplash, or any direct file URL).** Use\n`hostinger-ai/media-upload-from-url`. WordPress downloads the file and stores its own copy, so the\nsite never depends on the external URL staying up — which is exactly why a **Hostinger-AI** image\n(whose `url` from `wordpress_ai-content_generation-result` is temporary) must be uploaded here, while\nit is still valid; drop the Unsplash-only `caption`/`description` for AI images:\n\n```json\n{ \"name\": \"hostinger-ai/media-upload-from-url\",\n \"parameters\": {\n \"url\": \"https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max\",\n \"filename\": \"<site>-<subject>.jpg\",\n \"title\": \"<short title>\",\n \"alt_text\": \"<what the photo shows>\",\n \"caption\": \"Photo by <Photographer> on Unsplash\",\n \"description\": \"Source: https://unsplash.com/photos/<id> (Unsplash License)\",\n \"post_id\": <post id, if the post already exists> } }\n```\n\n- `url` must return 200 with an image content type. A photo *page* URL fails.\n- The result carries the attachment `id` and `source_url`. WordPress also generates the smaller\n sizes (`300x200`, `768x…`, `1024x…`, …) under `media_details.sizes`, which the frontend can use\n for `srcset`.\n- Set `filename` yourself, so files in `wp-content/uploads/` have readable names.\n\n**From a local file in `media/`.** Use the Media-upload ability that discovery lists for local\nfiles.\n\n**Set the featured image.** Pass `featured_media: <id>` when creating the post (Step 4.5). If the\npost already exists (on resume, or when images are added later), use\n`hostinger-ai/posts-update { id, featured_media }`.\n\n**Idempotency trap: don't look up earlier uploads by filename.** `media-search` matches titles and\ncontent, not filenames or the slug you passed. A search for `<site>-lisbon-tram` finds nothing even\nthough the file exists, so a re-run would upload the image again. WordPress then stores the duplicate\nas `…-1.jpg` and silently repoints the post at it. Check this way instead:\n\n1. `posts-get { id }`. If `featured_media` is already set, skip that post. Its image exists.\n2. Otherwise, search by the **title** you set on upload (`media-search { search: \"<title>\" }`),\n and reuse a hit.\n3. Only upload when both checks come up empty.\n\nIf duplicates do slip in, point each post back at the original with `posts-update`, then delete the\nduplicates with `media-delete { id, force: true }`.\n\n## Step 5 — Verify and record\n\n- Read a couple of created objects back through `wp/v2` (public GET, no auth): e.g.\n `GET {urls.rest_api}/posts?per_page=1` and check the `X-WP-Total` header against the planned article\n count; fetch one post with `_embed` to confirm `featured_media` and `categories` resolved.\n- Media: `GET {urls.rest_api}/media?per_page=1` → `X-WP-Total` equals the number of images planned\n (more means duplicates). `GET {urls.rest_api}/media?include=<ids>` confirms `alt_text` and\n `caption` on each image. Each `source_url` must return 200 with an image type. Each post's\n `featured_media` must point at its image.\n- Write the created-id map into `content-model.md` (a `## Created in WordPress` section: object → slug\n → id) so the frontend data phase and any re-run resolve references directly. Include one row per\n media item: filename, credit and source page, the post it's featured on, and its id.\n\n## WooCommerce catalog *(only for a `WordPress + WooCommerce` brief — skip otherwise)*\n\nWhen `site.json → woocommerce.enabled` is `true`, also seed the store **in this same authenticated MCP\nsession**, using WooCommerce abilities the Assistant exposes alongside the content ones. Full recipe (incl.\nthe storefront Store API and the checkout gate): [`woocommerce.md`](woocommerce.md). In short:\n\n1. **Product categories** — `wc-product-categories-list` (reuse by slug/name) else\n `wc-product-categories-create { name, slug }`. Keep `name → id`.\n2. **Products** — **idempotent by SKU**: `wc-products-search { sku }` → reuse, else\n `wc-products-create { name, type:\"simple\", sku, regular_price:\"<decimal>\", description, status:\"publish\",\n categories:[{id}] }`. For images, `media-upload-from-url` then `wc-products-update { id, images:[{id}] }`.\n3. **Coupons** — **idempotent by code**: `wc-coupons-search { code }` → reuse, else\n `wc-coupons-create { code, discount_type, amount }`.\n4. **Verify over the public Store API** (no auth): `GET {urls.store_api}/products?per_page=100` count +\n `prices` (minor units), `GET {urls.store_api}/products/categories`. Record the SKU→id map, category ids\n and coupon codes into `content-model.md` under `## Created in WooCommerce`.\n\n> **Don't seed or test checkout here.** A fresh WooCommerce install has no payment gateway/shipping and\n> that config isn't reachable headlessly (`site.json → woocommerce.payment_enabled` is `false` — the owner\n> finishes it in wp-admin). Seeding + verifying the catalog over the Store API is the whole job; never call\n> `/checkout` or block on it. See the cookbook §4.\n\n## Step 6 — Hand off\n\nUpdate `state.json` (`provision` stays `completed`; advance `stage` toward the frontend **data**\nphase) with a fresh `updated_at`. The backend now holds the content; Step 6's data wiring points the\nfrontend at `urls.rest_api` (and, for a store, `urls.store_api`).\n"
196
+ "text": "---\nname: headless-step-content-model-wordpress\ndescription: \"Push phase of Step 4 for a headless WordPress backend: open an authenticated session to the per-site WP MCP with the installation JWT, then create the content helper's Title/Description, categories, media, pages, posts and navigation through the MCP ability tools. wp/v2 REST is the public read surface, not a write path for this token. Idempotent, instance-gated.\"\n---\n\n# headless-step-content-model-wordpress\n\n**Push phase — needs the live instance.** Run only after `provision.status == \"completed\"` (re-verify\nagainst `hosting_websites_list-setups`). It maps the backend-agnostic `content-model.md` onto real\nWordPress objects.\n\n**In:** `content-model.md`, `site.json` (both prepared earlier), generated media under\n`~/.hostinger/<domain>/media/`. **Out:** content created in WordPress; created IDs recorded back into\n`content-model.md`.\n\n## What creates what — and what authenticates it\n\nThe Hostinger **management** MCP (`wordpress_*`) is infra-only: it installs WordPress, plugins and\nthemes but has **no content endpoints**.\n\nWrites go through the **per-site WP MCP** (the Hostinger AI Assistant plugin), authenticated with the\ninstallation JWT. **The JWT authenticates the MCP endpoint only** — it is wired as the MCP server's\n`authenticate_request`, and the plugin registers no global `determine_current_user` /\n`rest_authentication_errors` filter. So a raw `POST /wp/v2/posts` with this token is **not**\nauthenticated; the admin user context (`wp_set_current_user`) exists only *inside* an MCP session.\n\n| Surface | Base (from `site.json`) | Role |\n|---|---|---|\n| **Per-site WP MCP** | `urls.wp_mcp` (`mcp_url`) | **all writes** — create content through its ability tools |\n| **WP REST API** `wp/v2` | `urls.rest_api` | **public reads only** (no auth needed) — what the Step 6 frontend consumes; do not write to it with the JWT |\n\n> **The WordPress content model is posts, pages and categories** (plus media). For **SEO**, **Yoast**\n> is already installed + activated at provision (every headless WordPress site — see provision Step 4),\n> so meta + schema come from a maintained plugin and the frontend reads each item's `yoast_head_json`.\n> This push only sets per-item Yoast meta (Step 4.7 below).\n\n> **A store on this site? See [`woocommerce.md`](woocommerce.md).** When the brief's **Suggested\n> backend** is `**WordPress + WooCommerce**` (`site.json → woocommerce.enabled: true`), this push also\n> seeds the WooCommerce catalog — products, categories and coupons — through the **same per-site WP MCP\n> session**, which exposes native `wc-products-*`, `wc-product-categories-*` and `wc-coupons-*` abilities\n> (no custom plugin, no consumer key). Do the WordPress content below as normal, then run the **WooCommerce\n> catalog** section before you hand off.\n\n> **Needs more than posts and pages? See [`custom-plugins.md`](custom-plugins.md).** The per-site MCP can\n> create content *data* but can't register a **custom post type**, a **custom taxonomy**, or a **global\n> settings page** (footer text, phone, social links the frontend reads). When the content model calls for\n> those, author a small custom plugin and deploy it into the headless `/cms` with\n> `hosting_deploy { type: \"wordpress-plugin\", headless: true }`, exposing everything over REST for the\n> frontend. **Deploy the plugin first** (the schema — the deploy activates it), then the steps below populate\n> its entries through the MCP's native `cpt-create` ability (custom-taxonomy terms are seeded in the plugin — see the cookbook).\n\n> **A contact form on this site? See [`contact-form.md`](contact-form.md).** When the brief's\n> **Must-haves** include a contact form (`site.json → contact_form.enabled: true`), provision installed\n> **Contact Form 7**; this push resolves the form's numeric id + field names (in the **same session**)\n> and records them into `site.json` so the Step 6 frontend can submit to CF7's public feedback endpoint.\n> Run the **Contact form** section after the WordPress content below.\n\n## Step 1 — Open the authenticated MCP session\n\nMint a short-lived token through the Hostinger connector:\n\n```json\n{ \"operation\": \"wordpress_installations_jwt-token\",\n \"params\": { \"username\": \"<site.json username>\", \"software\": \"<site.json wordpress.install_id>\" } }\n```\n\nReturns `token` (+ `mcp_url`, already stored as `urls.wp_mcp`). Connect to `urls.wp_mcp` as an MCP\nclient (`initialize` → `tools/list` → `tools/call`) and send the token on **every** request as:\n\n- `X-Hostinger-JWT-Token: <token>` — the plugin's preferred header (checked first), **or**\n- `Authorization: Bearer <token>` — accepted as a fallback (case-insensitive `bearer ` prefix).\n\nThe token lives ~24 h — mint on demand, **never print, log, or write it to `site.json` /\n`content-model.md`**. If a call returns `401/403` with `token_invalid`/`invalid_api_key`, the token\nexpired or was revoked → re-mint and reconnect.\n\n> **Transport gotchas (streamable HTTP MCP).** The endpoint is session-based: `initialize` returns an\n> **`Mcp-Session-Id` response header** — capture it and send it on **every** subsequent request\n> (`tools/list`, `tools/call`), or they fail with `-32600 Missing Mcp-Session-Id header`. Send\n> `Accept: application/json, text/event-stream`. After `initialize`, post the `notifications/initialized`\n> notification, and **tolerate a non-2xx on it** (it can answer 404 with no body — harmless; don't let\n> it abort the session). A raw curl/HTTP client works fine for all this.\n\n## Step 2 — Discover the ability tools\n\nThe server advertises either every ability as its own tool, or three **discovery meta-tools**\n(discover → get-info → execute) to keep `tools/list` small. Call `tools/list` first and work with\nwhat's there; with discovery, list abilities, then execute by ability name. The content tools that\ncover this step (exact names come from discovery — don't hardcode them):\n\n`SettingsTools` · `CategoriesTools` · `TagsTools` · `MediaUploadTools` / `MediaTools` · `PagesTools` ·\n`PostsTools` · `MenusTools` / `NavigationTools` · `SeoTools`, plus **`cpt-*`** for custom post types. There\nis **no** generic `wp/v2` passthrough tool — every write goes through a dedicated ability, so a `wp/v2`\nroute with no matching ability (e.g. the terms of a *custom* taxonomy) isn't reachable from the MCP;\nregister and seed those in a custom plugin instead (see the backend's custom-plugins cookbook).\n\nSeen on a live install (Hostinger AI Assistant, discovery mode): the meta-tools are\n`hostinger-ai-discover-abilities`, `hostinger-ai-get-ability-info { name }` and\n`hostinger-ai-execute-ability { name, parameters }`. The abilities are named\n`hostinger-ai/<thing>-<action>`, e.g. `posts-create`, `posts-update`, `media-upload-from-url`,\n`media-delete`. Call `get-ability-info` for each schema before using it. Results come back as\n`{ ability, result: { data, … } }` for lists and `{ ability, result: {…} }` for single objects — so a\ncreate answers **`200`** (not `201`) and the new object's `id` is **inside** `result` (`result.id`, or\n`result.data.id`), never at the top level. A missing top-level `id` is not a failure; read it from\n`result`.\n\n## Step 3 — Idempotency rule (makes resume safe)\n\nEvery create is **list/get-by-slug first, then create only if absent** — a re-run must not duplicate.\nUse the matching read ability (`posts-search`, `pages-search`, `categories-list`, `tags-list`,\n`cpt-search`, `media-search`, …): a hit → reuse its `id`; empty → create. Capture each returned `id` and record the id map back into\n`content-model.md` so later references resolve without re-querying.\n\n## Step 4 — Create in dependency order\n\n> **Custom types/settings? Deploy the plugin before this list.** If the content model needs a custom post\n> type, taxonomy or global settings page, deploy that plugin **first** (the deploy activates it — see\n> [`custom-plugins.md`](custom-plugins.md)) so its REST routes exist — then the steps below populate its\n> entries through the MCP's native `cpt-create` ability, as they do core posts and pages (custom-taxonomy\n> terms are seeded in the plugin — the MCP has no taxonomy-term path; see the cookbook).\n\n0. **Remove WordPress's default content first.** A fresh install ships a **\"Hello world!\" post**\n (id 1), a **\"Sample Page\"**, and a default comment — they will otherwise show up on the live site.\n Delete the default post (`posts-delete { id: 1, force: true }`) and, if present, the sample page\n (find it via `pages-search` for slug `sample-page`, then `pages-delete`). This is idempotent: if\n they're already gone, skip. Leave the default `Uncategorized` category — it's harmless once no post\n uses it (the frontend only lists categories with posts).\n1. **Site Title & Description** (the helper's global part) → the Settings ability.\n2. **Categories / tags** — one per term in the helper → Categories / Tags abilities. Keep `name → id`.\n3. **Media.** Upload every image in the helper, set `alt_text`, and keep `filename → id`. See\n *Media upload* below for all sources: a Hostinger-AI image URL, an Unsplash URL, or a generated\n file in `media/`. For a Hostinger-AI image, generate it now (its URL is temporary — see the\n content-model `SKILL.md`, *Images* Option A) and upload straight away, before `expires_at`.\n4. **Pages** — each static/index page → Pages ability. Pass `title`, `content` and `excerpt` as\n **`{ \"raw\": \"…\" }` objects** (see the note below), plus `slug`, `status: publish`. Keep `slug → id`.\n5. **Posts (articles)** — each → Posts ability: `title`, `content` and `excerpt` as **`{ \"raw\": \"…\" }`**\n (note below), plus `slug`, `status: publish`, `categories: [id]`, `tags: [id…]`,\n `featured_media: <id>`, `date`. (The plugin block-converts post content on insert via\n `rest_pre_insert_post`, so HTML bodies in `content.raw` are fine.)\n\n > **Create payload gotcha (`pages-create` / `posts-create`).** These abilities take `title`,\n > `content` and `excerpt` as WordPress's core **`{ \"raw\": \"…\" }` objects**, not bare strings. Pass a\n > bare string and the field is silently dropped — the create still answers `200`, so you get a\n > *published-but-empty* page or post with nothing to key off. Always send `{ \"raw\": \"…\" }` and\n > read the new `id` from `result` (see Step 2). Confirm the exact field shape with\n > `get-ability-info` if a schema differs.\n6. **Navigation** — Menus / Navigation abilities: create the menu, then an item per nav entry\n referencing a page `id` (or a custom URL for listing routes). **Headless note:** the Step 6\n frontend renders nav from `content-model.md`, so the WP menu is for owners editing in wp-admin, not\n what the live site reads — create it, but it isn't the source of truth.\n7. **SEO** — **Yoast SEO** (`wordpress-seo`) is already installed + activated at provision (Step 4 of\n the WordPress provision recipe), so it generates meta + schema and the frontend reads\n `yoast_head_json` per post/page. Here, just set each post's Yoast title/description if an ability\n exposes it; otherwise the excerpt + title are a fine fallback. Don't fail the push over SEO fields.\n If `site.json → seo.enabled` is `false` (the owner declined Yoast, or provision couldn't confirm the\n install), skip this and let the frontend generate tags from content — don't try to install the\n plugin from the content stage.\n\n### Media upload\n\n**From a URL (a Hostinger-AI image, Unsplash, or any direct file URL).** Use\n`hostinger-ai/media-upload-from-url`. WordPress downloads the file and stores its own copy, so the\nsite never depends on the external URL staying up — which is exactly why a **Hostinger-AI** image\n(whose `url` from `wordpress_ai-content_generation-result` is temporary) must be uploaded here, while\nit is still valid; drop the Unsplash-only `caption`/`description` for AI images:\n\n```json\n{ \"name\": \"hostinger-ai/media-upload-from-url\",\n \"parameters\": {\n \"url\": \"https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max\",\n \"filename\": \"<site>-<subject>.jpg\",\n \"title\": \"<short title>\",\n \"alt_text\": \"<what the photo shows>\",\n \"caption\": \"Photo by <Photographer> on Unsplash\",\n \"description\": \"Source: https://unsplash.com/photos/<id> (Unsplash License)\",\n \"post_id\": <post id, if the post already exists> } }\n```\n\n- `url` must return 200 with an image content type. A photo *page* URL fails.\n- The result carries the attachment `id` and `source_url`. WordPress also generates the smaller\n sizes (`300x200`, `768x…`, `1024x…`, …) under `media_details.sizes`, which the frontend can use\n for `srcset`.\n- Set `filename` yourself, so files in `wp-content/uploads/` have readable names.\n\n**From a local file in `media/`.** Use the Media-upload ability that discovery lists for local\nfiles.\n\n**Set the featured image.** Pass `featured_media: <id>` when creating the post (Step 4.5). If the\npost already exists (on resume, or when images are added later), use\n`hostinger-ai/posts-update { id, featured_media }`.\n\n**Idempotency trap: don't look up earlier uploads by filename.** `media-search` matches titles and\ncontent, not filenames or the slug you passed. A search for `<site>-lisbon-tram` finds nothing even\nthough the file exists, so a re-run would upload the image again. WordPress then stores the duplicate\nas `…-1.jpg` and silently repoints the post at it. Check this way instead:\n\n1. `posts-get { id }`. If `featured_media` is already set, skip that post. Its image exists.\n2. Otherwise, search by the **title** you set on upload (`media-search { search: \"<title>\" }`),\n and reuse a hit.\n3. Only upload when both checks come up empty.\n\nIf duplicates do slip in, point each post back at the original with `posts-update`, then delete the\nduplicates with `media-delete { id, force: true }`.\n\n## Step 5 — Verify and record\n\n- Read a couple of created objects back through `wp/v2` (public GET, no auth): e.g.\n `GET {urls.rest_api}/posts?per_page=1` and check the `X-WP-Total` header against the planned article\n count; fetch one post with `_embed` to confirm `featured_media` and `categories` resolved.\n- Media: `GET {urls.rest_api}/media?per_page=1` → `X-WP-Total` equals the number of images planned\n (more means duplicates). `GET {urls.rest_api}/media?include=<ids>` confirms `alt_text` and\n `caption` on each image. Each `source_url` must return 200 with an image type. Each post's\n `featured_media` must point at its image.\n- Write the created-id map into `content-model.md` (a `## Created in WordPress` section: object → slug\n → id) so the frontend data phase and any re-run resolve references directly. Include one row per\n media item: filename, credit and source page, the post it's featured on, and its id.\n\n## WooCommerce catalog *(only for a `WordPress + WooCommerce` brief — skip otherwise)*\n\nWhen `site.json → woocommerce.enabled` is `true`, also seed the store **in this same authenticated MCP\nsession**, using WooCommerce abilities the Assistant exposes alongside the content ones. Full recipe (incl.\nthe storefront Store API and the checkout gate): [`woocommerce.md`](woocommerce.md). In short:\n\n1. **Product categories** — `wc-product-categories-list` (reuse by slug/name) else\n `wc-product-categories-create { name, slug }`. Keep `name → id`.\n2. **Products** — **idempotent by SKU**: `wc-products-search { sku }` → reuse, else\n `wc-products-create { name, type:\"simple\", sku, regular_price:\"<decimal>\", description, status:\"publish\",\n categories:[{id}] }`. For images, `media-upload-from-url` then `wc-products-update { id, images:[{id}] }`.\n3. **Coupons** — **idempotent by code**: `wc-coupons-search { code }` → reuse, else\n `wc-coupons-create { code, discount_type, amount }`.\n4. **Verify over the public Store API** (no auth): `GET {urls.store_api}/products?per_page=100` count +\n `prices` (minor units), `GET {urls.store_api}/products/categories`. Record the SKU→id map, category ids\n and coupon codes into `content-model.md` under `## Created in WooCommerce`.\n\n> **Don't seed or test checkout here.** A fresh WooCommerce install has no payment gateway/shipping and\n> that config isn't reachable headlessly (`site.json → woocommerce.payment_enabled` is `false` — the owner\n> finishes it in wp-admin). Seeding + verifying the catalog over the Store API is the whole job; never call\n> `/checkout` or block on it. See the cookbook §4.\n\n## Contact form (Contact Form 7) *(only when `site.json → contact_form.enabled` is true — skip otherwise)*\n\nProvision installed Contact Form 7. Here, resolve the form the frontend submits to, deploy a tiny\nspam-guard plugin (a headless honeypot), and record both into `site.json`. Full recipe:\n[`contact-form.md`](contact-form.md). In short:\n\n1. **Find the form id (idempotent).** The per-site MCP has **no generic REST ability** and\n `wpcf7_contact_form` is not a public CPT, so read the id by **probing the public feedback endpoint**:\n a **`multipart/form-data`** `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<id>/feedback` with\n `_wpcf7_unit_tag=wpcf7-f<id>-o1`, for `id` in `1`‒`30`. The hit is the id whose `200` JSON reply carries\n `\"contact_form_id\": <id>` (plus a CF7 `\"status\"`) — the default **\"Contact form 1\"** (fields `your-name`,\n `your-email`, `your-subject`, `your-message`); `404`/`wpcf7_not_found` is a miss, and anything else\n (`415`, `403`, `429`, `5xx`) is inconclusive, never a hit. **The content type matters:** CF7 answers\n `415` to a non-multipart body *before* it looks up the id, so a bare POST would wrongly pick id 1. Reuse\n it on re-run. CF7's default mail already sends to `[_site_admin_email]`, so no recipient change is needed.\n *(Fallback if nothing answers: hand the owner the one-click wp-admin login — `wordpress_login_create-links`\n — to read the id from Contact → Contact Forms, the WooCommerce-checkout-gate owner-handoff pattern.)* See\n the cookbook for the full method.\n2. **Deploy the spam guard (honeypot).** The `contact-form-7-honeypot` add-on can't work headlessly (it\n needs a `[honeypot]` form-tag only an owner can add), so deploy a ~15-line `wpcf7_spam` plugin to `/cms`\n with `hosting_deploy { type: \"wordpress-plugin\", slug: \"cf7-headless-spamguard\", headless: true }` — it\n flags a submission as `spam` (no mail) when a fixed decoy field (`your_website`) is filled. Cookbook §2b.\n3. **Record into `site.json`** — fill the `contact_form` block provision started: `form_id`,\n `feedback_url` (`{urls.cms}/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`), `fields`\n (the form's input names, e.g. `[\"your-name\",\"your-email\",\"your-subject\",\"your-message\"]`) and\n `honeypot_field` (`\"your_website\"`, the decoy the frontend renders empty). Note the form id in\n `content-model.md` under `## Created in WordPress`.\n\n> **Don't send a real test here.** Resolving the form id + fields is the whole job for this stage; the\n> throwaway `mail_sent` test belongs to **validate** (Step 7), so one dummy email reaches the owner, not\n> several. See the cookbook for the feedback contract and the hidden `_wpcf7*` fields the frontend needs.\n\n## Step 6 — Hand off\n\nUpdate `state.json` (`provision` stays `completed`; advance `stage` toward the frontend **data**\nphase) with a fresh `updated_at`. The backend now holds the content; Step 6's data wiring points the\nfrontend at `urls.rest_api` (and, for a store, `urls.store_api`).\n"
197
+ },
198
+ {
199
+ "uri": "skill://hostinger-headless/steps/04-content-model/wordpress/contact-form.md",
200
+ "digest": "sha256:52d25142f4e562d494b598f6ee5942a0d0eed887d011d854320a7765ec283f88",
201
+ "size": 13236,
202
+ "mimeType": "text/markdown",
203
+ "text": "# headless — Contact Form 7 cookbook (contact form on headless WordPress)\n\nA companion recipe to `SKILL.md` (WordPress content model). Use it whenever the brief's **Must-haves**\ninclude a contact form (`site.json → contact_form.enabled: true`). The goal: a contact page whose form,\nsubmitted from the **decoupled frontend**, sends the enquiry to the site owner by email using\nWordPress's built-in `wp_mail()` — no third-party form service, no API keys, no secret on the client.\n\n**Contact Form 7 is not a separate backend.** It is the `contact-form-7` plugin on the same headless\nWordPress install at `/cms` (installed in the provision step). This cookbook is the authoritative home for\nthe contact-form data layer: how to **resolve the form** (content-model push), **deploy a spam guard**,\nwhat the **frontend submits** (Step 6), and how **validate** proves mail actually leaves.\n\n---\n\n## 1 — Why CF7, and why it works headlessly\n\nYou **could** deploy a custom plugin into the headless `/cms` and write a bespoke mailer (see\n[`custom-plugins.md`](custom-plugins.md)), but a contact form is exactly the kind of maintained,\nspam-hardened functionality you should **reuse rather than reinvent**. CF7 is the right choice because:\n\n- It is a **wordpress.org-slug plugin** → installs through the same management MCP (`wordpress_plugins_install`)\n as Yoast and WooCommerce. No custom code to deploy.\n- It ships a **public, unauthenticated submit endpoint**:\n `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`. Submissions don't need\n the JWT or any key.\n- The frontend (apex domain) and `/cms` are **same-origin**, so the browser posts straight to that\n endpoint — **no proxy route, no CORS, no credentials client-side.**\n- On submit CF7 runs its mail template through `wp_mail()`, which on Hostinger routes through the\n server's mailer. The default form's `To` is `[_site_admin_email]` — the admin address set at install —\n so **basic email delivery works out of the box.**\n\n**Spam protection comes from a tiny deployed plugin, not the Honeypot add-on.** CF7's own\n`contact-form-7-honeypot` add-on only acts when a `[honeypot]` form-tag is present in the form — a wp-admin\nedit the headless agent can't make — so on the default form it does **nothing**. Instead, deploy a\n~15-line `wpcf7_spam` guard into `/cms` (the capability from [`custom-plugins.md`](custom-plugins.md)): it\nrejects a submission whose decoy field is filled, with no owner step and no keys. See *§2b — Spam\nprotection* below. (reCAPTCHA is the alternative but needs owner-provided Google keys set in wp-admin —\nout of reach for the headless agent.)\n\n---\n\n## 2 — Content-model push: resolve the form and record it\n\nA fresh CF7 install ships one form, **\"Contact form 1\"**, with the default fields `your-name`,\n`your-email`, `your-subject`, `your-message` and a submit, and mails `[_site_admin_email]`. You need\nits **numeric form id** (and, if you tailored the form, its field names) to build `feedback_url`.\n\n> **Reality check — how to read the id.** The per-site WP MCP (Hostinger AI Assistant, discovery mode)\n> exposes **no generic REST/`RestEndpointTool` ability**, and `wpcf7_contact_form` is **not a public CPT**\n> (it is absent from `cpt-list-types` and from `wp/v2/types`), so the MCP cannot read the form, and CF7's\n> own `GET /contact-form-7/v1/contact-forms` is capability-gated (no public read). The reliable,\n> tool-free way is to **probe the public feedback endpoint**, which needs no auth.\n\n1. **Find the form id by probing the feedback endpoint (idempotent).** For `id` in a small range\n (`1`‒`30` covers a fresh install — the default form lands a few ids after the sample content), send a\n **`multipart/form-data`** `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<id>/feedback` with\n a single field `_wpcf7_unit_tag=wpcf7-f<id>-o1`. **The content type matters:** CF7 checks it *before*\n it looks up the id and answers **415** to any non-multipart body, so a bare POST returns non-404 for\n *every* id and would wrongly pick id 1.\n - **Hit** → `200` whose JSON body carries `\"contact_form_id\": <id>` (matching the id you probed) and a\n CF7 `\"status\"` (e.g. `validation_failed`). **This id is the form.** On a fresh single-form install\n that is the only hit; reuse it on a re-run.\n - **Miss** → `404` with `\"code\": \"wpcf7_not_found\"`. Keep going.\n - **Inconclusive** → anything else (`415` from a wrong content type, `403`, `429`, `5xx`) is **not** a\n hit — never select such an id. Fix the request or retry, widen the range, and fall back to the owner\n handoff (step 2) if nothing resolves.\n\n The default field names are `your-name`, `your-email`, `your-subject`, `your-message`. (A full test\n submission that confirms both the id and the field names — and that mail actually sends — belongs to\n **validate**, so you send just one real email; see §4.)\n\n2. **(Optional) tailor the form — owner-side.** The headless tooling can't edit the CF7 form (no REST\n ability reaches it). If the design needs different fields, keep the default form for the build and note\n it; the owner edits fields/recipient in wp-admin (Contact → Contact Forms) via the one-click login.\n The default form is enough for a name/email/subject/message enquiry, and its recipient\n (`[_site_admin_email]`) is already correct.\n\n3. **Record into `site.json`** — fill the `contact_form` block provision started:\n\n ```json\n {\n \"contact_form\": {\n \"enabled\": true,\n \"plugin\": \"contact-form-7\",\n \"form_id\": 4,\n \"feedback_url\": \"https://<domain>/cms/wp-json/contact-form-7/v1/contact-forms/4/feedback\",\n \"fields\": [\"your-name\", \"your-email\", \"your-subject\", \"your-message\"],\n \"cf7_version\": \"6.2.2\",\n \"honeypot_field\": \"your_website\",\n \"recipient\": \"site-admin-email\"\n }\n }\n ```\n\n `cf7_version` is the installed CF7 version (from `wordpress_plugins_list-installed`) — the frontend\n sends it as `_wpcf7_version`. `honeypot_field` is the decoy the spam guard checks (§2b) — the frontend\n renders it empty and the server rejects a filled one. Also note the form id in `content-model.md`. No\n address, token or secret goes into `site.json`.\n\n> **Fallback if the probe finds nothing** (an unusual install, or the default form was removed): mint a\n> one-click wp-admin link (`wordpress_login_create-links { username, software }`) and ask the owner to\n> read the form id from **Contact → Contact Forms** (the shortcode shows `id=\"…\"`). Record it into\n> `site.json` and continue — the same owner-handoff used for the WooCommerce checkout gate, not a build\n> failure.\n\n> **Aside — Hostinger's own shortcode.** The per-site MCP's `pages-create` ability suggests\n> `[hostinger_contact_form]` for a contact form *inside a WordPress page*. That is server-rendered\n> shortcode output, not useful to a decoupled React frontend that reads REST JSON — this flow uses CF7's\n> JSON feedback endpoint instead. Mentioned only so the shortcode hint isn't mistaken for the headless path.\n\n---\n\n## 2b — Spam protection: deploy the honeypot guard\n\nCF7's default form has **no spam protection**, and the `contact-form-7-honeypot` add-on can't help\nheadlessly (it needs a `[honeypot]` form-tag only an owner can add — see §1). Instead, deploy a tiny\nplugin that enforces a honeypot **server-side**, on the same REST feedback path the frontend uses. This\nreuses the custom-plugin deploy from [`custom-plugins.md`](custom-plugins.md) — the only moving part is one\n~15-line file.\n\n**Why it works.** CF7's `wpcf7_spam` filter runs inside `submit()`, which the REST feedback endpoint\ncalls, so returning `true` sets the submission `status` to `spam` and **suppresses the mail**. The hook can\nread any posted field via `WPCF7_Submission::get_posted_data()` — including a decoy field that isn't a CF7\nform-tag, **as long as its name doesn't start with `_`** (CF7 strips `_`-prefixed keys). So the frontend\nsends a decoy field (empty for humans), and the guard flags the submission as spam when a bot fills it.\n\n1. **Write the guard** to `~/.hostinger/<domain>/plugins/cf7-headless-spamguard/cf7-headless-spamguard.php`:\n\n ```php\n <?php\n /**\n * Plugin Name: CF7 Headless Spam Guard\n * Description: Server-side honeypot for Contact Form 7 submissions from a headless frontend.\n * Version: 1.0.0\n * Requires PHP: 7.4\n * License: GPL-2.0-or-later\n */\n if ( ! defined( 'ABSPATH' ) ) { exit; }\n\n // Reject a submission whose decoy field is filled. The name is a fixed convention the frontend\n // renders empty (site.json → contact_form.honeypot_field); it must NOT start with \"_\".\n add_filter( 'wpcf7_spam', function ( $spam, $submission ) {\n \tif ( $spam ) { return $spam; } // keep any earlier spam verdict\n \t$decoy = $submission->get_posted_data( 'your_website' );\n \tif ( is_string( $decoy ) && trim( $decoy ) !== '' ) {\n \t\t$spam = true; // bot filled it → status 'spam', no mail\n \t}\n \treturn $spam;\n }, 10, 2 );\n ```\n\n2. **Deploy it** into the headless `/cms` (activates on install; idempotent — re-deploy to update):\n\n ```json\n { \"operation\": \"hosting_deploy\",\n \"params\": {\n \"domain\": \"<domain>\",\n \"type\": \"wordpress-plugin\",\n \"path\": \"~/.hostinger/<domain>/plugins/cf7-headless-spamguard\",\n \"slug\": \"cf7-headless-spamguard\",\n \"headless\": true } }\n ```\n\n3. **Record** `contact_form.honeypot_field: \"your_website\"` in `site.json` (done in §2 step 3) so the\n frontend knows which decoy to render. Keep the plugin **generic** (no site-specific values) — it's one\n reusable artifact, not per-site code.\n\n> **Nonce note.** CF7's built-in spam check also flags nonce failure, but nonce verification is **inactive\n> for logged-out visitors by default** — which is why a plain headless submission returns `mail_sent`, not\n> `spam`. The guard adds the honeypot on top. Confirm end-to-end in validate (§4).\n\n---\n\n## 3 — What the frontend submits (Step 6 reference)\n\nThe feedback endpoint expects `multipart/form-data` with the form's own fields **plus** CF7's hidden\ncontrol fields. The frontend builds this in a `submitContactForm(data)` helper in the one data module:\n\n| Field | Value |\n|---|---|\n| each name in `contact_form.fields` | the user's input (`your-name`, `your-email`, …) |\n| `_wpcf7` | `contact_form.form_id` (number) |\n| `_wpcf7_version` | CF7 version string, e.g. `\"5.9.8\"` (not strictly validated) |\n| `_wpcf7_locale` | e.g. `\"en_US\"` |\n| `_wpcf7_unit_tag` | any stable string, e.g. `\"wpcf7-f<form_id>-o1\"` |\n| `_wpcf7_container_post` | `\"0\"` |\n| `contact_form.honeypot_field` (the decoy, e.g. `your_website`) | **empty** — rendered hidden/off-screen, `autocomplete=\"off\"`, `tabindex=\"-1\"`; the §2b guard rejects it as spam if filled |\n\n`POST` to `contact_form.feedback_url` and **do not set `Content-Type`** — let the browser add the\nmultipart boundary. Same-origin, so no CORS and no secret.\n\n**Response** is JSON:\n\n```json\n{ \"contact_form_id\": 42, \"status\": \"mail_sent\", \"message\": \"Thank you for your message. It has been sent.\",\n \"invalid_fields\": [], \"posted_data_hash\": \"…\" }\n```\n\nBranch on `status`:\n\n- `mail_sent` → success state (clear the form, show the thank-you message).\n- `validation_failed` → map each `invalid_fields[].field` to its input and show the message inline.\n- `spam` / `aborted` / `mail_failed` / `acceptance_missing` → a generic \"couldn't send, try again\" error.\n\nA user validation error is a **normal UI state, not a build error**. When `contact_form.enabled` is\n`false` or absent, the frontend renders the contact page with plain contact details and **no form**.\n\n---\n\n## 4 — Email delivery & the `wp_mail` caveat (what to tell the owner)\n\nCF7's default form mails `[_site_admin_email]` via `wp_mail()`, so **Hostinger does \"set up\" basic email\nby default** — nothing else to configure for the form to send. Two caveats worth surfacing:\n\n- **Deliverability.** `wp_mail` from a shared host can land in spam for external recipients without\n SPF/DKIM. The most reliable recipient is a **Hostinger mailbox on the same domain**. Keep CF7's admin\n recipient; if the owner wants a different inbox, point them to a same-domain mailbox.\n- **Prove it, don't assume it.** Validate (Step 7) POSTs a throwaway test submission and asserts\n `status == \"mail_sent\"`. A `mail_failed` is a real finding — report it (CF7 is wired, but the mailer\n needs attention), don't pass silently.\n- **Check the honeypot too.** Send one more throwaway submission with the decoy\n (`contact_form.honeypot_field`) **filled** — it must come back `status: \"spam\"` with **no** mail. If it\n returns `mail_sent`, the §2b guard plugin isn't active (re-check its deploy). The normal empty-decoy\n submission still returns `mail_sent`, so this costs one extra throwaway email at most.\n\nDon't install an SMTP plugin or ask for mail credentials as part of this flow — the default `wp_mail`\npath is the intended behaviour; SMTP is an owner decision in wp-admin if deliverability needs hardening.\n"
204
+ },
205
+ {
206
+ "uri": "skill://hostinger-headless/steps/04-content-model/wordpress/custom-plugins.md",
207
+ "digest": "sha256:089294fec9e6489bab21f8c5a3916ad68cf909f72b2886f65202adb8bab7f3e4",
208
+ "size": 24321,
209
+ "mimeType": "text/markdown",
210
+ "text": "# headless — WordPress custom plugins cookbook (structure the backend in code)\n\nA companion recipe to `SKILL.md` (WordPress content model). Use it when the content model needs **more\nthan posts, pages, categories and media** — when the brief calls for **structured content types**,\n**global site settings the frontend reads**, or small **site-specific helpers**. Those cannot be created\nthrough the per-site WP MCP (it has no endpoint to register a post type, a taxonomy, or an options page);\nthey need PHP, which means a **small custom plugin** deployed into the headless `/cms`.\n\n**When to reach for this (and when not to).** The per-site WP MCP already covers content *data* through\ndedicated abilities — `posts-*`, `pages-*`, `categories-*`, `tags-*`, `media-*`, `menus-*`, and `cpt-*` for\ncustom post types — but there is **no generic `wp/v2` passthrough** (no `RestEndpointTool`). A plugin is for\n*structure and behaviour* that data sits in, and for anything those abilities can't reach:\n\n| Need | Plugin? | Do it with |\n|---|---|---|\n| Blog posts, static pages, categories, media, nav | **no** | per-site WP MCP (`SKILL.md` Step 4) |\n| A **custom post type** — Team, Services, Properties, Testimonials, a restaurant's dishes | **yes** | *Custom post type* below |\n| A **custom taxonomy** — Property Type, Cuisine, Service Area | **yes** | *Custom taxonomy* below |\n| **Global site content** the frontend needs — footer text, phone, address, opening hours, social links, a CTA line | **yes** | *Settings page + public settings route* below |\n| Extra **fields on a post/CPT** exposed in REST (price, subtitle, map coords) | **yes** | *Custom fields in REST* below |\n| A small **helper** — a shortcode, a computed REST value, a cron tidy-up | **yes** | *Helpers* below |\n| Payment, membership, page-builder, SEO, caching, or anything a maintained plugin already does well | **no** | install the real plugin at provision — don't reinvent it |\n\n> **Plugins carry structure, not catalog or content data.** You deploy a plugin you've written into the\n> headless `/cms` with `hosting_deploy { type: \"wordpress-plugin\", headless: true }` (see *Deploy* below).\n> Author one only for the *structure and behaviour* the per-site MCP can't express — types, taxonomies,\n> settings, helpers. Don't write a **seed/config plugin** to load WooCommerce catalog data or page content;\n> that goes through the per-site MCP's native abilities (`woocommerce.md`, `SKILL.md` Step 4), never a plugin.\n\n---\n\n## 1 — The one rule: everything the frontend needs must be in the public REST API\n\nThe Step 6 frontend is a separate app that reads the backend over the **public `wp/v2` REST API** (open\nCORS on a headless install, no auth for reads). So a custom post type the frontend never sees in REST is\nuseless to it. **Register everything headless-visible with REST on:**\n\n- **Post types** → `'show_in_rest' => true` and an explicit `'rest_base'` → the frontend reads\n `GET {urls.rest_api}/<rest_base>`.\n- **Taxonomies** → `'show_in_rest' => true` and an explicit `'rest_base'` → readable, and embeddable on\n their objects with `?_embed`.\n- **Meta fields** → `register_post_meta(..., [ 'show_in_rest' => true ])`, or `register_rest_field` for a\n computed value → appears under the object's `meta` (or your field) in REST.\n- **Global settings** → a **public read-only REST route** the frontend fetches once for footer/contact/etc.\n\nThis is the whole reason the plugin exists in a headless build: it is a **schema + settings layer the\nfrontend consumes**, not an admin experience. Everything below keeps `show_in_rest` front and centre.\n\n> **Register on the right hooks.** Post types and taxonomies register on `init`; meta on `init`; REST routes\n> on `rest_api_init`; settings/admin pages on `admin_init` / `admin_menu`. Registering a post type outside\n> `init` silently misbehaves.\n\n---\n\n## 2 — Plugin anatomy and WordPress standards\n\nKeep it a **single, small, self-contained plugin per site** — one folder, prefixed, headless-focused. Ship\nit as a directory (not a zip); the deploy tool uploads the folder's files and installs from them.\n\n```\n<site>-headless/ slug = folder name = plugin slug, e.g. \"acme-headless\"\n├── <site>-headless.php main file with the plugin header (same base name as the folder)\n├── inc/\n│ ├── post-types.php\n│ ├── taxonomies.php\n│ ├── settings.php\n│ └── rest.php\n├── uninstall.php optional — clean up options on delete\n└── readme.txt optional\n```\n\n**Main file** — header, the `ABSPATH` guard, a constant for the version, and the activation flush:\n\n```php\n<?php\n/**\n * Plugin Name: Acme Headless\n * Description: Content types, taxonomies and site settings for the Acme headless frontend.\n * Version: 1.0.0\n * Requires at least: 6.4\n * Requires PHP: 7.4\n * Author: Acme\n * License: GPL-2.0-or-later\n * Text Domain: acme-headless\n */\n\n// No direct access — every PHP file in the plugin starts with this.\nif ( ! defined( 'ABSPATH' ) ) {\n\texit;\n}\n\ndefine( 'ACME_HEADLESS_VERSION', '1.0.0' );\n\nrequire_once __DIR__ . '/inc/post-types.php';\nrequire_once __DIR__ . '/inc/taxonomies.php';\nrequire_once __DIR__ . '/inc/settings.php';\nrequire_once __DIR__ . '/inc/rest.php';\n\n// Post types/taxonomies are registered on `init` by the files above. Rewrite rules for their\n// permalinks must be flushed once, on activation only (never on every load — it is expensive).\nregister_activation_hook( __FILE__, function () {\n\tacme_headless_register_post_types();\n\tacme_headless_register_taxonomies();\n\tflush_rewrite_rules();\n} );\nregister_deactivation_hook( __FILE__, 'flush_rewrite_rules' );\n```\n\n**Standards that matter (every snippet below follows them):**\n\n- **Prefix everything** — functions, options, the text domain, the REST namespace — with a per-site token\n (`acme_headless_`, `acme-headless`). Two plugins must never collide.\n- **Guard every file** with `if ( ! defined( 'ABSPATH' ) ) { exit; }`.\n- **Sanitize on input, escape on output.** `sanitize_text_field`, `sanitize_textarea_field`, `esc_url_raw`,\n `absint` on the way in; `esc_html`, `esc_attr`, `esc_url`, `wp_kses_post` on the way out.\n- **Check capability + nonce** on any admin write (`current_user_can( 'manage_options' )`; the Settings API\n handles the nonce for you). Set `permission_callback` on every REST route — `__return_true` only for\n genuinely public reads, a capability check for anything that writes.\n- **Never hardcode a secret** in the plugin, and never expose one over a public route. Site settings the\n frontend reads are public content (footer text, phone) — API keys and the like are not; keep those out.\n- **i18n:** wrap user-facing strings in `__( 'text', 'acme-headless' )`. Headless admin labels are low-stakes\n but costs nothing to do right.\n\n> **Flushing rewrite rules headlessly.** The activation hook above runs when the plugin is activated, so CPT\n> *permalinks* resolve. But the frontend reads **REST**, and REST routes work the moment the type is\n> registered — they don't depend on a rewrite flush. So even if activation timing is odd, `GET /<rest_base>`\n> is live as soon as the plugin is active. Don't block the build on permalink flushing.\n\n---\n\n## 3 — Custom post type\n\nOne `register_post_type` per type, on `init`, with REST on and an explicit `rest_base`:\n\n```php\n// inc/post-types.php\nif ( ! defined( 'ABSPATH' ) ) { exit; }\n\nfunction acme_headless_register_post_types() {\n\tregister_post_type( 'acme_service', array(\n\t\t'labels' => array(\n\t\t\t'name' => __( 'Services', 'acme-headless' ),\n\t\t\t'singular_name' => __( 'Service', 'acme-headless' ),\n\t\t),\n\t\t'public' => true,\n\t\t'has_archive' => true,\n\t\t'menu_icon' => 'dashicons-hammer',\n\t\t'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ),\n\t\t'show_in_rest' => true, // REQUIRED for the headless frontend\n\t\t'rest_base' => 'services', // GET {rest_api}/services\n\t\t'rewrite' => array( 'slug' => 'services' ),\n\t) );\n}\nadd_action( 'init', 'acme_headless_register_post_types' );\n```\n\n- Pick a **prefixed internal key** (`acme_service`, ≤ 20 chars) but a **clean `rest_base`** (`services`) —\n the frontend route reads naturally and won't clash with core.\n- `'supports' => [ 'thumbnail' ]` enables the featured image; `'custom-fields'` lets meta ride along.\n- After deploy, the per-site WP MCP can **populate entries** into this type with its **native\n `cpt-*` abilities** — `cpt-list-types` (confirm the type is registered, with its `rest_base`),\n then `cpt-create { post_type: \"acme_service\", title, content, excerpt?, status: \"publish\" }`\n (and `cpt-search` / `cpt-get` / `cpt-update` / `cpt-delete`). Deploy the plugin first (the schema),\n then seed entries through the MCP. Note `cpt-create` takes **no taxonomy/term fields** — see §4 for how\n custom-taxonomy terms are handled.\n\n## 4 — Custom taxonomy\n\n```php\n// inc/taxonomies.php\nif ( ! defined( 'ABSPATH' ) ) { exit; }\n\nfunction acme_headless_register_taxonomies() {\n\tregister_taxonomy( 'acme_service_area', array( 'acme_service' ), array(\n\t\t'labels' => array(\n\t\t\t'name' => __( 'Service Areas', 'acme-headless' ),\n\t\t\t'singular_name' => __( 'Service Area', 'acme-headless' ),\n\t\t),\n\t\t'public' => true,\n\t\t'hierarchical' => true, // true = categories, false = tags\n\t\t'show_in_rest' => true, // REQUIRED for the frontend\n\t\t'rest_base' => 'service-areas',\n\t\t'rewrite' => array( 'slug' => 'service-areas' ),\n\t) );\n}\nadd_action( 'init', 'acme_headless_register_taxonomies' );\n```\n\n**Seeding terms is a PHP job, not an MCP one.** The per-site MCP has no custom-taxonomy ability\n(`categories-*` / `tags-*` cover only the built-in `category` / `post_tag`, and there is no generic REST\npassthrough), and `cpt-create` takes no term fields. So seed the taxonomy's starter terms **in the plugin**,\non `init`, **gated by a version option** — not the activation hook, which doesn't re-run when you redeploy\nan already-active plugin, so later-added terms would never seed. Bump `ACME_HEADLESS_VERSION` and redeploy\nto re-run the seed:\n\n```php\nadd_action( 'init', function () {\n\tif ( get_option( 'acme_headless_seed_version' ) === ACME_HEADLESS_VERSION ) {\n\t\treturn; // already seeded for this version\n\t}\n\tforeach ( array( 'Downtown', 'Suburbs', 'Coast' ) as $area ) {\n\t\tif ( ! term_exists( $area, 'acme_service_area' ) ) {\n\t\t\twp_insert_term( $area, 'acme_service_area' ); // idempotent via the term_exists guard\n\t\t}\n\t}\n\tupdate_option( 'acme_headless_seed_version', ACME_HEADLESS_VERSION );\n}, 11 ); // after register_taxonomy (init, priority 10)\n```\n\nTerms then read over REST (`GET {rest_api}/service-areas`) and embed on their objects with\n`GET {rest_api}/services?_embed`. **Pre-assigning a term to an entry is separate** — `cpt-create` can't set\nterms. Either the owner assigns them in wp-admin, or the same versioned seed attaches them **by entry slug\nonce the entries exist** (`wp_set_object_terms( get_page_by_path( '<slug>', OBJECT, 'acme_service' )->ID,\n'<term>', 'acme_service_area' )`) — seed the entries first, then bump `ACME_HEADLESS_VERSION` and redeploy\nso the seed re-runs with the entries in place.\n\n## 5 — Settings page + a public settings route (footer, phone, generic text)\n\nThe classic headless need: **one place the owner edits global content** (footer blurb, phone, email,\naddress, opening hours, social links, a CTA line) and **one REST route the frontend fetches** to render it\nsite-wide. Store it as a **single option holding an array**, edited with the Settings API, and exposed\nread-only over REST.\n\n```php\n// inc/settings.php\nif ( ! defined( 'ABSPATH' ) ) { exit; }\n\nconst ACME_HEADLESS_OPTION = 'acme_headless_settings';\n\n// --- Admin page (wp-admin → Settings → Acme Headless): where the owner edits global content. ---\nadd_action( 'admin_menu', function () {\n\tadd_options_page(\n\t\t__( 'Acme Headless', 'acme-headless' ),\n\t\t__( 'Acme Headless', 'acme-headless' ),\n\t\t'manage_options', // capability check\n\t\t'acme-headless',\n\t\t'acme_headless_settings_page'\n\t);\n} );\n\nadd_action( 'admin_init', function () {\n\t// register_setting wires in the nonce + capability check and runs our sanitizer on save.\n\tregister_setting( 'acme_headless', ACME_HEADLESS_OPTION, array(\n\t\t'type' => 'object',\n\t\t'sanitize_callback' => 'acme_headless_sanitize_settings',\n\t\t'default' => array(),\n\t) );\n} );\n\n// Sanitize on input — never trust the posted array.\nfunction acme_headless_sanitize_settings( $input ) {\n\treturn array(\n\t\t'phone' => sanitize_text_field( $input['phone'] ?? '' ),\n\t\t'email' => sanitize_email( $input['email'] ?? '' ),\n\t\t'address' => sanitize_textarea_field( $input['address'] ?? '' ),\n\t\t'footer_text' => sanitize_textarea_field( $input['footer_text'] ?? '' ),\n\t\t'facebook' => esc_url_raw( $input['facebook'] ?? '' ),\n\t\t'instagram' => esc_url_raw( $input['instagram'] ?? '' ),\n\t);\n}\n\nfunction acme_headless_settings_page() {\n\tif ( ! current_user_can( 'manage_options' ) ) { return; }\n\t$o = get_option( ACME_HEADLESS_OPTION, array() );\n\t?>\n\t<div class=\"wrap\">\n\t\t<h1><?php esc_html_e( 'Acme Headless', 'acme-headless' ); ?></h1>\n\t\t<form method=\"post\" action=\"options.php\">\n\t\t\t<?php settings_fields( 'acme_headless' ); // nonce + option group ?>\n\t\t\t<table class=\"form-table\" role=\"presentation\">\n\t\t\t\t<tr>\n\t\t\t\t\t<th><label for=\"ah_phone\"><?php esc_html_e( 'Phone', 'acme-headless' ); ?></label></th>\n\t\t\t\t\t<td><input name=\"<?php echo esc_attr( ACME_HEADLESS_OPTION ); ?>[phone]\" id=\"ah_phone\"\n\t\t\t\t\t\ttype=\"text\" value=\"<?php echo esc_attr( $o['phone'] ?? '' ); ?>\" class=\"regular-text\"></td>\n\t\t\t\t</tr>\n\t\t\t\t<tr>\n\t\t\t\t\t<th><label for=\"ah_footer\"><?php esc_html_e( 'Footer text', 'acme-headless' ); ?></label></th>\n\t\t\t\t\t<td><textarea name=\"<?php echo esc_attr( ACME_HEADLESS_OPTION ); ?>[footer_text]\" id=\"ah_footer\"\n\t\t\t\t\t\trows=\"3\" class=\"large-text\"><?php echo esc_textarea( $o['footer_text'] ?? '' ); ?></textarea></td>\n\t\t\t\t</tr>\n\t\t\t\t<!-- email, address, social … same pattern -->\n\t\t\t</table>\n\t\t\t<?php submit_button(); ?>\n\t\t</form>\n\t</div>\n\t<?php\n}\n```\n\nNow expose it to the frontend as a **public, read-only** route. This is global site chrome — public content\nby definition — so the read is open. The values were **sanitized on save** (the Settings API sanitizer\nabove), so return them as **plain JSON** — the frontend escapes on render — and never add a secret to it.\nDon't HTML-escape here: `esc_html()`/`esc_url()` in a JSON response double-encode `&`:\n\n```php\n// inc/rest.php\nif ( ! defined( 'ABSPATH' ) ) { exit; }\n\nadd_action( 'rest_api_init', function () {\n\tregister_rest_route( 'acme/v1', '/settings', array(\n\t\t'methods' => WP_REST_Server::READABLE, // GET\n\t\t'permission_callback' => '__return_true', // public read — global chrome only\n\t\t'callback' => function () {\n\t\t\t$o = get_option( ACME_HEADLESS_OPTION, array() );\n\t\t\treturn array(\n\t\t\t\t// Already sanitized on save (see the Settings API sanitizer above); return as plain\n\t\t\t\t// JSON strings — the frontend escapes on render. Don't esc_html()/esc_url() here, or\n\t\t\t\t// a JSON client double-encodes \"&\" (e.g. \"Tea & Co\" → \"Tea &amp; Co\").\n\t\t\t\t'phone' => $o['phone'] ?? '',\n\t\t\t\t'email' => sanitize_email( $o['email'] ?? '' ),\n\t\t\t\t'address' => $o['address'] ?? '',\n\t\t\t\t'footer_text' => wp_kses_post( $o['footer_text'] ?? '' ),\n\t\t\t\t'social' => array(\n\t\t\t\t\t'facebook' => esc_url_raw( $o['facebook'] ?? '' ),\n\t\t\t\t\t'instagram' => esc_url_raw( $o['instagram'] ?? '' ),\n\t\t\t\t),\n\t\t\t);\n\t\t},\n\t) );\n} );\n```\n\nThe frontend fetches `GET {urls.cms}/wp-json/acme/v1/settings` once (it's cacheable) and renders the\nfooter, header contact bar, etc. Record this route in `content-model.md` so Step 6 wires it in.\n\n> **Custom namespace base vs `urls.rest_api`.** `site.json → urls.rest_api` points at the `…/wp-json/wp/v2`\n> base (core content). A custom namespace lives one level up, off `{urls.cms}/wp-json` — e.g.\n> `{urls.cms}/wp-json/acme/v1/settings`. Record the full custom URL explicitly so the frontend doesn't try\n> to hang it off the `wp/v2` base.\n\n## 6 — Custom fields in REST (meta on a post or CPT)\n\nFor a simple stored field, `register_post_meta` with `show_in_rest` — it then reads/writes under the\nobject's `meta`:\n\n```php\nadd_action( 'init', function () {\n\tregister_post_meta( 'acme_service', 'price_from', array(\n\t\t'type' => 'string',\n\t\t'single' => true,\n\t\t'show_in_rest' => true, // appears in REST under meta.price_from\n\t\t'auth_callback' => function () { return current_user_can( 'edit_posts' ); },\n\t\t'sanitize_callback' => 'sanitize_text_field',\n\t) );\n} );\n```\n\nFor a **computed/read-only** value (not stored, derived at response time), use `register_rest_field`:\n\n```php\nadd_action( 'rest_api_init', function () {\n\tregister_rest_field( 'acme_service', 'read_time', array(\n\t\t'get_callback' => function ( $post ) {\n\t\t\treturn max( 1, (int) ceil( str_word_count( wp_strip_all_tags( get_post( $post['id'] )->post_content ) ) / 200 ) );\n\t\t},\n\t\t'schema' => array( 'type' => 'integer' ),\n\t) );\n} );\n```\n\n## 7 — Helpers (shortcodes, computed values, small cron)\n\nKeep helpers tiny and single-purpose, in `inc/`. A headless frontend rarely renders WordPress HTML, so\nprefer a **REST value over a shortcode**. If a cron job is genuinely needed, schedule it on activation and\nclear it on deactivation (don't leave orphaned events):\n\n```php\nregister_activation_hook( __FILE__, function () {\n\tif ( ! wp_next_scheduled( 'acme_headless_daily' ) ) {\n\t\twp_schedule_event( time(), 'daily', 'acme_headless_daily' );\n\t}\n} );\nregister_deactivation_hook( __FILE__, function () {\n\twp_clear_scheduled_hook( 'acme_headless_daily' );\n} );\nadd_action( 'acme_headless_daily', 'acme_headless_run_daily' );\n```\n\n> **The two `register_*_hook` lines must sit in the main `<site>-headless.php` file** (beside the plugin's\n> existing activation hook), not in an `inc/` include: `__FILE__` has to resolve to the **main plugin file**\n> or the hooks never fire. The schedule check and the callback (`add_action( 'acme_headless_daily', … )` and\n> `acme_headless_run_daily()`) can live in `inc/`.\n\nClean up on uninstall so deleting the plugin leaves no rows behind:\n\n```php\n// uninstall.php\nif ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { exit; }\ndelete_option( 'acme_headless_settings' );\n```\n\n---\n\n## 8 — Deploy the plugin into the headless `/cms`\n\nDeploy the **plugin directory** with the connector's one-shot `hosting_deploy`, passing **`headless: true`**\nso it lands in `cms/wp-content/plugins/` (not the document-root `wp-content/`):\n\n```json\n{ \"operation\": \"hosting_deploy\",\n \"params\": {\n \"domain\": \"<domain>\",\n \"type\": \"wordpress-plugin\",\n \"path\": \"~/.hostinger/<domain>/plugins/<site>-headless\",\n \"slug\": \"<site>-headless\",\n \"headless\": true } }\n```\n\n- **`path`** is the plugin **folder** (it contains the main `*.php` with the plugin header). The tool scans\n the directory and uploads every file; it skips `node_modules`, `.git`, OS junk and `.env*` on its own —\n don't zip it yourself, don't pass a git repo (`headless` is path-only).\n- **`slug`** becomes the install directory name (lower-case letters, digits, `-`, `_`). Keep it the same\n across re-runs; the tool appends a random suffix to the *upload* dir, then installs from it.\n- `type` is optional — the tool detects a WordPress plugin from the header — but pass it to be explicit.\n- A plugin/theme deploy is **done when the call returns** (unlike a Node.js build). The result echoes\n `{ type: \"wordpress-plugin\", status: \"deployed\", slug, headless: true }`.\n- It **overwrites** a previous deploy of the same plugin — re-run freely to ship a fix. Confirm the domain\n first, as with any deploy.\n\n**The deploy activates the plugin for you — then verify over public REST:**\n\n1. **Confirm it's active** (the headless plugin deploy activates it on install — you normally don't\n call activate yourself). Check the per-site MCP's `plugin-list` ability for your slug, or just\n rely on the REST check below. Only if it comes back inactive, activate it with\n `wordpress_plugins_activate { username, software, plugin: \"<slug>/<slug>.php\" }` (or the bare\n `\"<slug>\"` form) and re-check.\n2. **Verify the REST surface is live** (public GET, no auth) — this is the real proof the plugin is\n active and registered:\n - CPT: `GET {urls.rest_api}/services` returns `200` (empty array before you seed entries — that's fine).\n - Taxonomy: `GET {urls.rest_api}/service-areas` returns `200`.\n - Settings: `GET {urls.cms}/wp-json/acme/v1/settings` returns your JSON shape.\n - Namespace: `GET {urls.cms}/wp-json/` lists your `acme/v1` under `namespaces`.\n A `404` on a route means the plugin isn't active (or the `rest_base`/namespace differs) — fix before moving on.\n\n> **Order matters.** Deploy the plugin **before** the content push seeds entries into the new types — the\n> type must exist in REST for the MCP's `cpt-create` to accept entries. Default **Settings** values are\n> seeded by the plugin itself (in the same versioned `init` seed, via `update_option`) or left for the owner\n> to fill in wp-admin — record which in `content-model.md`. The MCP can't write the custom option\n> (`wp-settings-update` only touches standard WordPress settings, and there's no generic REST passthrough).\n\n---\n\n## 9 — Record and hand off\n\nWrite what you built into `content-model.md` under a `## Custom plugin` section so Step 6 (frontend data)\nand any re-run resolve it without re-discovery:\n\n- Plugin **slug** and version, and that it's deployed + active.\n- Each **post type**: internal key → `rest_base` → the full read URL the frontend uses.\n- Each **taxonomy**: key → `rest_base`, and which types it's attached to.\n- Each **meta / REST field** exposed, and under what key it appears.\n- The **settings route** full URL and its JSON shape (so the frontend knows the footer/contact fields).\n\nThe frontend then reads these routes exactly like core `wp/v2` content — same host, same open CORS, no auth.\n\n---\n\n## 10 — Checklist & further reading\n\n- [ ] One prefixed plugin folder; main file carries the plugin header; every PHP file guards `ABSPATH`.\n- [ ] Post types & taxonomies: `show_in_rest: true` + explicit `rest_base`, registered on `init`.\n- [ ] Settings stored in one option, Settings API for the admin page (capability + nonce), **sanitized in**.\n- [ ] Public settings **read** route with `permission_callback`, values returned as **plain JSON** (sanitized on save, not HTML-escaped), no secrets.\n- [ ] Rewrite flush on activation; cron (if any) cleared on deactivation; `uninstall.php` tidies options.\n- [ ] Deployed with `hosting_deploy { type: \"wordpress-plugin\", headless: true }`, confirmed **active**,\n every REST route returns `200`.\n- [ ] Routes recorded in `content-model.md` for the frontend.\n\n**References** — this cookbook is self-contained, but for depth follow the maintained sources (don't invent\nAPIs; confirm a function against these):\n\n- WordPress **Plugin Handbook** — <https://developer.wordpress.org/plugins/> (headers, hooks, activation,\n Settings API, security: nonces/capabilities/sanitizing/escaping).\n- WordPress **REST API Handbook** — <https://developer.wordpress.org/rest-api/> (`show_in_rest`,\n `register_rest_route`, `register_rest_field`, authentication).\n- Post types & taxonomies — <https://developer.wordpress.org/reference/functions/register_post_type/> ·\n <https://developer.wordpress.org/reference/functions/register_taxonomy/>.\n- **WordPress/agent-skills** (expert WordPress knowledge for AI assistants — blocks, themes, plugins, best\n practices) — <https://github.com/WordPress/agent-skills>. If your coding agent exposes WordPress skills\n (e.g. plugin-development, rest-api), prefer them for generating and reviewing the PHP; this cookbook is the\n headless wiring around them.\n"
197
211
  },
198
212
  {
199
213
  "uri": "skill://hostinger-headless/steps/04-content-model/wordpress/woocommerce.md",
@@ -218,17 +232,17 @@ export default [
218
232
  },
219
233
  {
220
234
  "uri": "skill://hostinger-headless/steps/06-frontend/SKILL.md",
221
- "digest": "sha256:11a1ba8bd9951c768b4ee3cdd45c0235bee34cc5c5602180482644b588128b69",
222
- "size": 11885,
235
+ "digest": "sha256:2783b21dd7cd261a93b084c2f9718b91c29deee1a158b0c240102bde6ed8a512",
236
+ "size": 13449,
223
237
  "mimeType": "text/markdown",
224
- "text": "---\nname: headless-step-frontend\ndescription: \"Step 6 (frontend) of the Hostinger Headless flow. Builds the website that renders the backend's content. Figures out (or confirms) the framework — defaulting to Next.js SSR — implements the look already chosen in design.md (the design step's contract), and builds the source into src/. Only ask for the clarifications you actually need.\"\n---\n\n# headless-step-frontend\n\nBuild the site's frontend against the backend from `site.json`, implementing the look the design step\nalready chose and wrote to **`design.md`** (plus the logo in `brand/`). The design is decided; this step\nis where it becomes real code. There's no per-framework recipe — use good judgement, follow `design.md`\nas the contract, and ask only for the clarifications you genuinely need. **Out:** source in the site\nworkspace `~/.hostinger/<domain>/src/`.\n\n> ## ⚠️ Build mode: \"Bring your own frontend\"? Read this first (any backend)\n> Read `brief.md` → **Build mode**. When it is **Bring your own frontend**, the user already has a\n> frontend (the project in the folder the command was run in) and this step **wires it to the backend\n> from `site.json` — it does not scaffold a new project and there is usually no `design.md` to\n> implement** (the design step deferred to the user). **Use their project as the source**: copy or\n> point `~/.hostinger/<domain>/src/` at it, keep its framework and UI, and collaborate on changes at\n> the user's direction (vibe-code with them). Your job is to connect it to the live backend (WordPress\n> `wp/v2` reads, the WooCommerce/Ecommerce Store API, env/keys) and make it deployable (set\n> `engines.node` to `22`, remove any `.htaccess`), then hand off to deploy. This generalises the Webapp\n> \"existing app\" case below to WordPress and Ecommerce too. When **Build mode is \"Agent designs and\n> builds\"**, ignore this box and proceed normally (scaffold + implement `design.md`).\n\n> ## ⚠️ Webapp backend? Read this first\n> A `webapp` brief has **no CMS/store and no `content-model.md`** — the app owns its own data (its own\n> API, or the MySQL database recorded in `site.json → database`). It comes in two shapes, decided back in\n> **refine**:\n>\n> - **An existing app the user brought to deploy** (refine routes \"deploy my app\" here). **Use their\n> project as the source** — copy/point `~/.hostinger/<domain>/src/` at it and keep its own framework and\n> UI. **Do not scaffold a new project, and do not re-implement `design.md` over it** (so **the design\n> step does not apply** to an app that brings its own UI). Your job is only to make it deployable: set\n> `engines.node` to `22`, remove any `.htaccess`, wire its data/env, then hand off to deploy. **Skip the\n> Step 2 design pass**, and ignore the `content-model.md` requirement in Step 3.\n> - **A new app built from the brief.** Scaffold and build it from `brief.md` (and `design.md` if the\n> design step produced one — design *does* run for a from-scratch app). Its data is owned by the app, so\n> there is still **no `content-model.md`** to bind against.\n>\n> Framework choice (Step 1), the build, and hand-off (Step 4) apply to both. The per-backend bullets in\n> Step 3 are **WordPress / WooCommerce / Ecommerce only** — for a webapp follow the Webapp bullet there.\n\n## Step 1 — Pick the framework (guess → default → confirm)\n\n**The rendering is already decided in `brief.md` → Rendering** (SSR or SSG, set during refine). Honour\nit, and the backend constraint:\n\n- **Ecommerce → SSR required** (carts/search need per-request rendering). Any SSR framework works —\n Next.js is the default, not a requirement (Express, Nuxt, SvelteKit, etc. are all fine).\n- **WordPress + WooCommerce → SSR required** (same reason — cart/checkout need per-request rendering).\n- **WordPress → SSG (Astro)** when the brief says SSG, otherwise **SSR (Next.js default)**.\n- **Webapp → the app's own stack.** An **existing app** keeps its current framework (don't impose one); a\n **new app** uses whatever fits the brief (SSR or SPA). Either way it deploys as a Node.js app (Step 8) —\n an SSR app runs a server, a SPA builds to static output.\n- Map rendering → framework: **`ssr` → Next.js**, **`ssg` → Astro** — unless the brief/prompt explicitly\n names another supported framework, in which case use that.\n\nSupported frameworks (what the Hostinger build pipeline can deploy — the `app_type` in Step 8 deploy):\n\n- **Server/SSR-capable:** Next.js, Nuxt.js, Astro, SvelteKit, Express, NestJS, Fastify.\n- **Static / SPA:** React, Vue, Angular, Vite, Parcel, Preact, Svelte, (Astro / SvelteKit static too).\n\nConfirm the framework with the user in one line only if the brief left it open. Record the choice in\n`state.json`:\n\n```json\n{ \"frontend\": { \"framework\": \"nextjs\", \"rendering\": \"ssr\" } }\n```\n\nIf the brief is silent, default to **Next.js SSR**. Ecommerce must be SSR (never SSG) — but the SSR\nframework itself is free to be any supported one, not just Next.js.\n\n## Step 2 — Implement `design.md` (don't re-design)\n\nThe look was already chosen and written by the design step. **Read `~/.hostinger/<domain>/design.md` and\nimplement it faithfully** — tokens (colour, type, spacing), components, per-page composition, imagery and\naccessibility — plus the logo files in `brand/`. Don't re-run design questions or invent a new direction here.\n\n- Treat `design.md` as the contract: the built pages should match its tokens, type scale, components and\n per-page composition, and the 8-point grid / WCAG 2.2 AA rules it carries.\n- Use the **logo** from `brand/` as-is (wordmark, icon mark, favicon); never recolour or redraw it.\n- If `design.md` is **missing** (e.g. a resumed older build), fall back to `brief.md → Design direction`\n (reference site, colours, brand, adjectives) and a tasteful, accessible default — but prefer `design.md`\n whenever it exists.\n- **Don't gate on approval here** — the result is shown at **validate** (Step 7).\n\n**Only ask about the code, and only if it matters** (design decisions belong in the design step):\n- Where the **source should live** — default `~/.hostinger/<domain>/src/`; ask only if they want a\n specific folder or an existing repo.\n- **Git** — initialise / connect / push (default: a local project, no remote).\n- A specific **component library** or technical must-have, if they implied one.\n\n## Step 3 — Build against the backend\n\nTwo phases around the instance barrier (see the top-level `SKILL.md`): scaffold + build the shell while\nprovision settles, then wire live data once `provision.status == \"completed\"`.\n\nRead the backend from `site.json`:\n\n- **WordPress** → `urls.rest_api` (`wp/v2`), public read. Content is in the posts/pages/categories/media\n the content-model push created; nav/plan come from `content-model.md`. Map each item's **SEO** from its\n `yoast_head_json` into the page `<head>` (title, description, Open Graph, JSON-LD schema) — Yoast is\n installed by default at provision (`site.json → seo.enabled: true`). If `seo.enabled` is `false`\n (Yoast absent), fall back to generating per-page title/description/OG tags from each item's content.\n- **WordPress + WooCommerce** (`site.json → woocommerce.enabled: true`) → the WordPress bullet above **plus**\n the store. Read the catalog over the **public WooCommerce Store API** (`site.json → urls.store_api`,\n `/cms/wp-json/wc/store/v1`, no auth, open CORS): `GET /products` (prices are **integer minor units** on\n `prices.price` with `prices.currency_minor_unit` — divide to display), `GET /products/categories`. Cart,\n coupons and checkout are **client-side** against the Store API with the `Nonce` + `Cart-Token` headers\n (`GET /cart`, `POST /cart/add-item`, `/cart/apply-coupon`, `POST /checkout`) — see\n `../04-content-model/wordpress/woocommerce.md`. **Never embed a consumer key client-side**; the\n storefront is fully public. Blog/pages on the same site still come from `urls.rest_api` (`wp/v2`) + Yoast.\n - **Honour `site.json → woocommerce.payment_enabled`.** It is `false` until the owner enables a payment\n gateway + shipping in wp-admin (that config isn't reachable headlessly — see the provision note). While\n `false`, build the full catalog, product pages and the client-side cart as normal, but render a\n graceful disabled-checkout state (e.g. a disabled \"Checkout\" with \"Checkout isn't set up yet\"), **don't\n call `/checkout`**, and don't treat it as a build error. When `true`, wire checkout (`payment_method`\n `\"cod\"` works out of the box).\n- **Ecommerce** → the **public Hostinger Storefront Core V2 API** (`https://api-ecommerce.hostinger.com/v2`,\n no auth, open CORS), keyed on the **custom sales channel** (`site.json → ecommerce.sales_channel.id`):\n `GET /channels/<scha_*>/products` (prices are **minor units** on variants), `POST /channels/<scha_*>/checkout`\n for buying. Cart is client-side; never embed a token. Read\n `ecommerce_miscellaneous_custom-storefront-setup-instructions` for the authoritative, current guide.\n - **Honour `site.json → ecommerce.payment_enabled`.** When it is `false` (free account / no paid\n Ecommerce plan — see the provision gate), there is **no checkout**: build the full catalog, product\n pages and the client-side cart as normal, but **do not call the checkout endpoint**. Instead render\n a clear, graceful state at the checkout step — e.g. a disabled \"Checkout\" button with a short note\n like \"Online checkout isn't available on this plan yet\" (and/or a contact/enquiry fallback). A\n missing checkout is **not** a build error here — never leave the storefront half-built, throw, or\n retry checkout because payment is off. When `payment_enabled` is `true`, wire checkout normally.\n- **Webapp** → **no `content-model.md` and no headless backend to read**. The app uses its own data source:\n the MySQL database from `site.json → database` (through the `DB_*` / `DATABASE_URL` env vars — set at\n provision/deploy, see `../03-provision/webapp/SKILL.md` Step 3; `127.0.0.1` only resolves on the server,\n not locally), or whatever external API the app calls. For an existing app, keep its data layer as-is.\n\nGuidelines (framework-agnostic):\n\n- **One data module** is the single place that talks to the backend (e.g. `src/lib/data.ts`) — one file\n to inspect, one base URL to switch. Keep it swappable so the shell can build against stubs first.\n- **Fetch at request time for SSR** (fresh content, no rebuild); a static build fetches at build time.\n- **A failed or incomplete fetch is an error, not a silent empty/placeholder.** Planned content missing\n from the API is a build error too — a site that builds with holes ships with holes. *(WordPress/Ecommerce;\n a webapp has no planned content to check.)*\n- **`content-model.md` is the source for what should exist** (pages, nav, collections mapping) — for\n WordPress/Ecommerce only; a **webapp has no `content-model.md`**.\n- **No `.htaccess`** in the project (deploy would overwrite the live one); don't commit `node_modules`\n or build output.\n- Set `engines.node` to `22` (the deploy builds on Node 22).\n\n## Step 4 — Hand off\n\nConfirm the project builds clean locally against the live backend, no placeholder/fixture content\nremains, and `state.json → frontend` matches what you built. Update `state.json` (`stage: \"validate\"`).\nValidate (Step 7) owns the clean rebuild + user preview; deploy (Step 8) ships it with the `app_type`\nand output directory that match the chosen framework + rendering.\n\n> **Webapp:** just confirm the app builds clean and `state.json → frontend` is right — there's no\n> `content-model.md` or headless backend to check. A DB-backed app can't reach its database locally\n> (`127.0.0.1` is server-only), so its data layer is verified **after** deploy, not here.\n"
238
+ "text": "---\nname: headless-step-frontend\ndescription: \"Step 6 (frontend) of the Hostinger Headless flow. Builds the website that renders the backend's content. Figures out (or confirms) the framework — defaulting to Next.js SSR — implements the look already chosen in design.md (the design step's contract), and builds the source into src/. Only ask for the clarifications you actually need.\"\n---\n\n# headless-step-frontend\n\nBuild the site's frontend against the backend from `site.json`, implementing the look the design step\nalready chose and wrote to **`design.md`** (plus the logo in `brand/`). The design is decided; this step\nis where it becomes real code. There's no per-framework recipe — use good judgement, follow `design.md`\nas the contract, and ask only for the clarifications you genuinely need. **Out:** source in the site\nworkspace `~/.hostinger/<domain>/src/`.\n\n> ## ⚠️ Build mode: \"Bring your own frontend\"? Read this first (any backend)\n> Read `brief.md` → **Build mode**. When it is **Bring your own frontend**, the user already has a\n> frontend (the project in the folder the command was run in) and this step **wires it to the backend\n> from `site.json` — it does not scaffold a new project and there is usually no `design.md` to\n> implement** (the design step deferred to the user). **Use their project as the source**: copy or\n> point `~/.hostinger/<domain>/src/` at it, keep its framework and UI, and collaborate on changes at\n> the user's direction (vibe-code with them). Your job is to connect it to the live backend (WordPress\n> `wp/v2` reads, the WooCommerce/Ecommerce Store API, env/keys) and make it deployable (set\n> `engines.node` to `22`, remove any `.htaccess`), then hand off to deploy. This generalises the Webapp\n> \"existing app\" case below to WordPress and Ecommerce too. When **Build mode is \"Agent designs and\n> builds\"**, ignore this box and proceed normally (scaffold + implement `design.md`).\n\n> ## ⚠️ Webapp backend? Read this first\n> A `webapp` brief has **no CMS/store and no `content-model.md`** — the app owns its own data (its own\n> API, or the MySQL database recorded in `site.json → database`). It comes in two shapes, decided back in\n> **refine**:\n>\n> - **An existing app the user brought to deploy** (refine routes \"deploy my app\" here). **Use their\n> project as the source** — copy/point `~/.hostinger/<domain>/src/` at it and keep its own framework and\n> UI. **Do not scaffold a new project, and do not re-implement `design.md` over it** (so **the design\n> step does not apply** to an app that brings its own UI). Your job is only to make it deployable: set\n> `engines.node` to `22`, remove any `.htaccess`, wire its data/env, then hand off to deploy. **Skip the\n> Step 2 design pass**, and ignore the `content-model.md` requirement in Step 3.\n> - **A new app built from the brief.** Scaffold and build it from `brief.md` (and `design.md` if the\n> design step produced one — design *does* run for a from-scratch app). Its data is owned by the app, so\n> there is still **no `content-model.md`** to bind against.\n>\n> Framework choice (Step 1), the build, and hand-off (Step 4) apply to both. The per-backend bullets in\n> Step 3 are **WordPress / WooCommerce / Ecommerce only** — for a webapp follow the Webapp bullet there.\n\n## Step 1 — Pick the framework (guess → default → confirm)\n\n**The rendering is already decided in `brief.md` → Rendering** (SSR or SSG, set during refine). Honour\nit, and the backend constraint:\n\n- **Ecommerce → SSR required** (carts/search need per-request rendering). Any SSR framework works —\n Next.js is the default, not a requirement (Express, Nuxt, SvelteKit, etc. are all fine).\n- **WordPress + WooCommerce → SSR required** (same reason — cart/checkout need per-request rendering).\n- **WordPress → SSG (Astro)** when the brief says SSG, otherwise **SSR (Next.js default)**.\n- **Webapp → the app's own stack.** An **existing app** keeps its current framework (don't impose one); a\n **new app** uses whatever fits the brief (SSR or SPA). Either way it deploys as a Node.js app (Step 8) —\n an SSR app runs a server, a SPA builds to static output.\n- Map rendering → framework: **`ssr` → Next.js**, **`ssg` → Astro** — unless the brief/prompt explicitly\n names another supported framework, in which case use that.\n\nSupported frameworks (what the Hostinger build pipeline can deploy — the `app_type` in Step 8 deploy):\n\n- **Server/SSR-capable:** Next.js, Nuxt.js, Astro, SvelteKit, Express, NestJS, Fastify.\n- **Static / SPA:** React, Vue, Angular, Vite, Parcel, Preact, Svelte, (Astro / SvelteKit static too).\n\nConfirm the framework with the user in one line only if the brief left it open. Record the choice in\n`state.json`:\n\n```json\n{ \"frontend\": { \"framework\": \"nextjs\", \"rendering\": \"ssr\" } }\n```\n\nIf the brief is silent, default to **Next.js SSR**. Ecommerce must be SSR (never SSG) — but the SSR\nframework itself is free to be any supported one, not just Next.js.\n\n## Step 2 — Implement `design.md` (don't re-design)\n\nThe look was already chosen and written by the design step. **Read `~/.hostinger/<domain>/design.md` and\nimplement it faithfully** — tokens (colour, type, spacing), components, per-page composition, imagery and\naccessibility — plus the logo files in `brand/`. Don't re-run design questions or invent a new direction here.\n\n- Treat `design.md` as the contract: the built pages should match its tokens, type scale, components and\n per-page composition, and the 8-point grid / WCAG 2.2 AA rules it carries.\n- Use the **logo** from `brand/` as-is (wordmark, icon mark, favicon); never recolour or redraw it.\n- If `design.md` is **missing** (e.g. a resumed older build), fall back to `brief.md → Design direction`\n (reference site, colours, brand, adjectives) and a tasteful, accessible default — but prefer `design.md`\n whenever it exists.\n- **Don't gate on approval here** — the result is shown at **validate** (Step 7).\n\n**Only ask about the code, and only if it matters** (design decisions belong in the design step):\n- Where the **source should live** — default `~/.hostinger/<domain>/src/`; ask only if they want a\n specific folder or an existing repo.\n- **Git** — initialise / connect / push (default: a local project, no remote).\n- A specific **component library** or technical must-have, if they implied one.\n\n## Step 3 — Build against the backend\n\nTwo phases around the instance barrier (see the top-level `SKILL.md`): scaffold + build the shell while\nprovision settles, then wire live data once `provision.status == \"completed\"`.\n\nRead the backend from `site.json`:\n\n- **WordPress** → `urls.rest_api` (`wp/v2`), public read. Content is in the posts/pages/categories/media\n the content-model push created; nav/plan come from `content-model.md`. Map each item's **SEO** from its\n `yoast_head_json` into the page `<head>` (title, description, Open Graph, JSON-LD schema) — Yoast is\n installed by default at provision (`site.json → seo.enabled: true`). If `seo.enabled` is `false`\n (Yoast absent), fall back to generating per-page title/description/OG tags from each item's content.\n - **Contact form** (`site.json → contact_form.enabled: true`, any WordPress backend) → render the\n contact page's form and submit it **client-side** to Contact Form 7's public feedback endpoint. Put a\n `submitContactForm(data)` helper in the one data module: build a `FormData` with the field names from\n `contact_form.fields` (e.g. `your-name`, `your-email`, `your-subject`, `your-message`) **plus CF7's\n hidden fields** — `_wpcf7` = `contact_form.form_id`, `_wpcf7_version`, `_wpcf7_locale` (e.g. `en_US`),\n `_wpcf7_unit_tag` (any stable string), `_wpcf7_container_post` = `0` — and `POST` it to\n `contact_form.feedback_url` **without setting `Content-Type`** (let the browser add the multipart\n boundary). `/cms` is same-origin, so no CORS and no secret. Branch on the JSON `status`: `mail_sent`\n → success state; `validation_failed` → map `invalid_fields[].field` to per-input errors;\n `spam`/`aborted`/`mail_failed` → a generic \"couldn't send, try again\" message. Include the honeypot\n decoy field named in `contact_form.honeypot_field` (e.g. `your_website`), rendered hidden/off-screen,\n **empty**, `autocomplete=\"off\"`, `tabindex=\"-1\"` — the deployed spam guard rejects it if filled. A user\n validation error is a **normal UI state, not a build error**. When `contact_form.enabled` is `false` or absent, render the contact page with\n plain contact details and **no form** — graceful, like disabled checkout; never half-build. Full\n contract: `../04-content-model/wordpress/contact-form.md` §3.\n- **WordPress + WooCommerce** (`site.json → woocommerce.enabled: true`) → the WordPress bullet above **plus**\n the store. Read the catalog over the **public WooCommerce Store API** (`site.json → urls.store_api`,\n `/cms/wp-json/wc/store/v1`, no auth, open CORS): `GET /products` (prices are **integer minor units** on\n `prices.price` with `prices.currency_minor_unit` — divide to display), `GET /products/categories`. Cart,\n coupons and checkout are **client-side** against the Store API with the `Nonce` + `Cart-Token` headers\n (`GET /cart`, `POST /cart/add-item`, `/cart/apply-coupon`, `POST /checkout`) — see\n `../04-content-model/wordpress/woocommerce.md`. **Never embed a consumer key client-side**; the\n storefront is fully public. Blog/pages on the same site still come from `urls.rest_api` (`wp/v2`) + Yoast.\n - **Honour `site.json → woocommerce.payment_enabled`.** It is `false` until the owner enables a payment\n gateway + shipping in wp-admin (that config isn't reachable headlessly — see the provision note). While\n `false`, build the full catalog, product pages and the client-side cart as normal, but render a\n graceful disabled-checkout state (e.g. a disabled \"Checkout\" with \"Checkout isn't set up yet\"), **don't\n call `/checkout`**, and don't treat it as a build error. When `true`, wire checkout (`payment_method`\n `\"cod\"` works out of the box).\n- **Ecommerce** → the **public Hostinger Storefront Core V2 API** (`https://api-ecommerce.hostinger.com/v2`,\n no auth, open CORS), keyed on the **custom sales channel** (`site.json → ecommerce.sales_channel.id`):\n `GET /channels/<scha_*>/products` (prices are **minor units** on variants), `POST /channels/<scha_*>/checkout`\n for buying. Cart is client-side; never embed a token. Read\n `ecommerce_miscellaneous_custom-storefront-setup-instructions` for the authoritative, current guide.\n - **Honour `site.json → ecommerce.payment_enabled`.** When it is `false` (free account / no paid\n Ecommerce plan — see the provision gate), there is **no checkout**: build the full catalog, product\n pages and the client-side cart as normal, but **do not call the checkout endpoint**. Instead render\n a clear, graceful state at the checkout step — e.g. a disabled \"Checkout\" button with a short note\n like \"Online checkout isn't available on this plan yet\" (and/or a contact/enquiry fallback). A\n missing checkout is **not** a build error here — never leave the storefront half-built, throw, or\n retry checkout because payment is off. When `payment_enabled` is `true`, wire checkout normally.\n- **Webapp** → **no `content-model.md` and no headless backend to read**. The app uses its own data source:\n the MySQL database from `site.json → database` (through the `DB_*` / `DATABASE_URL` env vars — set at\n provision/deploy, see `../03-provision/webapp/SKILL.md` Step 3; `127.0.0.1` only resolves on the server,\n not locally), or whatever external API the app calls. For an existing app, keep its data layer as-is.\n\nGuidelines (framework-agnostic):\n\n- **One data module** is the single place that talks to the backend (e.g. `src/lib/data.ts`) — one file\n to inspect, one base URL to switch. Keep it swappable so the shell can build against stubs first.\n- **Fetch at request time for SSR** (fresh content, no rebuild); a static build fetches at build time.\n- **A failed or incomplete fetch is an error, not a silent empty/placeholder.** Planned content missing\n from the API is a build error too — a site that builds with holes ships with holes. *(WordPress/Ecommerce;\n a webapp has no planned content to check.)*\n- **`content-model.md` is the source for what should exist** (pages, nav, collections mapping) — for\n WordPress/Ecommerce only; a **webapp has no `content-model.md`**.\n- **No `.htaccess`** in the project (deploy would overwrite the live one); don't commit `node_modules`\n or build output.\n- Set `engines.node` to `22` (the deploy builds on Node 22).\n\n## Step 4 — Hand off\n\nConfirm the project builds clean locally against the live backend, no placeholder/fixture content\nremains, and `state.json → frontend` matches what you built. Update `state.json` (`stage: \"validate\"`).\nValidate (Step 7) owns the clean rebuild + user preview; deploy (Step 8) ships it with the `app_type`\nand output directory that match the chosen framework + rendering.\n\n> **Webapp:** just confirm the app builds clean and `state.json → frontend` is right — there's no\n> `content-model.md` or headless backend to check. A DB-backed app can't reach its database locally\n> (`127.0.0.1` is server-only), so its data layer is verified **after** deploy, not here.\n"
225
239
  },
226
240
  {
227
241
  "uri": "skill://hostinger-headless/steps/07-validate/SKILL.md",
228
- "digest": "sha256:7f4b47dc23609b84b9b09b077591b1744554e9c2f9ffe03ad6a2fe6adf3b1023",
229
- "size": 3463,
242
+ "digest": "sha256:f738ff6eceeb31df507f7cf01c15d87e7396e17f94d75d4848105c16fd864705",
243
+ "size": 4710,
230
244
  "mimeType": "text/markdown",
231
- "text": "---\nname: headless-step-validate\ndescription: \"Step 7 (validate) of the Hostinger Headless flow. OPTIONAL quick gate before deploy: every prior stage already self-verifies, so this is a fast confirmation (instance live + backend API answers with seeded content) plus optional local preview and user sign-off. Skip the heavy parts when nobody needs to approve.\"\n---\n\n# headless-step-validate\n\n> **Optional / fast.** Every stage before this one already verifies its own work (provision checks the\n> backend, content-model push checks the REST counts, frontend builds clean), and **deploy verifies the\n> live result**. So validate is **not** a re-run of all that — it's a quick confirmation, plus the\n> optional human preview/sign-off. **Skip Steps 3–4 (rebuild + approval) entirely** on an autonomous or\n> temp-domain run where no one is signing off; just do the two 10-second checks below and move on. Spend\n> time here only when a person wants to see it before it goes live.\n\n**In:** project in `~/.hostinger/<domain>/src/`, `site.json`, `state.json`.\n\n## 1 — Instance status (always, quick)\n\n`provision.status == \"completed\"` **and** re-verify against the connector:\n`hosting_websites_list-setups { \"domain\": \"<domain>\" }`, exact-match the row → `completed`. Anything\nelse → not ready; return to the instance barrier, don't deploy.\n\n## 2 — Backend API status (WordPress / WooCommerce / Ecommerce, always, quick)\n\nHit the live API from `site.json` and confirm it answers **with the content the push created** — a 200\nwith an empty catalog means the push didn't land:\n\n- **WordPress:** `GET {urls.rest_api}/posts?per_page=1` → 200 JSON array; check `X-WP-Total` against the\n planned post count, and `GET {urls.rest_api}/media?per_page=1` → `X-WP-Total` matches the planned\n image count (more = duplicates).\n- **WordPress + WooCommerce** (`site.json → woocommerce.enabled`): the WordPress check above **plus**\n `GET {urls.store_api}/products?per_page=100` → 200 with the seeded products and `prices` (minor units);\n count matches the plan. Don't test checkout while `woocommerce.payment_enabled` is `false`.\n- **Ecommerce:** query `urls.storefront_api` for the sales channel → 200 with the seeded products and\n prices *(confirm against the store)*.\n\nFail or counts off → fix content-model push before building against live data.\n\n## 3 — Clean rebuild against the live API (optional — skip unless signing off)\n\nFrontend already produced a clean build; only redo this if you want a from-scratch guarantee before a\nhuman sign-off. Remove the build output and rebuild; a failed or incomplete build is a stop (no\nfixtures, placeholder copy or `.htaccess` in the output).\n\n- **Astro (SSG):** `rm -rf dist && npm run build && npm run check:dist`.\n- **Next.js (SSR):** `rm -rf .next && npm run check && npm run build`.\n\n## 4 — Preview & approval (optional — only when a person is signing off)\n\nWhen someone wants to see it first: start the local preview (Astro: `npm run preview`; Next.js:\n`npx next start`), check desktop and mobile widths, navigation and a couple of key routes, and present\nit for **explicit approval**. If preview tooling is unavailable, give the user the command and URL.\nOn an autonomous or temp-domain run, skip this — deploy will verify the live result anyway.\n\nThen (after approval, or immediately if 3–4 were skipped): `state.json` → `stage: \"deploy\"`, fresh\n`updated_at`; leave `provision` unchanged.\n"
245
+ "text": "---\nname: headless-step-validate\ndescription: \"Step 7 (validate) of the Hostinger Headless flow. OPTIONAL quick gate before deploy: every prior stage already self-verifies, so this is a fast confirmation (instance live + backend API answers with seeded content) plus optional local preview and user sign-off. Skip the heavy parts when nobody needs to approve.\"\n---\n\n# headless-step-validate\n\n> **Optional / fast.** Every stage before this one already verifies its own work (provision checks the\n> backend, content-model push checks the REST counts, frontend builds clean), and **deploy verifies the\n> live result**. So validate is **not** a re-run of all that — it's a quick confirmation, plus the\n> optional human preview/sign-off. **Skip Steps 3–4 (rebuild + approval) entirely** on an autonomous or\n> temp-domain run where no one is signing off; just do the two 10-second checks below and move on. Spend\n> time here only when a person wants to see it before it goes live.\n\n**In:** project in `~/.hostinger/<domain>/src/`, `site.json`, `state.json`.\n\n## 1 — Instance status (always, quick)\n\n`provision.status == \"completed\"` **and** re-verify against the connector:\n`hosting_websites_list-setups { \"domain\": \"<domain>\" }`, exact-match the row → `completed`. Anything\nelse → not ready; return to the instance barrier, don't deploy.\n\n## 2 — Backend API status (WordPress / WooCommerce / Ecommerce, always, quick)\n\nHit the live API from `site.json` and confirm it answers **with the content the push created** — a 200\nwith an empty catalog means the push didn't land:\n\n- **WordPress:** `GET {urls.rest_api}/posts?per_page=1` → 200 JSON array; check `X-WP-Total` against the\n planned post count, and `GET {urls.rest_api}/media?per_page=1` → `X-WP-Total` matches the planned\n image count (more = duplicates).\n- **WordPress + WooCommerce** (`site.json → woocommerce.enabled`): the WordPress check above **plus**\n `GET {urls.store_api}/products?per_page=100` → 200 with the seeded products and `prices` (minor units);\n count matches the plan. Don't test checkout while `woocommerce.payment_enabled` is `false`.\n- **Ecommerce:** query `urls.storefront_api` for the sales channel → 200 with the seeded products and\n prices *(confirm against the store)*.\n- **Contact form** (`site.json → contact_form.enabled`): POST one throwaway test submission (dummy\n name/email/subject/message + CF7's hidden `_wpcf7*` fields — see\n `../04-content-model/wordpress/contact-form.md` §3) to `contact_form.feedback_url` and assert the JSON\n `status` is **`mail_sent`**. This is the one place a real send happens, so one test email reaches the\n owner — not several. `mail_failed` is a real finding: surface it to the user (CF7 is wired, but\n `wp_mail` delivery/SPF needs attention — a Hostinger mailbox on the domain is the most reliable\n recipient) and log it as an issue. A `validation_failed` means the test payload didn't match the form's\n fields — fix the field names in `site.json`, not the plugin.\n **Then check the honeypot:** POST a **second** throwaway submission with the same valid fields **plus**\n `contact_form.honeypot_field` **filled**, and assert the `status` is **`spam`** (no mail sent). Keep the\n real fields valid — CF7 validates **before** running the spam filter, so an invalid payload would return\n `validation_failed` and never reach the honeypot check. A `mail_sent` here means the\n `cf7-headless-spamguard` plugin isn't active — surface it and re-check its deploy.\n\nFail or counts off → fix content-model push before building against live data.\n\n## 3 — Clean rebuild against the live API (optional — skip unless signing off)\n\nFrontend already produced a clean build; only redo this if you want a from-scratch guarantee before a\nhuman sign-off. Remove the build output and rebuild; a failed or incomplete build is a stop (no\nfixtures, placeholder copy or `.htaccess` in the output).\n\n- **Astro (SSG):** `rm -rf dist && npm run build && npm run check:dist`.\n- **Next.js (SSR):** `rm -rf .next && npm run check && npm run build`.\n\n## 4 — Preview & approval (optional — only when a person is signing off)\n\nWhen someone wants to see it first: start the local preview (Astro: `npm run preview`; Next.js:\n`npx next start`), check desktop and mobile widths, navigation and a couple of key routes, and present\nit for **explicit approval**. If preview tooling is unavailable, give the user the command and URL.\nOn an autonomous or temp-domain run, skip this — deploy will verify the live result anyway.\n\nThen (after approval, or immediately if 3–4 were skipped): `state.json` → `stage: \"deploy\"`, fresh\n`updated_at`; leave `provision` unchanged.\n"
232
246
  },
233
247
  {
234
248
  "uri": "skill://hostinger-headless/steps/08-deploy/SKILL.md",
@@ -239,10 +253,10 @@ export default [
239
253
  },
240
254
  {
241
255
  "uri": "skill://hostinger-headless/steps/09-done/SKILL.md",
242
- "digest": "sha256:14e5d0db5306e44cf7b16d05e4f661c2a5755031105cb9afa80115f33abe6211",
243
- "size": 10201,
256
+ "digest": "sha256:ab3a26efdd812d5c70e0b1679d17dc692aebd150daba25b0dddb2a28d65b3cb7",
257
+ "size": 10651,
244
258
  "mimeType": "text/markdown",
245
- "text": "---\nname: headless-step-done\ndescription: \"Step 9 (done) of the Hostinger Headless flow. Verifies the live site, then hands over the URLs (hPanel success page, main site, WordPress/store admin, dashboard), opens the success page in the browser on a successful run, and gives a summary of what was built and what the user can do next. Its final report format is also used whenever a run stops early.\"\n---\n\n# headless-step-done\n\nVerify the live site, then hand over the URLs (success page, main site, WordPress admin, dashboard), a\nsummary of what was built, and what the user can do next. **On a successful run, open the success page\nfor the user** (see *Open the success page*).\n\n## Verify the live site (brief)\n\nOn a run that deployed this session, re-confirm what you're about to hand over actually works (deploy\nalready checked this — this is a quick re-confirm, not a full re-run):\n\n- **Main site:** `GET https://<domain>/` → 200 (Next.js SSR: `x-powered-by: Next.js`; Astro SSG: static\n HTML with real content).\n- **WordPress admin:** `GET {urls.wp_admin}` → reachable (a login page, 200/302), not 404.\n- **Backend API:** `GET {urls.rest_api}/posts?per_page=1` → 200 (WordPress); for a **WooCommerce** store\n (`site.json → woocommerce.enabled`) also `GET {urls.store_api}/products?per_page=1` → 200; or the\n storefront API for the sales channel → 200 (Ecommerce).\n\nIf a check fails, say so in the outcome paragraph and don't claim the site is live.\n\n## Final report\n\nEvery run ends with this report, whether it finishes here or stops at an earlier stage (a dummy or\nmissing step, a failed provision, the user pausing). Build it from `state.json`, `site.json`,\n`deploy.json` and what you actually did and checked in this run. Report only what you verified. Don't\nclaim a stage passed if you didn't check it.\n\nWrite it in this order:\n\n1. **One-paragraph outcome.** What was created and on which domain (bold it). If the run stopped\n early, name the stage it stopped at, why, and what the user will see right now (e.g. \"the site\n root returns 403 until the frontend is deployed\").\n\n2. **Steps completed** — a table with one row per stage that ran:\n\n | Stage | Result |\n |---|---|\n | connect | How the connector was reached, MCP version, how sign-in happened. |\n | 1 check state | New build / resumed at `<stage>` / adopted an existing site. |\n | 2 refine | What the brief is and which backend it chose (say if a sample brief was used). |\n | 3 provision | Plan and order used, how long setup took, which verification checks passed. |\n | 4 content model | What was created (counts of pages, posts, terms, menus, media, SEO) and how it was verified. |\n | … | One row for each further stage that ran (design, frontend, validate, deploy, done). |\n\n Stages that didn't run are left out of the table and named in the outcome paragraph instead.\n Use the stage numbers and names from the orchestrator's flow table.\n\n3. **Links** — every URL that exists at this point. Build the hPanel URLs from `site.json`\n (`order_id`, `username`, `domain`) and `state.json` (`tool`); take the rest from `site.json` /\n `deploy.json`. Lead with the one the user cares about most — the **main site** — then the admin\n and dashboards. **Don't list the success page here:** it opens on its own (see *Open the success\n page*), so printing its URL in the report is just noise — the only exception is when a browser\n can't be opened (handled in that section):\n\n - **Main site (live):** `urls.site` (`https://<domain>`) — only once deployed.\n - **WordPress admin (one-click auto-login):** for a WordPress backend, **mint and hand over the\n auto-login redirect URL** — `wordpress_login_create-links { username, software: <site.json\n wordpress.install_id> }` returns a temporary link that logs the user straight into wp-admin. Give\n that URL as the admin link (it lands on `/cms/wp-admin`), so the user never needs a password. Also\n note the plain `urls.wp_admin` as a fallback. **Never print or store a password.** For a\n **WordPress + WooCommerce** store, this *same* wp-admin login is the store admin (wp-admin →\n WooCommerce → Products/Orders) — do **not** use the Ecommerce `store_admin` path. **Call out the one\n setup step under *Gaps / follow-ups*:** when `site.json → woocommerce.payment_enabled` is `false`,\n the owner must enable a payment method + a shipping zone in WooCommerce → Settings before checkout\n works (the catalog and cart already work). For a Hostinger **Ecommerce** store, link the store admin\n from `urls.store_admin` instead.\n - **hPanel site dashboard:** `https://hpanel.hostinger.com/websites/<domain>` (the headless website\n is installed by now).\n - **Onboarding (resume):** `https://hpanel.hostinger.com/headless-onboarding/<order_id>/<username>/`\n - **REST API** (for developers): `urls.rest_api`.\n\n > URL shape note: `<username>` may be dropped from the onboarding/success URLs in a future hPanel\n > release. If a built URL 404s, retry the same path without the `/<username>` segment before\n > reporting it as broken.\n\n4. **Where state lives** — the workspace path `~/.hostinger/<domain>/`, the `stage` that a\n resumed run will start from, and the build report `~/.hostinger/<domain>/report.html`.\n\n5. **Gaps / follow-ups** (only if any) — a short numbered list of anything skipped, approximated or\n left for the user (e.g. no images generated, default WordPress content left in place).\n\nNever include passwords, JWTs or other secrets in the report. For wp-admin access, give the URL and\noffer a one-click login link (`wordpress_login_create-links`), not credentials.\n\n## Open the success page\n\nOn a **successful** run (the deploy succeeded and the live checks passed), open the success page in the\nuser's **main / system browser** right after presenting the report, so they land on the hPanel\ncelebration/next-steps screen for their new site. This is a side effect only — **don't print the\nsuccess URL in the report output** (it just opens); the one exception is the can't-open-a-browser\nfallback below. Build the URL with `<tool>` as the running tool's onboarding slug (`claude` for\nClaude Code, else `cursor`, `codex`, `vscode`, `antigravity`, `devin`), mapped from\n`state.json → tool` (e.g. `claude-code` → `claude`):\n\n```bash\n# Build the URL first, then open it with the OS default (main) browser — not an embedded one.\n# <tool> = the onboarding slug (claude for Claude Code, else cursor/codex/vscode/antigravity/devin)\nURL=\"https://hpanel.hostinger.com/headless-onboarding/<order_id>/<username>/success/<domain>/<tool>\"\nopen \"$URL\" # macOS\n# xdg-open \"$URL\" # Linux\n# start \"\" \"$URL\" # Windows\n```\n\n- **Main browser only.** Use the OS opener above (`open` / `xdg-open` / `start`) so the page lands in\n Chrome, Safari, Edge, or whatever the user actually uses. **Do not** open it in an embedded or\n sandboxed browser (Cursor IDE browser / `cursor-ide-browser`, Playwright, Puppeteer, WebView, or\n any in-agent browser tab). Those stay inside the tool session and the user won't see the\n celebration page where they expect it.\n- Open it **once**, and only on a successful run — never on an early stop or a failed deploy.\n- If your tool can't open a browser (the command isn't available, or `open`/`xdg-open`/`start`\n fails), don't treat it as a failure and **don't fall back to an embedded browser**. This is the\n **one** case where you print the success URL — show it once in the report and tell the user to\n open it themselves in their main browser.\n\n## Build report\n\nRight after the final report, save the build report for this run. It turns the run log (stage times\nfrom `state.json`, events from `events.jsonl`) into `reports/<run>.json` and `.html`, and copies the\nHTML to `report.html` in the workspace:\n\n```bash\nnode <skill>/run-log.mjs <domain> event good '{\"text\":\"<something that went well>\"}' # one per thing, if not logged yet\nnode <skill>/run-log.mjs <domain> stage-end done ok\nnode <skill>/run-log.mjs <domain> report --outcome <live|stopped|failed> [--tokens '<json>']\n```\n\nThen send it to Hostinger: pass `reports/<run>.json` unchanged to `hosting_headless_submit-build-report`\nand run `report-sent <run>` (or `report-rejected <run>` on a 422). The `report` command prints the\nexact path and run id. Don't ask first; see *Run log and build report* in the top-level `SKILL.md`\nfor failures. Then tell the user in one line: \"Sent the build report to Hostinger to help improve\nthe flow.\" On a 422 say it's saved locally only; on any other failure say it'll be sent next session.\n\nIf the user asks for more changes after this report in the same session, call\n`run-log.mjs <domain> run-resume --reason \"<what they asked for>\"` before the work. Then log as\nusual and run `report` again when it's done. It updates this run's report in place, and the report\nlists the follow-up. Send the updated report the same way.\n\n- **Outcome:** `live` when the deploy succeeded this run, `failed` when a stage failed and you\n couldn't recover, `stopped` for everything else (the user paused, a dummy or missing step). Leave\n `--outcome` out and the script decides from the log.\n- **Before you write it,** check that every issue, correction and skill gap from this run is in\n `events.jsonl`. Log any that are missing now; the report only shows what was logged.\n- **After you write it,** read the `warning:` lines `report` prints. They list logging gaps: a stage\n that never ended, a start that was estimated, a stage logged with no duration, or (on a live build)\n a stage that was never logged. Fix what you can: `stage-end` an open stage, or correct a start with\n `stage-fix <stage> --started-ago <time>`, then run `report` again. What you can't fix stays in the report's\n *Logging gaps* section.\n- **Tokens:** only what your tool tells you about your own usage (see *Run log and build report* in\n the top-level `SKILL.md`).\n- In the final report's *Where state lives*, add the path to `report.html` so the user can open it.\n\nDo this on every run, including the early stops: those reports are the most useful ones.\n"
259
+ "text": "---\nname: headless-step-done\ndescription: \"Step 9 (done) of the Hostinger Headless flow. Verifies the live site, then hands over the URLs (hPanel success page, main site, WordPress/store admin, dashboard), opens the success page in the browser on a successful run, and gives a summary of what was built and what the user can do next. Its final report format is also used whenever a run stops early.\"\n---\n\n# headless-step-done\n\nVerify the live site, then hand over the URLs (success page, main site, WordPress admin, dashboard), a\nsummary of what was built, and what the user can do next. **On a successful run, open the success page\nfor the user** (see *Open the success page*).\n\n## Verify the live site (brief)\n\nOn a run that deployed this session, re-confirm what you're about to hand over actually works (deploy\nalready checked this — this is a quick re-confirm, not a full re-run):\n\n- **Main site:** `GET https://<domain>/` → 200 (Next.js SSR: `x-powered-by: Next.js`; Astro SSG: static\n HTML with real content).\n- **WordPress admin:** `GET {urls.wp_admin}` → reachable (a login page, 200/302), not 404.\n- **Backend API:** `GET {urls.rest_api}/posts?per_page=1` → 200 (WordPress); for a **WooCommerce** store\n (`site.json → woocommerce.enabled`) also `GET {urls.store_api}/products?per_page=1` → 200; or the\n storefront API for the sales channel → 200 (Ecommerce).\n- **Contact form** (`site.json → contact_form.enabled`): validate already sent a test submission, so\n don't send another here. In the handover, tell the owner the contact page is live and enquiries email\n the **WordPress admin address** (via `wp_mail`) — and that for the most reliable delivery that address\n should be a Hostinger mailbox on the domain. If validate reported `mail_failed`, say so plainly instead\n of claiming the form sends.\n\nIf a check fails, say so in the outcome paragraph and don't claim the site is live.\n\n## Final report\n\nEvery run ends with this report, whether it finishes here or stops at an earlier stage (a dummy or\nmissing step, a failed provision, the user pausing). Build it from `state.json`, `site.json`,\n`deploy.json` and what you actually did and checked in this run. Report only what you verified. Don't\nclaim a stage passed if you didn't check it.\n\nWrite it in this order:\n\n1. **One-paragraph outcome.** What was created and on which domain (bold it). If the run stopped\n early, name the stage it stopped at, why, and what the user will see right now (e.g. \"the site\n root returns 403 until the frontend is deployed\").\n\n2. **Steps completed** — a table with one row per stage that ran:\n\n | Stage | Result |\n |---|---|\n | connect | How the connector was reached, MCP version, how sign-in happened. |\n | 1 check state | New build / resumed at `<stage>` / adopted an existing site. |\n | 2 refine | What the brief is and which backend it chose (say if a sample brief was used). |\n | 3 provision | Plan and order used, how long setup took, which verification checks passed. |\n | 4 content model | What was created (counts of pages, posts, terms, menus, media, SEO) and how it was verified. |\n | … | One row for each further stage that ran (design, frontend, validate, deploy, done). |\n\n Stages that didn't run are left out of the table and named in the outcome paragraph instead.\n Use the stage numbers and names from the orchestrator's flow table.\n\n3. **Links** — every URL that exists at this point. Build the hPanel URLs from `site.json`\n (`order_id`, `username`, `domain`) and `state.json` (`tool`); take the rest from `site.json` /\n `deploy.json`. Lead with the one the user cares about most — the **main site** — then the admin\n and dashboards. **Don't list the success page here:** it opens on its own (see *Open the success\n page*), so printing its URL in the report is just noise — the only exception is when a browser\n can't be opened (handled in that section):\n\n - **Main site (live):** `urls.site` (`https://<domain>`) — only once deployed.\n - **WordPress admin (one-click auto-login):** for a WordPress backend, **mint and hand over the\n auto-login redirect URL** — `wordpress_login_create-links { username, software: <site.json\n wordpress.install_id> }` returns a temporary link that logs the user straight into wp-admin. Give\n that URL as the admin link (it lands on `/cms/wp-admin`), so the user never needs a password. Also\n note the plain `urls.wp_admin` as a fallback. **Never print or store a password.** For a\n **WordPress + WooCommerce** store, this *same* wp-admin login is the store admin (wp-admin →\n WooCommerce → Products/Orders) — do **not** use the Ecommerce `store_admin` path. **Call out the one\n setup step under *Gaps / follow-ups*:** when `site.json → woocommerce.payment_enabled` is `false`,\n the owner must enable a payment method + a shipping zone in WooCommerce → Settings before checkout\n works (the catalog and cart already work). For a Hostinger **Ecommerce** store, link the store admin\n from `urls.store_admin` instead.\n - **hPanel site dashboard:** `https://hpanel.hostinger.com/websites/<domain>` (the headless website\n is installed by now).\n - **Onboarding (resume):** `https://hpanel.hostinger.com/headless-onboarding/<order_id>/<username>/`\n - **REST API** (for developers): `urls.rest_api`.\n\n > URL shape note: `<username>` may be dropped from the onboarding/success URLs in a future hPanel\n > release. If a built URL 404s, retry the same path without the `/<username>` segment before\n > reporting it as broken.\n\n4. **Where state lives** — the workspace path `~/.hostinger/<domain>/`, the `stage` that a\n resumed run will start from, and the build report `~/.hostinger/<domain>/report.html`.\n\n5. **Gaps / follow-ups** (only if any) — a short numbered list of anything skipped, approximated or\n left for the user (e.g. no images generated, default WordPress content left in place).\n\nNever include passwords, JWTs or other secrets in the report. For wp-admin access, give the URL and\noffer a one-click login link (`wordpress_login_create-links`), not credentials.\n\n## Open the success page\n\nOn a **successful** run (the deploy succeeded and the live checks passed), open the success page in the\nuser's **main / system browser** right after presenting the report, so they land on the hPanel\ncelebration/next-steps screen for their new site. This is a side effect only — **don't print the\nsuccess URL in the report output** (it just opens); the one exception is the can't-open-a-browser\nfallback below. Build the URL with `<tool>` as the running tool's onboarding slug (`claude` for\nClaude Code, else `cursor`, `codex`, `vscode`, `antigravity`, `devin`), mapped from\n`state.json → tool` (e.g. `claude-code` → `claude`):\n\n```bash\n# Build the URL first, then open it with the OS default (main) browser — not an embedded one.\n# <tool> = the onboarding slug (claude for Claude Code, else cursor/codex/vscode/antigravity/devin)\nURL=\"https://hpanel.hostinger.com/headless-onboarding/<order_id>/<username>/success/<domain>/<tool>\"\nopen \"$URL\" # macOS\n# xdg-open \"$URL\" # Linux\n# start \"\" \"$URL\" # Windows\n```\n\n- **Main browser only.** Use the OS opener above (`open` / `xdg-open` / `start`) so the page lands in\n Chrome, Safari, Edge, or whatever the user actually uses. **Do not** open it in an embedded or\n sandboxed browser (Cursor IDE browser / `cursor-ide-browser`, Playwright, Puppeteer, WebView, or\n any in-agent browser tab). Those stay inside the tool session and the user won't see the\n celebration page where they expect it.\n- Open it **once**, and only on a successful run — never on an early stop or a failed deploy.\n- If your tool can't open a browser (the command isn't available, or `open`/`xdg-open`/`start`\n fails), don't treat it as a failure and **don't fall back to an embedded browser**. This is the\n **one** case where you print the success URL — show it once in the report and tell the user to\n open it themselves in their main browser.\n\n## Build report\n\nRight after the final report, save the build report for this run. It turns the run log (stage times\nfrom `state.json`, events from `events.jsonl`) into `reports/<run>.json` and `.html`, and copies the\nHTML to `report.html` in the workspace:\n\n```bash\nnode <skill>/run-log.mjs <domain> event good '{\"text\":\"<something that went well>\"}' # one per thing, if not logged yet\nnode <skill>/run-log.mjs <domain> stage-end done ok\nnode <skill>/run-log.mjs <domain> report --outcome <live|stopped|failed> [--tokens '<json>']\n```\n\nThen send it to Hostinger: pass `reports/<run>.json` unchanged to `hosting_headless_submit-build-report`\nand run `report-sent <run>` (or `report-rejected <run>` on a 422). The `report` command prints the\nexact path and run id. Don't ask first; see *Run log and build report* in the top-level `SKILL.md`\nfor failures. Then tell the user in one line: \"Sent the build report to Hostinger to help improve\nthe flow.\" On a 422 say it's saved locally only; on any other failure say it'll be sent next session.\n\nIf the user asks for more changes after this report in the same session, call\n`run-log.mjs <domain> run-resume --reason \"<what they asked for>\"` before the work. Then log as\nusual and run `report` again when it's done. It updates this run's report in place, and the report\nlists the follow-up. Send the updated report the same way.\n\n- **Outcome:** `live` when the deploy succeeded this run, `failed` when a stage failed and you\n couldn't recover, `stopped` for everything else (the user paused, a dummy or missing step). Leave\n `--outcome` out and the script decides from the log.\n- **Before you write it,** check that every issue, correction and skill gap from this run is in\n `events.jsonl`. Log any that are missing now; the report only shows what was logged.\n- **After you write it,** read the `warning:` lines `report` prints. They list logging gaps: a stage\n that never ended, a start that was estimated, a stage logged with no duration, or (on a live build)\n a stage that was never logged. Fix what you can: `stage-end` an open stage, or correct a start with\n `stage-fix <stage> --started-ago <time>`, then run `report` again. What you can't fix stays in the report's\n *Logging gaps* section.\n- **Tokens:** only what your tool tells you about your own usage (see *Run log and build report* in\n the top-level `SKILL.md`).\n- In the final report's *Where state lives*, add the path to `report.html` so the user can open it.\n\nDo this on every run, including the early stops: those reports are the most useful ones.\n"
246
260
  }
247
261
  ]
248
262
  },