hostinger-api-mcp 3.8.0 → 3.8.1

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ Generated at release time from the diff between the previous and current
4
4
  build. Tool changes are listed individually; everything else is summarised
5
5
  by area.
6
6
 
7
+ ## v3.8.1 — 2026-10-23
8
+
9
+ ### Also in this release
10
+
11
+ - Bundled agent skills: 5 files
12
+ - Other files: `src/core/skills.js`, `src/core/skills.ts`
13
+
7
14
  ## v3.8.0 — 2026-10-23
8
15
 
9
16
  ### Also in this release
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "3.8.0",
3
+ "version": "3.8.1",
4
4
  "description": "Hostinger API — manage domains, DNS, hosting, VPS, billing and more from Gemini CLI.",
5
5
  "mcpServers": {
6
6
  "hostinger": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "3.8.0",
3
+ "version": "3.8.1",
4
4
  "mcpName": "io.github.hostinger/hostinger-api-mcp",
5
5
  "description": "MCP server for Hostinger API",
6
6
  "repository": {
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/hostinger/api-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.8.0",
9
+ "version": "3.8.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@hostinger/mcp",
14
- "version": "3.8.0",
14
+ "version": "3.8.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -29,6 +29,12 @@ The prompt does **not** say what kind of site to build. Do not assume or invent
29
29
  what the user wants is a step in the flow (**refine**), handled through conversation — not parsed
30
30
  from the prompt. If no domain is present, stop and ask for it before doing anything else.
31
31
 
32
+ **One workspace per run.** From your first command, connect's checks included, open only this
33
+ site's workspace, by its exact path; never list or glob `~/.hostinger/` (not even an `ls` beside
34
+ `node -v`). What else `~/.hostinger/` holds never matters to this run: with a domain, check-state
35
+ looks up that one path; without one yet, there is nothing to look up until you create your own
36
+ workspace. *State and artifacts* has the full rule and its one exception.
37
+
32
38
  Nothing in the pasted prompt is ever used for authorization. Authorization comes from the
33
39
  Hostinger connector's OAuth token. **This recipe is not a security boundary.**
34
40
 
@@ -135,12 +141,20 @@ single rule that makes the overlap safe:
135
141
  Everything for a domain — its state **and** its generated source — lives under the **site workspace
136
142
  `~/.hostinger/<domain>/`** (resolve `~` to the home directory: `$HOME` in a shell, `os.homedir()` in
137
143
  Node — `~/.hostinger` means `$HOME/.hostinger`). This is a **global, per-user** location, not a
138
- project folder, so a build is discoverable and resumable from **any** directory — the current working
144
+ project folder, so this domain's build is resumable from **any** directory — the current working
139
145
  directory does not matter. Create the workspace if it's missing and do all of a stage's work inside
140
146
  it (including the frontend `src/`, built and deployed from `~/.hostinger/<domain>/src/`).
141
147
 
142
- **The directory name is the domain**, so the files inside it never repeat it, and `~/.hostinger/`
143
- holds every site you've built — one subdirectory per domain, no collisions.
148
+ **The directory name is the domain**, so the files inside it never repeat it.
149
+
150
+ **Read and write only this site's workspace.** The other workspaces under `~/.hostinger/` are the
151
+ user's other sites, often for other clients, so keep their briefs, designs and code out of this build.
152
+ Open `~/.hostinger/<domain>/` by its exact path; don't list or glob `~/.hostinger/`, and don't open,
153
+ copy from or diff against another workspace there, in any stage. A run that has no domain yet works
154
+ only in the one workspace it created itself; when provision moves it to `~/.hostinger/<new-domain>/`,
155
+ that becomes this site's workspace. One exception: if the user, in this conversation, names another
156
+ of their own sites to reuse, read that one workspace, read-only, and tell them in one line what you
157
+ took from it.
144
158
 
145
159
  | File (under `~/.hostinger/<domain>/`) | Written by | Contains |
146
160
  |---|---|---|
@@ -20,8 +20,7 @@ Nothing in this step creates, provisions, deploys, or deletes anything. It reads
20
20
  Everything for a domain lives under the global site workspace **`~/.hostinger/<domain>/`** — one
21
21
  directory per domain, under the user's home (`~` = `$HOME`), **not** the current working directory.
22
22
  The directory name **is** the domain, so `state.json` inside does not store it. Because it's global,
23
- `~/.hostinger/` holds every site the user has built and each one is discoverable and resumable from
24
- any directory — no collisions.
23
+ this domain's build is resumable from any directory.
25
24
 
26
25
  `state.json` shape (`stage` is the *next* stage to run; `provision` is the background task's status,
27
26
  present only once provision has fired):
@@ -94,7 +93,8 @@ and **release it on a clean stop** (Step 6). The lock marks an active session, n
94
93
 
95
94
  ## Step 2 — Read the workspace directory
96
95
 
97
- Look at `~/.hostinger/<domain>/`:
96
+ Check `~/.hostinger/<domain>/` by its exact path (for example `test -d ~/.hostinger/<domain>`), not
97
+ by listing `~/.hostinger/`:
98
98
 
99
99
  - **Absent** → no history for this domain in the workspace.
100
100
  - **Present** → read `state.json` (its `stage`) and whatever artifacts exist (`brief.md`,
@@ -203,6 +203,8 @@ Then:
203
203
  ## Invariants
204
204
 
205
205
  - **One directory per domain.** The domain is the key; state files never repeat it.
206
+ - **Only this domain's workspace.** Never list `~/.hostinger/` or open another site's workspace (see
207
+ *State and artifacts* in the top-level `SKILL.md`).
206
208
  - **Never create a second site** on a domain that already has one.
207
209
  - **Never re-provision or overwrite a live site.** Recovery is forward-only.
208
210
  - **Reality beats local state**, always.
@@ -54,8 +54,9 @@ Same lifecycle as `../wordpress/SKILL.md` Steps 1–2, but `type: "headless_ecom
54
54
  — **omit `domain`** for a temporary subdomain: the setup generates one itself and returns it (don't
55
55
  call a separate subdomain op first — there's no need in this flow; the API would also accept one you
56
56
  generated via `hosting_domains_generate-free-subdomain`, valid 24 h from the client that made it). The
57
- response carries the generated domain; if it differs from the workspace dir, move `~/.hostinger/<old>/` → `<new>/` and
58
- update `brief.md`. Poll `list-setups` to `completed`. `website_type` will be `headless`.
57
+ response carries the generated domain; if it differs from the workspace dir, move
58
+ `~/.hostinger/<old>/` → `<new>/` (if `<new>/` already exists, stop and ask instead of moving
59
+ into it) and update `brief.md`. Poll `list-setups` to `completed`. `website_type` will be `headless`.
59
60
 
60
61
  > **Onboarding handoff — an empty headless shell needs no new website.** If the domain already has an
61
62
  > empty headless shell (`website_type: headless`, setup `type: headless_empty`, nothing installed —
@@ -49,7 +49,8 @@ All website/database operations below go through the Hostinger connector (`execu
49
49
  separate subdomain op to make one first: there's no need in this flow. (The API would also accept a
50
50
  subdomain you generated via `hosting_domains_generate-free-subdomain`, valid for 24 h from the client
51
51
  that made it — this flow just doesn't use that.) The response then carries the generated domain: if it
52
- differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`,
52
+ differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`
53
+ (if `<new>/` already exists, stop and ask instead of moving into it),
53
54
  update the domain in `brief.md`, and tell the user the new domain.
54
55
  3. Poll `hosting_websites_list-setups { "domain": "<domain>" }` every ~15 s until `completed`
55
56
  (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel.
@@ -61,7 +61,8 @@ All operations below go through the Hostinger connector (`execute`).
61
61
  separate subdomain op to make one first: there's no need in this flow. (The API would also accept a
62
62
  subdomain you generated via `hosting_domains_generate-free-subdomain`, valid for 24 h from the client
63
63
  that made it — this flow just doesn't use that.) The response then carries the generated domain:
64
- if it differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`,
64
+ if it differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`
65
+ (if `<new>/` already exists, stop and ask instead of moving into it),
65
66
  update the domain in `brief.md`, and tell the user the new domain.
66
67
  Admin credentials are generated by the platform; never ask the user for a password.
67
68
  3. Poll `hosting_websites_list-setups { "domain": "<domain>" }` every ~15 s until `completed`
@@ -57,10 +57,10 @@ export default [
57
57
  "resources": [
58
58
  {
59
59
  "uri": "skill://hostinger-headless/SKILL.md",
60
- "digest": "sha256:95ced0006adb47d24b16b01f4cb3eec550529977615732bb1169a9916855da29",
61
- "size": 18989,
60
+ "digest": "sha256:46594f26182f4c088b4add5f95aabf3cbf01831b3eaee33d7f5a6875f46ceede",
61
+ "size": 20064,
62
62
  "mimeType": "text/markdown",
63
- "text": "---\nname: hostinger-headless\ndescription: \"Orchestrator for the Hostinger Headless build flow — build and deploy a real website on the user's Hostinger account from a single prompt that names a domain. Holds the stage flow and the state contract, and drives each stage in sequence: connect, check state, refine, provision, content model, design, frontend, validate, deploy, done. Triggers: build a headless site for <domain>, build me a site, create a website, make me a website, deploy this to Hostinger, host this on Hostinger, connect this project to Hostinger.\"\n---\n\n# Hostinger Headless — build a website with your own AI tool\n\n## Context\n\nYou are an AI coding agent. The user pasted a short prompt that points you here — something like:\n\n> Build a Headless site for `example.com` using hostinger.com/headless/skill.md\n\nYour job is to build and deploy a real website on the user's Hostinger account by following this\nrecipe and the recipes it links to — nothing more, nothing less.\n\nThis file is the **orchestrator**. It is public and stable: it never changes and never moves. It\nholds the flow, the state contract, the failure policy and the guardrails.\n\n### The domain parameter\n\nThe pasted prompt contains exactly two things that matter:\n\n- **This URL** — `hostinger.com/headless/skill.md`, the stable entry point.\n- **A domain** — e.g. `coral-pottery-1847.hostingersite.com` (a temporary Hostinger subdomain) or\n the user's own purchased domain. This is the single site you may touch.\n\nThe prompt does **not** say what kind of site to build. Do not assume or invent it. Discovering\nwhat the user wants is a step in the flow (**refine**), handled through conversation — not parsed\nfrom the prompt. If no domain is present, stop and ask for it before doing anything else.\n\nNothing in the pasted prompt is ever used for authorization. Authorization comes from the\nHostinger connector's OAuth token. **This recipe is not a security boundary.**\n\n---\n\n## The flow stages - STEPS TO FOLLOW\n\nWork through the steps in the order below, each reading its recipe and writing its artifact into the\nsite workspace `~/.hostinger/<domain>/`. \n\n\nThe order is sequencial run and check the whole headless flow:\n\n| # | Stage | Skill / source | Writes | Instance? |\n|---|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|---|---|\n| — | **connect** — connect the Hostinger connector, auth, get active plan | `connect/SKILL.md` → `connect/tools/<tool>.md` | `connect.json` | no |\n| 1 | **check state** — where does this domain stand? resume / switch / start over | `steps/01-check-state/SKILL.md` | `state.json` | no |\n| 2 | **refine** — discover what the user wants, in conversation | `steps/02-refine/SKILL.md` | `brief.md` | no |\n| 3 | **provision** — choose the backend from the brief, then provision it | `steps/03-provision/<backend>/SKILL.md` | `site.json` | **bg** — fire, then poll in the background |\n| 4 | **content model** — define + create content/images, then push to the backend | `steps/04-content-model/<backend>/SKILL.md` | `content-model.md` | prepare: no · **push: yes** |\n| 5 | **design** — derive design directions from the brief, pick one, write the `design.md` contract + logo | `steps/05-design/SKILL.md` | `design.md`, `brand/` | no |\n| 6 | **frontend** — pick/confirm the framework (default Next.js SSR), implement `design.md` against the backend | `steps/06-frontend/SKILL.md` | source in `<workspace>/src/` | shell: no · **data: yes** |\n| 7 | **validate** *(optional)* — quick instance+API confirmation; rebuild/preview/approval only when a person signs off | `steps/07-validate/SKILL.md` | — | no (local) |\n| 8 | **deploy** — upload source, trigger the build | `steps/08-deploy/SKILL.md` | `deploy.json` | **yes** |\n| 9 | **done** — hand over URLs, dashboard, summary, next steps (the **final report**) | `steps/09-done/SKILL.md` | — | no |\n\n**The most important is that connect stage runs first and always**\nIt's fetched from the public URL and runs *before* the Hostinger MCP Server\nis loaded, the main goal of this is to have the Hostinger MCP Server.\n\nYou can find those connect skills under `connect/`.\nThe main skill lives in `connect/SKILL.md`.\nThere are also specific tools under `connect/tools/`.\n\nThe other 9 numbered steps live under `steps/` and are read from the installed skill folder (this will be guided in connect step).\n\n**One concurrency rule:** two *independent* stages in a single session may run at once, but never\ntwo writers of the same artifact, and never two agents/windows on the same domain — the `.lock`\n(Step 1) guards that. Parallelism is always *optional*: an agent that can't interleave work runs the\nsteps serially and is still correct. Saying this, Always that you consider, you can run the steps\nin parallel using different threads or processes to save time, but always ensure the right output.\n\nSteps can generate **State files** those files are only hints; the truth is what the Hostinger connector reports, so\nverify a \"done\" marker against reality before acting on it. If a step's `SKILL.md` is missing from the installed skill folder, stop and tell\nthe user rather than improvising the stage from its description.\n\nThe **Instance?** column says whether a stage touches the live backend:\n`no` = pure prepare work that can run during the provision wait;\n`push`/`data`/`yes` = a commit phase that joins the instance barrier;\n`bg` = provision itself, the background task.\n\n**Always end with the final report.** Whenever a run ends, give the user the final report defined\nin `steps/09-done/SKILL.md`: an outcome paragraph, a *Steps completed* table (one row per stage that\nran), the links, where state lives, and any gaps. That includes runs that stop early at a dummy or\nmissing step, a failed provision, or a user pause. Every run also saves a **build report**\n(`run-log.mjs report`, see *Run log and build report* below), on those early stops too.\n\n**Log the run as you go.** Wrap every stage (and each phase of a split stage) in\n`run-log.mjs stage-start` / `stage-end`, and log an event the moment something goes wrong, the user\ncorrects you, or a skill is missing or wrong — before you continue. See *Run log and build report*.\n\n**Why this order.** connect first, so account/plan failures surface before the user spends time\ntalking. Check-state right after, so a resumed, duplicate, or brand-new run is sorted out — and the\nuser is offered the choice to continue, switch domains, or start over — before any new work begins.\nRefinement before provisioning, because the backend cannot be chosen until the brief exists.\nValidation before deploy, always — nothing reaches the live domain unseen.\n\n### Background provision & the instance barrier\n\n**Provision always runs — what it does depends on what the domain already has.** Often onboarding on\nhostinger.com has already created the website before you run, so the domain *has* a headless instance;\nbut an instance existing does **not** mean provision is done or can be skipped. `check_state` works out\nwhich of these cases applies and records it, then provision takes the matching path:\n\n1. **An empty headless shell already exists for the domain (the common onboarding case).** Onboarding\n creates the website first — an **empty headless shell** (`hosting_websites_start-setup { type:\n \"headless_empty\" }`): `website_type` is `headless`, but **no backend is installed** (no WordPress at\n `/cms`, no store). This still goes through provision, which **adopts the shell and swaps it to the\n chosen backend** (the backend is picked in refine first): WordPress is installed into the shell's\n `/cms` with `wordpress_installations_install { type: \"headless\" }`, or the shell is adopted as the\n Ecommerce storefront host. **Never skip provision for a shell, and never create a second site.** See\n the provision router's *Onboarding handoff*.\n2. **No instance exists at all** (the user ran the headless flow without onboarding provisioning\n anything). Provision creates a fresh headless instance with `hosting_websites_start-setup` using the\n chosen backend's own `type`.\n3. **User wants a webapp.** Provision is a plain Node.js app host, not headless — `webapp` ignores\n headless shells entirely and is only reached by experimental actions, never the onboarding flow.\n\nEither way the create/install is asynchronous and returns `queued` immediately, then it's polled\nevery ~15 s for a few minutes — **what you poll depends on the path**: a fresh\n`hosting_websites_start-setup` is polled with `hosting_websites_list-setups` until `completed`, while a\nshell swap (`wordpress_installations_install`) creates no new setup, so it's polled with\n`wordpress_installations_list` until the `/cms` row is `is_valid`. Don't block on that poll. The\nsingle rule that makes the overlap safe:\n\n> **Fire provision as a background task, then do every stage's prepare work while it settles. The\n> moment a step must write to the live instance, it waits for provision to report ready (via the\n> backend step's readiness poll — see above). That wait is the *instance barrier*.**\n\n---\n\n## State and artifacts\n\nEverything for a domain — its state **and** its generated source — lives under the **site workspace\n`~/.hostinger/<domain>/`** (resolve `~` to the home directory: `$HOME` in a shell, `os.homedir()` in\nNode — `~/.hostinger` means `$HOME/.hostinger`). This is a **global, per-user** location, not a\nproject folder, so a build is discoverable and resumable from **any** directory — the current working\ndirectory does not matter. Create the workspace if it's missing and do all of a stage's work inside\nit (including the frontend `src/`, built and deployed from `~/.hostinger/<domain>/src/`).\n\n**The directory name is the domain**, so the files inside it never repeat it, and `~/.hostinger/`\nholds every site you've built — one subdirectory per domain, no collisions.\n\n| File (under `~/.hostinger/<domain>/`) | Written by | Contains |\n|---|---|---|\n| `state.json` | every stage | tool, next stage, Hostinger connector version, timestamps |\n| `connect.json` | connect | tool, account/plan readiness |\n| `brief.md` | refine | refined brief, audience, tone, pages, must-haves |\n| `site.json` | provision | chosen backend, username, install id, all URLs |\n| `content-model.md` | content model | entities, fields, pages, navigation |\n| `design.md` (+ `brand/` logo files) | design | chosen design direction: tokens, type, logo, components, per-page composition, accessibility |\n| `src/` | frontend | the website source implementing `design.md`; framework choice is recorded in `state.json → frontend` |\n| `deploy.json` | deploy | build id, deployed at, live URLs |\n| `events.jsonl` | every stage, via `run-log.mjs` | append-only log of issues, user feedback, skill gaps and wins |\n| `reports/<run>.json` + `.html`, `report.html` | done (or any early stop), via `run-log.mjs` | the build report for each run; `report.html` is the latest |\n\n`state.json` is the minimum you must keep current. **`stage` is the *next* stage to run** (not the\nlast completed one), and it does **not** store the domain — the directory is the key. Because\nprovision runs in the background, its status lives in its own `provision` field rather than in\n`stage`, so prepare work can advance `stage` while provision is still settling:\n\n```json\n{\n \"tool\": \"claude-code\",\n \"stage\": \"content_model\",\n \"provision\": {\n \"status\": \"in_progress\",\n \"domain\": \"example.com\",\n \"started_at\": \"2026-10-08T10:00:00Z\"\n },\n \"updated_at\": \"2026-10-08T10:00:00Z\"\n}\n```\n\n- `provision` is **absent** until provision fires, `{\"status\":\"in_progress\", …}` while the\n background poll runs, and `{\"status\":\"completed\", …}` once `hosting_websites_list-setups` confirms\n it. `domain` is the poll target.\n- Any **instance-barrier** step (content-model push, frontend data, deploy) must read\n `provision.status == \"completed\"` — and re-verify it against the connector — before it runs. A\n prepare step ignores `provision` entirely.\n- On **resume**, `provision.status` is a hint only: re-poll `hosting_websites_list-setups` to learn\n the truth, then update the field.\n- `run` (the current run), `history` (one entry per stage or phase, with real start and end\n times), and `reports_sent` / `reports_rejected` (which copy of each report reached Hostinger) are\n written **only** by `run-log.mjs`. Leave them alone when you update `state.json` by hand.\n\n---\n\n## Run log and build report\n\n`run-log.mjs` (next to this file) records the run while it happens and turns it into a report at the\nend. Timestamps come from the script, so they're measured, not remembered. It needs only Node.\nEvery command is `node <skill>/run-log.mjs <domain> …`, where `<skill>` is the folder this file is\nin. Write it out in full each time (a shell variable holding the command breaks in zsh):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> # check-state, first thing\nnode <skill>/run-log.mjs <domain> run-update --kind resume # check-state, once the route is agreed (or redesign / iterate)\nnode <skill>/run-log.mjs <domain> stage-start frontend # before a stage\nnode <skill>/run-log.mjs <domain> stage-end frontend ok_after_retries --note \"second framework tried\"\nnode <skill>/run-log.mjs <domain> stage-start frontend --phase shell # split stages log each phase\nnode <skill>/run-log.mjs <domain> event issue '{\"stage\":\"content_model\",\"symptom\":\"…\",\"cause\":\"…\",\"workaround\":\"…\",\"severity\":\"medium\",\"resolved\":true}'\nnode <skill>/run-log.mjs <domain> report --outcome live # done, or wherever the run stops\nnode <skill>/run-log.mjs <domain> report-sent <run> # after the report was sent (see below)\nnode <skill>/run-log.mjs <domain> report-rejected <run> # the send returned 422\nnode <skill>/run-log.mjs <domain> unsent # check-state: reports still to send\nnode <skill>/run-log.mjs <domain> run-resume --reason \"about/contact layout fix\" # follow-up in the same session\n```\n\n**Follow-ups continue the run.** When the user asks for a change after the final report in the\nsame session (a layout fix, a copy tweak, a redeploy), that work belongs to the run it follows:\ncall `run-resume`, log the feedback and the stages it re-runs as usual, then `report` again. That\nrewrites the same `reports/<run>.json` and `.html`, so you get one report that includes the follow-up,\nnot a second report that replaces `report.html`. Use `run-start` only in a new session; check-state\ndoes that.\n\n`stage-end` results: `ok`, `ok_after_retries`, `ok_with_fixups`, `failed`, `skipped`, `stopped`.\nStages: `check_state`, `refine`, `provision`, `content_model`, `design`, `frontend`, `validate`,\n`deploy`, `done` (connect has its own `connect.json`). Split stages use `--phase`: provision `fire` /\n`join`, content model `prepare` / `push`, frontend `shell` / `data`.\n\n**Forgot a `stage-start`?** Log it as soon as you notice, with roughly when the stage began:\n`stage-start frontend --started-ago 6m` (or `--started-at <ISO time>`). The same flags work on a\n`stage-end` that has no start. With neither, the script assumes the stage began when the previous one\nended. Either way the start is marked as estimated in the report. Never log a start and an end\nback to back after the work is done: that records a stage that took no time. If you already did,\ncorrect it with `stage-fix frontend --started-ago 6m`.\n\n**Log an event the moment it happens**, not at the end — details are gone by then:\n\n| Event | Log it when | Fields (required in bold) |\n|---|---|---|\n| `issue` | something fails, misbehaves, or needs a workaround — including your own mistakes | **stage**, **symptom**, cause, workaround, severity (`high`/`medium`/`low`), resolved, user_had_to_intervene |\n| `feedback` | the user corrects you, rejects something, or changes scope | **stage**, **category** (e.g. `design_rejected`, `layout_bug`, `scope_add`, `wrong_assumption`), **paraphrase** |\n| `skill_gap` | a step is dummy, missing, wrong or silent on what you needed | **skill** (path), **gap** |\n| `good` | something worked notably well or fast | **text** |\n\nParaphrase the user; don't paste long quotes, file contents, credentials or personal data. Set\n`user_had_to_intervene` when the user had to point the problem out.\n\n**Tokens** go on `report --tokens '<json>'` only if your tool tells you your own usage directly\n(`{\"source\":\"…\",\"requests\":…,\"input\":…,\"output\":…,\"cache_read\":…,\"cache_write\":…,\"est_cost_usd\":…}`).\nOtherwise leave it out and the report says `{\"source\":\"not reported\"}` (not null: the connector rejects a null `tokens`). Never read session transcripts or log\nfiles to get it.\n\nThe report is saved to `reports/<run>.json` and `.html`, and `report.html` in the workspace is the\nlatest. If a run never reaches its report (the window closed), the next `run-start` closes it as\n`abandoned`, so its log still becomes a report.\n\n**Send every report to Hostinger.** Sending is part of the flow, so don't ask first; tell the user in\none line right after the send (see `09-done`). Right after each\n`report` (follow-ups included), read `reports/<run>.json` and pass its contents, unchanged, as the\narguments of the Hostinger connector tool `hosting_headless_submit-build-report`. Then record the\nresult:\n\n- **Success:** `report-sent <run>`.\n- **422** (the report doesn't match the schema the endpoint holds): `report-rejected <run>`. Don't\n resend it.\n- **Anything else** (network error, 5xx, the tool isn't in your connector): do nothing. The next\n check-state sends it again.\n\nNever edit the JSON before sending, never add account details (the connector's login identifies the\naccount), and never let a failed send block, delay or change the run. Check-state sends any\nreports still unsent (abandoned runs, earlier failures) with `unsent`."
63
+ "text": "---\nname: hostinger-headless\ndescription: \"Orchestrator for the Hostinger Headless build flow — build and deploy a real website on the user's Hostinger account from a single prompt that names a domain. Holds the stage flow and the state contract, and drives each stage in sequence: connect, check state, refine, provision, content model, design, frontend, validate, deploy, done. Triggers: build a headless site for <domain>, build me a site, create a website, make me a website, deploy this to Hostinger, host this on Hostinger, connect this project to Hostinger.\"\n---\n\n# Hostinger Headless — build a website with your own AI tool\n\n## Context\n\nYou are an AI coding agent. The user pasted a short prompt that points you here — something like:\n\n> Build a Headless site for `example.com` using hostinger.com/headless/skill.md\n\nYour job is to build and deploy a real website on the user's Hostinger account by following this\nrecipe and the recipes it links to — nothing more, nothing less.\n\nThis file is the **orchestrator**. It is public and stable: it never changes and never moves. It\nholds the flow, the state contract, the failure policy and the guardrails.\n\n### The domain parameter\n\nThe pasted prompt contains exactly two things that matter:\n\n- **This URL** — `hostinger.com/headless/skill.md`, the stable entry point.\n- **A domain** — e.g. `coral-pottery-1847.hostingersite.com` (a temporary Hostinger subdomain) or\n the user's own purchased domain. This is the single site you may touch.\n\nThe prompt does **not** say what kind of site to build. Do not assume or invent it. Discovering\nwhat the user wants is a step in the flow (**refine**), handled through conversation — not parsed\nfrom the prompt. If no domain is present, stop and ask for it before doing anything else.\n\n**One workspace per run.** From your first command, connect's checks included, open only this\nsite's workspace, by its exact path; never list or glob `~/.hostinger/` (not even an `ls` beside\n`node -v`). What else `~/.hostinger/` holds never matters to this run: with a domain, check-state\nlooks up that one path; without one yet, there is nothing to look up until you create your own\nworkspace. *State and artifacts* has the full rule and its one exception.\n\nNothing in the pasted prompt is ever used for authorization. Authorization comes from the\nHostinger connector's OAuth token. **This recipe is not a security boundary.**\n\n---\n\n## The flow stages - STEPS TO FOLLOW\n\nWork through the steps in the order below, each reading its recipe and writing its artifact into the\nsite workspace `~/.hostinger/<domain>/`. \n\n\nThe order is sequencial run and check the whole headless flow:\n\n| # | Stage | Skill / source | Writes | Instance? |\n|---|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|---|---|\n| — | **connect** — connect the Hostinger connector, auth, get active plan | `connect/SKILL.md` → `connect/tools/<tool>.md` | `connect.json` | no |\n| 1 | **check state** — where does this domain stand? resume / switch / start over | `steps/01-check-state/SKILL.md` | `state.json` | no |\n| 2 | **refine** — discover what the user wants, in conversation | `steps/02-refine/SKILL.md` | `brief.md` | no |\n| 3 | **provision** — choose the backend from the brief, then provision it | `steps/03-provision/<backend>/SKILL.md` | `site.json` | **bg** — fire, then poll in the background |\n| 4 | **content model** — define + create content/images, then push to the backend | `steps/04-content-model/<backend>/SKILL.md` | `content-model.md` | prepare: no · **push: yes** |\n| 5 | **design** — derive design directions from the brief, pick one, write the `design.md` contract + logo | `steps/05-design/SKILL.md` | `design.md`, `brand/` | no |\n| 6 | **frontend** — pick/confirm the framework (default Next.js SSR), implement `design.md` against the backend | `steps/06-frontend/SKILL.md` | source in `<workspace>/src/` | shell: no · **data: yes** |\n| 7 | **validate** *(optional)* — quick instance+API confirmation; rebuild/preview/approval only when a person signs off | `steps/07-validate/SKILL.md` | — | no (local) |\n| 8 | **deploy** — upload source, trigger the build | `steps/08-deploy/SKILL.md` | `deploy.json` | **yes** |\n| 9 | **done** — hand over URLs, dashboard, summary, next steps (the **final report**) | `steps/09-done/SKILL.md` | — | no |\n\n**The most important is that connect stage runs first and always**\nIt's fetched from the public URL and runs *before* the Hostinger MCP Server\nis loaded, the main goal of this is to have the Hostinger MCP Server.\n\nYou can find those connect skills under `connect/`.\nThe main skill lives in `connect/SKILL.md`.\nThere are also specific tools under `connect/tools/`.\n\nThe other 9 numbered steps live under `steps/` and are read from the installed skill folder (this will be guided in connect step).\n\n**One concurrency rule:** two *independent* stages in a single session may run at once, but never\ntwo writers of the same artifact, and never two agents/windows on the same domain — the `.lock`\n(Step 1) guards that. Parallelism is always *optional*: an agent that can't interleave work runs the\nsteps serially and is still correct. Saying this, Always that you consider, you can run the steps\nin parallel using different threads or processes to save time, but always ensure the right output.\n\nSteps can generate **State files** those files are only hints; the truth is what the Hostinger connector reports, so\nverify a \"done\" marker against reality before acting on it. If a step's `SKILL.md` is missing from the installed skill folder, stop and tell\nthe user rather than improvising the stage from its description.\n\nThe **Instance?** column says whether a stage touches the live backend:\n`no` = pure prepare work that can run during the provision wait;\n`push`/`data`/`yes` = a commit phase that joins the instance barrier;\n`bg` = provision itself, the background task.\n\n**Always end with the final report.** Whenever a run ends, give the user the final report defined\nin `steps/09-done/SKILL.md`: an outcome paragraph, a *Steps completed* table (one row per stage that\nran), the links, where state lives, and any gaps. That includes runs that stop early at a dummy or\nmissing step, a failed provision, or a user pause. Every run also saves a **build report**\n(`run-log.mjs report`, see *Run log and build report* below), on those early stops too.\n\n**Log the run as you go.** Wrap every stage (and each phase of a split stage) in\n`run-log.mjs stage-start` / `stage-end`, and log an event the moment something goes wrong, the user\ncorrects you, or a skill is missing or wrong — before you continue. See *Run log and build report*.\n\n**Why this order.** connect first, so account/plan failures surface before the user spends time\ntalking. Check-state right after, so a resumed, duplicate, or brand-new run is sorted out — and the\nuser is offered the choice to continue, switch domains, or start over — before any new work begins.\nRefinement before provisioning, because the backend cannot be chosen until the brief exists.\nValidation before deploy, always — nothing reaches the live domain unseen.\n\n### Background provision & the instance barrier\n\n**Provision always runs — what it does depends on what the domain already has.** Often onboarding on\nhostinger.com has already created the website before you run, so the domain *has* a headless instance;\nbut an instance existing does **not** mean provision is done or can be skipped. `check_state` works out\nwhich of these cases applies and records it, then provision takes the matching path:\n\n1. **An empty headless shell already exists for the domain (the common onboarding case).** Onboarding\n creates the website first — an **empty headless shell** (`hosting_websites_start-setup { type:\n \"headless_empty\" }`): `website_type` is `headless`, but **no backend is installed** (no WordPress at\n `/cms`, no store). This still goes through provision, which **adopts the shell and swaps it to the\n chosen backend** (the backend is picked in refine first): WordPress is installed into the shell's\n `/cms` with `wordpress_installations_install { type: \"headless\" }`, or the shell is adopted as the\n Ecommerce storefront host. **Never skip provision for a shell, and never create a second site.** See\n the provision router's *Onboarding handoff*.\n2. **No instance exists at all** (the user ran the headless flow without onboarding provisioning\n anything). Provision creates a fresh headless instance with `hosting_websites_start-setup` using the\n chosen backend's own `type`.\n3. **User wants a webapp.** Provision is a plain Node.js app host, not headless — `webapp` ignores\n headless shells entirely and is only reached by experimental actions, never the onboarding flow.\n\nEither way the create/install is asynchronous and returns `queued` immediately, then it's polled\nevery ~15 s for a few minutes — **what you poll depends on the path**: a fresh\n`hosting_websites_start-setup` is polled with `hosting_websites_list-setups` until `completed`, while a\nshell swap (`wordpress_installations_install`) creates no new setup, so it's polled with\n`wordpress_installations_list` until the `/cms` row is `is_valid`. Don't block on that poll. The\nsingle rule that makes the overlap safe:\n\n> **Fire provision as a background task, then do every stage's prepare work while it settles. The\n> moment a step must write to the live instance, it waits for provision to report ready (via the\n> backend step's readiness poll — see above). That wait is the *instance barrier*.**\n\n---\n\n## State and artifacts\n\nEverything for a domain — its state **and** its generated source — lives under the **site workspace\n`~/.hostinger/<domain>/`** (resolve `~` to the home directory: `$HOME` in a shell, `os.homedir()` in\nNode — `~/.hostinger` means `$HOME/.hostinger`). This is a **global, per-user** location, not a\nproject folder, so this domain's build is resumable from **any** directory — the current working\ndirectory does not matter. Create the workspace if it's missing and do all of a stage's work inside\nit (including the frontend `src/`, built and deployed from `~/.hostinger/<domain>/src/`).\n\n**The directory name is the domain**, so the files inside it never repeat it.\n\n**Read and write only this site's workspace.** The other workspaces under `~/.hostinger/` are the\nuser's other sites, often for other clients, so keep their briefs, designs and code out of this build.\nOpen `~/.hostinger/<domain>/` by its exact path; don't list or glob `~/.hostinger/`, and don't open,\ncopy from or diff against another workspace there, in any stage. A run that has no domain yet works\nonly in the one workspace it created itself; when provision moves it to `~/.hostinger/<new-domain>/`,\nthat becomes this site's workspace. One exception: if the user, in this conversation, names another\nof their own sites to reuse, read that one workspace, read-only, and tell them in one line what you\ntook from it.\n\n| File (under `~/.hostinger/<domain>/`) | Written by | Contains |\n|---|---|---|\n| `state.json` | every stage | tool, next stage, Hostinger connector version, timestamps |\n| `connect.json` | connect | tool, account/plan readiness |\n| `brief.md` | refine | refined brief, audience, tone, pages, must-haves |\n| `site.json` | provision | chosen backend, username, install id, all URLs |\n| `content-model.md` | content model | entities, fields, pages, navigation |\n| `design.md` (+ `brand/` logo files) | design | chosen design direction: tokens, type, logo, components, per-page composition, accessibility |\n| `src/` | frontend | the website source implementing `design.md`; framework choice is recorded in `state.json → frontend` |\n| `deploy.json` | deploy | build id, deployed at, live URLs |\n| `events.jsonl` | every stage, via `run-log.mjs` | append-only log of issues, user feedback, skill gaps and wins |\n| `reports/<run>.json` + `.html`, `report.html` | done (or any early stop), via `run-log.mjs` | the build report for each run; `report.html` is the latest |\n\n`state.json` is the minimum you must keep current. **`stage` is the *next* stage to run** (not the\nlast completed one), and it does **not** store the domain — the directory is the key. Because\nprovision runs in the background, its status lives in its own `provision` field rather than in\n`stage`, so prepare work can advance `stage` while provision is still settling:\n\n```json\n{\n \"tool\": \"claude-code\",\n \"stage\": \"content_model\",\n \"provision\": {\n \"status\": \"in_progress\",\n \"domain\": \"example.com\",\n \"started_at\": \"2026-10-08T10:00:00Z\"\n },\n \"updated_at\": \"2026-10-08T10:00:00Z\"\n}\n```\n\n- `provision` is **absent** until provision fires, `{\"status\":\"in_progress\", …}` while the\n background poll runs, and `{\"status\":\"completed\", …}` once `hosting_websites_list-setups` confirms\n it. `domain` is the poll target.\n- Any **instance-barrier** step (content-model push, frontend data, deploy) must read\n `provision.status == \"completed\"` — and re-verify it against the connector — before it runs. A\n prepare step ignores `provision` entirely.\n- On **resume**, `provision.status` is a hint only: re-poll `hosting_websites_list-setups` to learn\n the truth, then update the field.\n- `run` (the current run), `history` (one entry per stage or phase, with real start and end\n times), and `reports_sent` / `reports_rejected` (which copy of each report reached Hostinger) are\n written **only** by `run-log.mjs`. Leave them alone when you update `state.json` by hand.\n\n---\n\n## Run log and build report\n\n`run-log.mjs` (next to this file) records the run while it happens and turns it into a report at the\nend. Timestamps come from the script, so they're measured, not remembered. It needs only Node.\nEvery command is `node <skill>/run-log.mjs <domain> …`, where `<skill>` is the folder this file is\nin. Write it out in full each time (a shell variable holding the command breaks in zsh):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> # check-state, first thing\nnode <skill>/run-log.mjs <domain> run-update --kind resume # check-state, once the route is agreed (or redesign / iterate)\nnode <skill>/run-log.mjs <domain> stage-start frontend # before a stage\nnode <skill>/run-log.mjs <domain> stage-end frontend ok_after_retries --note \"second framework tried\"\nnode <skill>/run-log.mjs <domain> stage-start frontend --phase shell # split stages log each phase\nnode <skill>/run-log.mjs <domain> event issue '{\"stage\":\"content_model\",\"symptom\":\"…\",\"cause\":\"…\",\"workaround\":\"…\",\"severity\":\"medium\",\"resolved\":true}'\nnode <skill>/run-log.mjs <domain> report --outcome live # done, or wherever the run stops\nnode <skill>/run-log.mjs <domain> report-sent <run> # after the report was sent (see below)\nnode <skill>/run-log.mjs <domain> report-rejected <run> # the send returned 422\nnode <skill>/run-log.mjs <domain> unsent # check-state: reports still to send\nnode <skill>/run-log.mjs <domain> run-resume --reason \"about/contact layout fix\" # follow-up in the same session\n```\n\n**Follow-ups continue the run.** When the user asks for a change after the final report in the\nsame session (a layout fix, a copy tweak, a redeploy), that work belongs to the run it follows:\ncall `run-resume`, log the feedback and the stages it re-runs as usual, then `report` again. That\nrewrites the same `reports/<run>.json` and `.html`, so you get one report that includes the follow-up,\nnot a second report that replaces `report.html`. Use `run-start` only in a new session; check-state\ndoes that.\n\n`stage-end` results: `ok`, `ok_after_retries`, `ok_with_fixups`, `failed`, `skipped`, `stopped`.\nStages: `check_state`, `refine`, `provision`, `content_model`, `design`, `frontend`, `validate`,\n`deploy`, `done` (connect has its own `connect.json`). Split stages use `--phase`: provision `fire` /\n`join`, content model `prepare` / `push`, frontend `shell` / `data`.\n\n**Forgot a `stage-start`?** Log it as soon as you notice, with roughly when the stage began:\n`stage-start frontend --started-ago 6m` (or `--started-at <ISO time>`). The same flags work on a\n`stage-end` that has no start. With neither, the script assumes the stage began when the previous one\nended. Either way the start is marked as estimated in the report. Never log a start and an end\nback to back after the work is done: that records a stage that took no time. If you already did,\ncorrect it with `stage-fix frontend --started-ago 6m`.\n\n**Log an event the moment it happens**, not at the end — details are gone by then:\n\n| Event | Log it when | Fields (required in bold) |\n|---|---|---|\n| `issue` | something fails, misbehaves, or needs a workaround — including your own mistakes | **stage**, **symptom**, cause, workaround, severity (`high`/`medium`/`low`), resolved, user_had_to_intervene |\n| `feedback` | the user corrects you, rejects something, or changes scope | **stage**, **category** (e.g. `design_rejected`, `layout_bug`, `scope_add`, `wrong_assumption`), **paraphrase** |\n| `skill_gap` | a step is dummy, missing, wrong or silent on what you needed | **skill** (path), **gap** |\n| `good` | something worked notably well or fast | **text** |\n\nParaphrase the user; don't paste long quotes, file contents, credentials or personal data. Set\n`user_had_to_intervene` when the user had to point the problem out.\n\n**Tokens** go on `report --tokens '<json>'` only if your tool tells you your own usage directly\n(`{\"source\":\"…\",\"requests\":…,\"input\":…,\"output\":…,\"cache_read\":…,\"cache_write\":…,\"est_cost_usd\":…}`).\nOtherwise leave it out and the report says `{\"source\":\"not reported\"}` (not null: the connector rejects a null `tokens`). Never read session transcripts or log\nfiles to get it.\n\nThe report is saved to `reports/<run>.json` and `.html`, and `report.html` in the workspace is the\nlatest. If a run never reaches its report (the window closed), the next `run-start` closes it as\n`abandoned`, so its log still becomes a report.\n\n**Send every report to Hostinger.** Sending is part of the flow, so don't ask first; tell the user in\none line right after the send (see `09-done`). Right after each\n`report` (follow-ups included), read `reports/<run>.json` and pass its contents, unchanged, as the\narguments of the Hostinger connector tool `hosting_headless_submit-build-report`. Then record the\nresult:\n\n- **Success:** `report-sent <run>`.\n- **422** (the report doesn't match the schema the endpoint holds): `report-rejected <run>`. Don't\n resend it.\n- **Anything else** (network error, 5xx, the tool isn't in your connector): do nothing. The next\n check-state sends it again.\n\nNever edit the JSON before sending, never add account details (the connector's login identifies the\naccount), and never let a failed send block, delay or change the run. Check-state sends any\nreports still unsent (abandoned runs, earlier failures) with `unsent`."
64
64
  },
65
65
  {
66
66
  "uri": "skill://hostinger-headless/connect/SKILL.md",
@@ -134,10 +134,10 @@ export default [
134
134
  },
135
135
  {
136
136
  "uri": "skill://hostinger-headless/steps/01-check-state/SKILL.md",
137
- "digest": "sha256:4ce8632b184b5f594faacf93120d806a5d3eeb8ab3c54cc128e6da9c4c4edfa6",
138
- "size": 12154,
137
+ "digest": "sha256:2908794f7c9421e2eafccec55481532200e91e47761de3771734ad6345c42136",
138
+ "size": 12323,
139
139
  "mimeType": "text/markdown",
140
- "text": "---\nname: headless-step-check-state\ndescription: \"First build step of the Hostinger Headless flow, run right after connect. Works out where a given domain's build stands — is a run already in progress, does the site already exist, how far did it get — by reconciling the global workspace state under ~/.hostinger/<domain>/ with what the Hostinger connector reports, then guides the user to continue, switch or buy a domain, or start over. Owns all resume/position logic for the flow.\"\n---\n\n# headless-step-check-state\n\nThe first build step of the Hostinger Headless flow (orchestrated by `hostinger.com/headless/skill.md`),\nrun right after connect. Connect has already connected the Hostinger connector and confirmed the\naccount can build. Your job here is to\n**figure out where this domain stands and agree the route with the user** before any new work begins.\nThis step owns all resume/position logic — no other stage second-guesses it.\n\nNothing in this step creates, provisions, deploys, or deletes anything. It reads and guides.\n\n---\n\n## Where state lives\n\nEverything for a domain lives under the global site workspace **`~/.hostinger/<domain>/`** — one\ndirectory per domain, under the user's home (`~` = `$HOME`), **not** the current working directory.\nThe directory name **is** the domain, so `state.json` inside does not store it. Because it's global,\n`~/.hostinger/` holds every site the user has built and each one is discoverable and resumable from\nany directory — no collisions.\n\n`state.json` shape (`stage` is the *next* stage to run; `provision` is the background task's status,\npresent only once provision has fired):\n\n```json\n{ \"tool\": \"claude-code\", \"stage\": \"content_model\",\n \"provision\": { \"status\": \"in_progress\", \"domain\": \"<domain>\", \"started_at\": \"…\" },\n \"updated_at\": \"…\" }\n```\n\n`provision.status` is `in_progress` or `completed` — but it is a **hint**: always re-verify it against\n`hosting_websites_list-setups` (Step 1) before trusting it to gate an instance-barrier step.\n\n---\n\n## Step 0 — Start the run log\n\nBefore anything else, start a run and the stage (see *Run log and build report* in the top-level\n`SKILL.md`):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version>\nnode <skill>/run-log.mjs <domain> stage-start check_state\n```\n\nIf an earlier run on this domain never wrote its report, `run-start` closes it as `abandoned` first.\n\nThen send any build reports that haven't reached Hostinger yet (that abandoned run, or a send that\nfailed last time):\n\n```bash\nnode <skill>/run-log.mjs <domain> unsent\n```\n\nEach line is `<run> <path>`. For each, send the file through `hosting_headless_submit-build-report`\nand record the result, as described in *Run log and build report* in the top-level `SKILL.md`. One\ntry each. Don't ask first; if any went out, say so in one line (e.g. \"Sent 1 earlier build report to\nHostinger.\"). Don't let a failure hold up this step.\nCheck-state runs once per session. A follow-up after the final report, in the same session, doesn't\ncome back here: it calls `run-resume` (see *Run log and build report* in the top-level `SKILL.md`).\n\n## Step 1 — Is a build actively running right now? (check this before anything else)\n\nKeep two things separate, and **never guess from a timestamp.**\n\n- **A platform build actively in progress** — the only *reliable* \"running now\" signal. Check\n `hosting_websites_list-setups { \"domain\": \"<domain>\" }` and exact-match the domain row:\n - `status: running` → provisioning is underway **right now** (a background provision from an earlier\n turn). Don't start a second one — reconcile `state.json`'s `provision` to `in_progress`, and\n either resume prepare work while it finishes or poll it to `completed` at the barrier.\n - `status: failed` → not running; route to provision to self-heal (Step 3).\n - `completed` → provision is done; set `provision.status` to `completed`. Instance-barrier steps\n (content push, frontend data, deploy) are now unblocked.\n - no row → not provisioned yet; carry on.\n\n This check **is** the source of truth for `provision.status`: a stored `in_progress`/`completed` in\n `state.json` is only a hint until this reconciles it.\n\n- **A `.lock`** — `~/.hostinger/<domain>/.lock`. It only tells you *a previous run\n touched this domain*. **Do not judge it by age** — a 2-minute-old lock and a 3-year-old lock get the\n same treatment, and a lock on its own is **not** a reason to interrupt the user. Carry it into Step 5:\n if there's nothing to resume (new build), silently take it over; only if there's real state to resume\n do you also ask *is another Claude window building this domain right now?* (yes → stop; no → take\n over).\n\nOnce you're actively working this domain, **acquire/refresh the lock** (write the current timestamp),\nand **release it on a clean stop** (Step 6). The lock marks an active session, nothing more.\n\n---\n\n## Step 2 — Read the workspace directory\n\nLook at `~/.hostinger/<domain>/`:\n\n- **Absent** → no history for this domain in the workspace.\n- **Present** → read `state.json` (its `stage`) and whatever artifacts exist (`brief.md`,\n `site.json`, …). Treat these as a hint, not the truth.\n\n---\n\n## Step 3 — Verify against reality (reality wins)\n\nQuery the Hostinger connector — the local files are never trusted on their own:\n\n- **Does the site exist?** `hosting_websites_list { \"domain\": \"<domain>\" }`. ⚠️ This filter is a\n **case-insensitive substring match**, not exact — so `test.com` would also return\n `shop.test.com`. **Compare each returned row's `domain` to your target for an *exact* match**\n (case-insensitive) and ignore the rest. No exact row ⇒ the domain is free. An exact row ⇒ the site\n exists; note its `website_type` (`headless`, `wordpress`, `nodejs`, …) and `username`.\n- **How far did it get?** If the site exists, check its setup via\n `hosting_websites_list-setups { \"domain\": \"<domain>\" }` (exact-match the row the same way):\n - `status: completed` → provisioned; then, depending on backend, check whether a build has succeeded.\n - `status: running` → still provisioning (see Step 1).\n - `status: failed` → provisioning stopped before finishing → route to **provision** to self-heal.\n- **Is it an empty headless shell from onboarding?** A `headless` site whose setup is `completed` is\n **not necessarily finished** — onboarding now creates the website *before* the build runs, as an\n **empty headless shell** (`hosting_websites_start-setup { type: \"headless_empty\" }`): `website_type`\n is `headless` but **no backend is installed** (no WordPress at `/cms`, no store). Tell a shell from a\n real backend the same way provision does: within 24 h the setup `type` is `headless_empty`; otherwise\n `wordpress_installations_list { username, domain, ownership: \"all\" }` returns **no** `/cms` row and\n there is no `site.json` recording a backend. An empty shell means **provision has not chosen or\n installed a backend yet** — this is a **new build** that happens to already own its website, not a\n finished or resumable one. Route it to **refine** (choose the backend), then **provision** adopts the\n shell and swaps it to that backend (see the provision router's *Onboarding handoff*). Never create a\n second site, and never skip provision for it.\n\nReconcile with the local state. **When they disagree, reality wins** and the local `stage` is\ncorrected.\n\n---\n\n## Step 4 — Determine the position\n\nConnect always writes `state.json` (with `stage: check_state`) before this step runs, so local\nstate is never truly empty. **\"Local state: none\" here means only connect artifacts exist** —\n`connect.json` + a `state.json` still pointing at `check_state`/`refine`, and **no build artifacts\nyet** (`brief.md`, `site.json`, …).\n\n| Local state | Reality (domain) | Position |\n|---|---|---|\n| none (connect only) | free | **new build** — start from refine |\n| none (connect only) | empty headless shell (onboarding) | **new build on an existing shell** — start from refine; provision adopts the shell and swaps it to the chosen backend. Never create a second site, never skip provision |\n| none (connect only) | site exists with a real backend | **adopt / resume** — never create a second site; route to the first unfinished stage |\n| build artifacts present, incomplete | matches | **resume** at `state.json`'s `stage` |\n| build artifacts present | disagrees | **reality wins** — recompute the stage from what actually exists |\n| build artifacts present, all done | live site exists | **finished** — this is now an *iterate* request, not a build |\n\n---\n\n## Step 5 — Guide the user\n\nThe question to ask depends on whether there is actually **something to resume** — i.e. **build\nartifacts** (`brief.md`, `site.json`, …) or a **live site** for this domain. A stray `.lock` or\nconnect-only files are *not* something to resume.\n\n- **New build** (no build artifacts and either the domain is free **or** it holds only an empty\n headless shell from onboarding) → just proceed to refine. **Don't ask resume vs start over** —\n there's nothing to resume; both would land in the same place. An empty headless shell is not\n \"something to resume\": it has no backend and no content yet, so it's a fresh build that already owns\n its website — provision will adopt and swap it. Mention it in one line (\"Using the headless site\n onboarding already created for this domain.\"). If a leftover `.lock` is lying around, silently take\n it over and continue (optionally mention it in one line).\n- **Something to resume** (build artifacts exist, or a site exists for the domain **with a real backend\n installed** — WordPress at `/cms`, a store, or a deployed app) → tell the user plainly what's there\n and **ask them to choose**, regardless of how old it is (don't guess from timestamps):\n\n- **Resume** — pick up at the computed stage. (If a `.lock` is present in this case, also confirm no\n other window is actively building this domain right now.)\n- **Start over / redo a step** — only safe **before anything is provisioned**. If the site is already\n provisioned or live, you **cannot** re-create it: that is forward-only *iterate* (change content or\n design and redeploy), not a fresh build. Never re-provision or overwrite a live site; if the user\n insists on a clean slate for a live domain, explain the limitation and point them at hPanel.\n- **Switch domain** — they want a different domain. A new domain is a **new workspace**\n (`~/.hostinger/<other-domain>/`, with its own `src/`). Hand off to start that domain's run.\n- **Buy a domain** — they want a custom domain instead of the temporary one. Route to the domain\n purchase / `connect-domain` flow, then continue with the chosen domain.\n\nWhen the intent is ambiguous, **ask** — don't guess.\n\n---\n\n## Step 6 — Record and route\n\nWrite `~/.hostinger/<domain>/state.json` with the agreed next `stage` and a fresh timestamp. If the\nroute isn't a new build, record it with `run-log.mjs <domain> run-update --kind resume` (or\n`redesign` / `iterate`), then end the stage with `stage-end check_state ok --note \"<the route>\"`.\nThen:\n\n- **Handing straight to the next stage in this same session?** Keep the `.lock` (you are still\n actively running) and continue.\n- **Stopping and returning control to the user?** (e.g. the next stage isn't built yet, or you are\n waiting on them.) **Delete the `.lock`** — the session is no longer active, and a leftover lock just\n forces an unnecessary \"is another window running?\" question on the next run. Always release the lock\n on a clean stop; it exists only to mark an actively-running session. A stop is still the end of a\n run: give the final report and save the build report (`09-done`).\n\n---\n\n## Invariants\n\n- **One directory per domain.** The domain is the key; state files never repeat it.\n- **Never create a second site** on a domain that already has one.\n- **Never re-provision or overwrite a live site.** Recovery is forward-only.\n- **Reality beats local state**, always.\n- **When ambiguous, ask.**\n"
140
+ "text": "---\nname: headless-step-check-state\ndescription: \"First build step of the Hostinger Headless flow, run right after connect. Works out where a given domain's build stands — is a run already in progress, does the site already exist, how far did it get — by reconciling the global workspace state under ~/.hostinger/<domain>/ with what the Hostinger connector reports, then guides the user to continue, switch or buy a domain, or start over. Owns all resume/position logic for the flow.\"\n---\n\n# headless-step-check-state\n\nThe first build step of the Hostinger Headless flow (orchestrated by `hostinger.com/headless/skill.md`),\nrun right after connect. Connect has already connected the Hostinger connector and confirmed the\naccount can build. Your job here is to\n**figure out where this domain stands and agree the route with the user** before any new work begins.\nThis step owns all resume/position logic — no other stage second-guesses it.\n\nNothing in this step creates, provisions, deploys, or deletes anything. It reads and guides.\n\n---\n\n## Where state lives\n\nEverything for a domain lives under the global site workspace **`~/.hostinger/<domain>/`** — one\ndirectory per domain, under the user's home (`~` = `$HOME`), **not** the current working directory.\nThe directory name **is** the domain, so `state.json` inside does not store it. Because it's global,\nthis domain's build is resumable from any directory.\n\n`state.json` shape (`stage` is the *next* stage to run; `provision` is the background task's status,\npresent only once provision has fired):\n\n```json\n{ \"tool\": \"claude-code\", \"stage\": \"content_model\",\n \"provision\": { \"status\": \"in_progress\", \"domain\": \"<domain>\", \"started_at\": \"…\" },\n \"updated_at\": \"…\" }\n```\n\n`provision.status` is `in_progress` or `completed` — but it is a **hint**: always re-verify it against\n`hosting_websites_list-setups` (Step 1) before trusting it to gate an instance-barrier step.\n\n---\n\n## Step 0 — Start the run log\n\nBefore anything else, start a run and the stage (see *Run log and build report* in the top-level\n`SKILL.md`):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version>\nnode <skill>/run-log.mjs <domain> stage-start check_state\n```\n\nIf an earlier run on this domain never wrote its report, `run-start` closes it as `abandoned` first.\n\nThen send any build reports that haven't reached Hostinger yet (that abandoned run, or a send that\nfailed last time):\n\n```bash\nnode <skill>/run-log.mjs <domain> unsent\n```\n\nEach line is `<run> <path>`. For each, send the file through `hosting_headless_submit-build-report`\nand record the result, as described in *Run log and build report* in the top-level `SKILL.md`. One\ntry each. Don't ask first; if any went out, say so in one line (e.g. \"Sent 1 earlier build report to\nHostinger.\"). Don't let a failure hold up this step.\nCheck-state runs once per session. A follow-up after the final report, in the same session, doesn't\ncome back here: it calls `run-resume` (see *Run log and build report* in the top-level `SKILL.md`).\n\n## Step 1 — Is a build actively running right now? (check this before anything else)\n\nKeep two things separate, and **never guess from a timestamp.**\n\n- **A platform build actively in progress** — the only *reliable* \"running now\" signal. Check\n `hosting_websites_list-setups { \"domain\": \"<domain>\" }` and exact-match the domain row:\n - `status: running` → provisioning is underway **right now** (a background provision from an earlier\n turn). Don't start a second one — reconcile `state.json`'s `provision` to `in_progress`, and\n either resume prepare work while it finishes or poll it to `completed` at the barrier.\n - `status: failed` → not running; route to provision to self-heal (Step 3).\n - `completed` → provision is done; set `provision.status` to `completed`. Instance-barrier steps\n (content push, frontend data, deploy) are now unblocked.\n - no row → not provisioned yet; carry on.\n\n This check **is** the source of truth for `provision.status`: a stored `in_progress`/`completed` in\n `state.json` is only a hint until this reconciles it.\n\n- **A `.lock`** — `~/.hostinger/<domain>/.lock`. It only tells you *a previous run\n touched this domain*. **Do not judge it by age** — a 2-minute-old lock and a 3-year-old lock get the\n same treatment, and a lock on its own is **not** a reason to interrupt the user. Carry it into Step 5:\n if there's nothing to resume (new build), silently take it over; only if there's real state to resume\n do you also ask *is another Claude window building this domain right now?* (yes → stop; no → take\n over).\n\nOnce you're actively working this domain, **acquire/refresh the lock** (write the current timestamp),\nand **release it on a clean stop** (Step 6). The lock marks an active session, nothing more.\n\n---\n\n## Step 2 — Read the workspace directory\n\nCheck `~/.hostinger/<domain>/` by its exact path (for example `test -d ~/.hostinger/<domain>`), not\nby listing `~/.hostinger/`:\n\n- **Absent** → no history for this domain in the workspace.\n- **Present** → read `state.json` (its `stage`) and whatever artifacts exist (`brief.md`,\n `site.json`, …). Treat these as a hint, not the truth.\n\n---\n\n## Step 3 — Verify against reality (reality wins)\n\nQuery the Hostinger connector — the local files are never trusted on their own:\n\n- **Does the site exist?** `hosting_websites_list { \"domain\": \"<domain>\" }`. ⚠️ This filter is a\n **case-insensitive substring match**, not exact — so `test.com` would also return\n `shop.test.com`. **Compare each returned row's `domain` to your target for an *exact* match**\n (case-insensitive) and ignore the rest. No exact row ⇒ the domain is free. An exact row ⇒ the site\n exists; note its `website_type` (`headless`, `wordpress`, `nodejs`, …) and `username`.\n- **How far did it get?** If the site exists, check its setup via\n `hosting_websites_list-setups { \"domain\": \"<domain>\" }` (exact-match the row the same way):\n - `status: completed` → provisioned; then, depending on backend, check whether a build has succeeded.\n - `status: running` → still provisioning (see Step 1).\n - `status: failed` → provisioning stopped before finishing → route to **provision** to self-heal.\n- **Is it an empty headless shell from onboarding?** A `headless` site whose setup is `completed` is\n **not necessarily finished** — onboarding now creates the website *before* the build runs, as an\n **empty headless shell** (`hosting_websites_start-setup { type: \"headless_empty\" }`): `website_type`\n is `headless` but **no backend is installed** (no WordPress at `/cms`, no store). Tell a shell from a\n real backend the same way provision does: within 24 h the setup `type` is `headless_empty`; otherwise\n `wordpress_installations_list { username, domain, ownership: \"all\" }` returns **no** `/cms` row and\n there is no `site.json` recording a backend. An empty shell means **provision has not chosen or\n installed a backend yet** — this is a **new build** that happens to already own its website, not a\n finished or resumable one. Route it to **refine** (choose the backend), then **provision** adopts the\n shell and swaps it to that backend (see the provision router's *Onboarding handoff*). Never create a\n second site, and never skip provision for it.\n\nReconcile with the local state. **When they disagree, reality wins** and the local `stage` is\ncorrected.\n\n---\n\n## Step 4 — Determine the position\n\nConnect always writes `state.json` (with `stage: check_state`) before this step runs, so local\nstate is never truly empty. **\"Local state: none\" here means only connect artifacts exist** —\n`connect.json` + a `state.json` still pointing at `check_state`/`refine`, and **no build artifacts\nyet** (`brief.md`, `site.json`, …).\n\n| Local state | Reality (domain) | Position |\n|---|---|---|\n| none (connect only) | free | **new build** — start from refine |\n| none (connect only) | empty headless shell (onboarding) | **new build on an existing shell** — start from refine; provision adopts the shell and swaps it to the chosen backend. Never create a second site, never skip provision |\n| none (connect only) | site exists with a real backend | **adopt / resume** — never create a second site; route to the first unfinished stage |\n| build artifacts present, incomplete | matches | **resume** at `state.json`'s `stage` |\n| build artifacts present | disagrees | **reality wins** — recompute the stage from what actually exists |\n| build artifacts present, all done | live site exists | **finished** — this is now an *iterate* request, not a build |\n\n---\n\n## Step 5 — Guide the user\n\nThe question to ask depends on whether there is actually **something to resume** — i.e. **build\nartifacts** (`brief.md`, `site.json`, …) or a **live site** for this domain. A stray `.lock` or\nconnect-only files are *not* something to resume.\n\n- **New build** (no build artifacts and either the domain is free **or** it holds only an empty\n headless shell from onboarding) → just proceed to refine. **Don't ask resume vs start over** —\n there's nothing to resume; both would land in the same place. An empty headless shell is not\n \"something to resume\": it has no backend and no content yet, so it's a fresh build that already owns\n its website — provision will adopt and swap it. Mention it in one line (\"Using the headless site\n onboarding already created for this domain.\"). If a leftover `.lock` is lying around, silently take\n it over and continue (optionally mention it in one line).\n- **Something to resume** (build artifacts exist, or a site exists for the domain **with a real backend\n installed** — WordPress at `/cms`, a store, or a deployed app) → tell the user plainly what's there\n and **ask them to choose**, regardless of how old it is (don't guess from timestamps):\n\n- **Resume** — pick up at the computed stage. (If a `.lock` is present in this case, also confirm no\n other window is actively building this domain right now.)\n- **Start over / redo a step** — only safe **before anything is provisioned**. If the site is already\n provisioned or live, you **cannot** re-create it: that is forward-only *iterate* (change content or\n design and redeploy), not a fresh build. Never re-provision or overwrite a live site; if the user\n insists on a clean slate for a live domain, explain the limitation and point them at hPanel.\n- **Switch domain** — they want a different domain. A new domain is a **new workspace**\n (`~/.hostinger/<other-domain>/`, with its own `src/`). Hand off to start that domain's run.\n- **Buy a domain** — they want a custom domain instead of the temporary one. Route to the domain\n purchase / `connect-domain` flow, then continue with the chosen domain.\n\nWhen the intent is ambiguous, **ask** — don't guess.\n\n---\n\n## Step 6 — Record and route\n\nWrite `~/.hostinger/<domain>/state.json` with the agreed next `stage` and a fresh timestamp. If the\nroute isn't a new build, record it with `run-log.mjs <domain> run-update --kind resume` (or\n`redesign` / `iterate`), then end the stage with `stage-end check_state ok --note \"<the route>\"`.\nThen:\n\n- **Handing straight to the next stage in this same session?** Keep the `.lock` (you are still\n actively running) and continue.\n- **Stopping and returning control to the user?** (e.g. the next stage isn't built yet, or you are\n waiting on them.) **Delete the `.lock`** — the session is no longer active, and a leftover lock just\n forces an unnecessary \"is another window running?\" question on the next run. Always release the lock\n on a clean stop; it exists only to mark an actively-running session. A stop is still the end of a\n run: give the final report and save the build report (`09-done`).\n\n---\n\n## Invariants\n\n- **One directory per domain.** The domain is the key; state files never repeat it.\n- **Only this domain's workspace.** Never list `~/.hostinger/` or open another site's workspace (see\n *State and artifacts* in the top-level `SKILL.md`).\n- **Never create a second site** on a domain that already has one.\n- **Never re-provision or overwrite a live site.** Recovery is forward-only.\n- **Reality beats local state**, always.\n- **When ambiguous, ask.**\n"
141
141
  },
142
142
  {
143
143
  "uri": "skill://hostinger-headless/steps/02-refine/SKILL.md",
@@ -155,24 +155,24 @@ export default [
155
155
  },
156
156
  {
157
157
  "uri": "skill://hostinger-headless/steps/03-provision/ecommerce/SKILL.md",
158
- "digest": "sha256:92079eacad771a4dfb828a187b8d0fa4eb9d914ce232b903d05090ad3519618c",
159
- "size": 8642,
158
+ "digest": "sha256:7a7275785e5cb375a6551e52d9c912ee3b7e3af07279044caa73c81d872a51da",
159
+ "size": 8714,
160
160
  "mimeType": "text/markdown",
161
- "text": "---\nname: headless-step-provision-ecommerce\ndescription: \"Provision a Hostinger Ecommerce backend for the Hostinger Headless flow: ensure the hostinger-ecommerce MCP is connected, create the headless website (frontend host) and a Hostinger Ecommerce STORE with a custom (headless) sales channel, set shipping + payment, and record it all in site.json.\"\n---\n\n# headless-step-provision-ecommerce\n\nProvision a **Hostinger Ecommerce** backend for `<domain>` and prove it is ready. Two distinct things:\n\n1. A **headless website** (`type: \"headless_ecommerce\"`) — this is only the **hosting slot for the\n storefront you'll build**. It installs **no store engine** (no `/cms`, no API on the domain).\n2. A **Hostinger Ecommerce store** — a separate object created through the **Ecommerce API**, with a\n **custom (headless) sales channel**. The storefront reads this store's **public Storefront Core V2\n API**, keyed on the sales channel. This is the real backend.\n\n**Out:** `site.json` (store id, custom sales channel id, URLs).\n\n> ## ⚠️ Prerequisite: the `hostinger-ecommerce` MCP server must be connected\n> Store management is **not** in the `hosting`/`wordpress` MCP groups — it's the separate\n> **`hostinger-ecommerce`** group (`ecommerce_*` ops, OAuth-backed, no API token needed). If those\n> tools aren't available, add the server and **reconnect the session** before continuing:\n> ```bash\n> claude mcp add-json hostinger-ecommerce '{\"command\":\"npx\",\"args\":[\"--package=@hostinger/mcp@latest\",\"hostinger-ecommerce-mcp\"],\"env\":{\"USER_AGENT\":\"extension;claude-code;via-cursor;1.4.2\"}}' --scope user\n> ```\n> (For other tools, enable the **Ecommerce** MCP in the Hostinger Connector.) A newly added MCP server\n> only loads on a session restart/reconnect.\n>\n> **Can't restart your own session? Don't block — drive the server directly.** A coding agent usually\n> can't reconnect itself, so instead of stopping, run the stdio server yourself and speak MCP to it:\n> spawn `npx --package=@hostinger/mcp@latest hostinger-ecommerce-mcp` (same `USER_AGENT` as above) and\n> send newline-delimited JSON-RPC over its stdin/stdout — `initialize` → the `notifications/initialized`\n> notification → `tools/list` → `tools/call`, where the tools are `search` / `execute` / `multi-execute`\n> (call ops exactly as in-session: `{ \"name\": \"execute\", \"arguments\": { \"operation\": \"…\", \"params\": {…} } }`).\n> It self-authenticates with the same stored Hostinger OAuth creds as the other connected servers, so\n> there is **no token to handle** (keep any secret off the command line). Your sandbox may ask the user\n> to approve spawning it — ask once, then proceed. Only fall back to asking the user to restart if\n> driving the server directly isn't permitted.\n>\n> Verify the tools work (in-session or via the direct drive) with a search — e.g. \"list stores\" →\n> `ecommerce_stores_list` — before proceeding.\n> The authoritative, always-current build guide is `ecommerce_miscellaneous_custom-storefront-setup-instructions` — read it once per build.\n\nAll website-lifecycle calls go through the `hosting` connector (`execute`); all store calls through the\n`hostinger-ecommerce` MCP.\n\n## Step 1 — The headless website (frontend host)\n\nSame lifecycle as `../wordpress/SKILL.md` Steps 1–2, but `type: \"headless_ecommerce\"`:\n\n1. Detect: `hosting_websites_list` / `hosting_websites_list-setups` (exact-match the domain). `completed`\n → adopt. `running` → poll. `failed` → stop, point at hPanel. If `website_type` is not `headless`\n (plain WordPress, Node.js, …), stop and tell the user — don't overwrite another kind of site.\n2. Create (only if **no** site exists): `hosting_websites_start-setup { order_id, type: \"headless_ecommerce\", domain }`\n — **omit `domain`** for a temporary subdomain: the setup generates one itself and returns it (don't\n call a separate subdomain op first — there's no need in this flow; the API would also accept one you\n generated via `hosting_domains_generate-free-subdomain`, valid 24 h from the client that made it). The\n response carries the generated domain; if it differs from the workspace dir, move `~/.hostinger/<old>/` → `<new>/` and\n update `brief.md`. Poll `list-setups` to `completed`. `website_type` will be `headless`.\n\n> **Onboarding handoff — an empty headless shell needs no new website.** If the domain already has an\n> empty headless shell (`website_type: headless`, setup `type: headless_empty`, nothing installed —\n> typically created in onboarding), **adopt it as the storefront host and skip the create.** A\n> `headless_ecommerce` website installs no store engine either, so an empty headless shell *is* the host\n> you need — there is no backend to \"swap\" on the website side. Record its `username`/`order_id` and go\n> straight to Step 2 (store + custom sales channel). Never call `start-setup` on a domain that already\n> has a site.\n\n> Don't expect `/cms` or any API on this domain — it serves the storefront you build (Step 6) and\n> deploy (Step 8).\n\n## Step 2 — Resolve or create the store + custom sales channel\n\n`ecommerce_stores_list` → reuse an existing store if this build already made one; otherwise create:\n\n```json\n{ \"operation\": \"ecommerce_stores_create\",\n \"params\": { \"name\": \"<store name>\", \"country_code\": \"US\", \"company_email\": \"<email>\",\n \"company_name\": \"<store name>\", \"language\": \"en\",\n \"sales_channel\": { \"type\": \"custom\", \"external_id\": \"<slug>-headless\" } } }\n```\n\nThis returns `store.id` (`store_*`) **and** a custom `sales_channel.id` (`scha_*`) in one call — the\ncustom channel is what the headless storefront is keyed on. (You can also add one later with\n`ecommerce_sales-channels_create { store_id, type:\"custom\", name }`.) Currency comes from\n`country_code` (`default_currency_code`); all prices are **minor units** (cents).\n\n## Step 3 — Store readiness: shipping + payment\n\nA store needs ≥1 shipping zone and ≥1 payment method to check out.\n\n- **Shipping:** `ecommerce_shipping_set-store { store_id, price }` (minor units; 0 = free).\n- **Payment:** `ecommerce_payments_enable-manual-method { store_id, title }`.\n\n> **Known gate — free accounts have no payment method, and therefore no checkout.** On a **free\n> account** (and any account/store without a paid Hostinger Ecommerce plan), `enable-manual-method`\n> returns **HTTP 402**, and checkout later fails with `400 payment_provider_not_configured`. This is\n> **expected, not an error** — payment, and therefore checkout, is simply unavailable on the plan. The\n> catalog and the storefront still work fully. **Never block, stop, loop or retry the build on this:**\n> - Record `payment_enabled: false` in `site.json` (set `shipping_enabled` as usual).\n> - Tell the user in one line that checkout needs a paid Ecommerce plan, and carry straight on.\n> - Treat the 402 as a normal outcome — don't surface it as a failure or re-attempt the call.\n> - Never ask the user for payment credentials.\n>\n> Every downstream stage reads `site.json → ecommerce.payment_enabled` and, when it is `false`, skips\n> all checkout wiring and verification (see content-model Step 5, frontend, deploy). A store with\n> `payment_enabled: false` is a **complete, valid build** — a browsable catalog without checkout.\n\n## Step 4 — Verify\n\n- `ecommerce_stores_metadata { store_id }` → `has_shipping` true; `has_payment_methods` (may be false,\n see the gate); read `default_currency` (`decimal_digits`, `template`) here, don't guess.\n- Public Storefront V2 answers for the channel (no auth):\n `GET https://api-ecommerce.hostinger.com/v2/channels/<scha_*>/products?limit=100` → 200 (empty until\n content-model seeds products — fine here).\n- `GET https://<domain>/` returns the Hostinger default page until the storefront is deployed — expected.\n\n## Step 5 — Record `site.json`\n\n```json\n{\n \"backend\": \"ecommerce\",\n \"website_type\": \"headless\",\n \"order_id\": 1007223428,\n \"username\": \"u…\",\n \"ecommerce\": {\n \"store_id\": \"store_…\",\n \"sales_channel\": { \"id\": \"scha_…\", \"type\": \"custom\", \"external_id\": \"…-headless\" },\n \"currency\": \"usd\",\n \"payment_enabled\": false,\n \"shipping_enabled\": true\n },\n \"urls\": {\n \"site\": \"https://<domain>\",\n \"storefront_api\": \"https://api-ecommerce.hostinger.com/v2/channels/<scha_*>\",\n \"store_admin\": \"https://ecommerce.hostinger.com/store/<store_*>\"\n },\n \"provisioned_at\": \"…\",\n \"verified_at\": \"…\"\n}\n```\n\nNo secrets in `site.json`. After deploy, link the channel to the live domain with\n`ecommerce_sales-channels_update { store_id, sales_channel_id, url }` (done in the deploy stage).\n"
161
+ "text": "---\nname: headless-step-provision-ecommerce\ndescription: \"Provision a Hostinger Ecommerce backend for the Hostinger Headless flow: ensure the hostinger-ecommerce MCP is connected, create the headless website (frontend host) and a Hostinger Ecommerce STORE with a custom (headless) sales channel, set shipping + payment, and record it all in site.json.\"\n---\n\n# headless-step-provision-ecommerce\n\nProvision a **Hostinger Ecommerce** backend for `<domain>` and prove it is ready. Two distinct things:\n\n1. A **headless website** (`type: \"headless_ecommerce\"`) — this is only the **hosting slot for the\n storefront you'll build**. It installs **no store engine** (no `/cms`, no API on the domain).\n2. A **Hostinger Ecommerce store** — a separate object created through the **Ecommerce API**, with a\n **custom (headless) sales channel**. The storefront reads this store's **public Storefront Core V2\n API**, keyed on the sales channel. This is the real backend.\n\n**Out:** `site.json` (store id, custom sales channel id, URLs).\n\n> ## ⚠️ Prerequisite: the `hostinger-ecommerce` MCP server must be connected\n> Store management is **not** in the `hosting`/`wordpress` MCP groups — it's the separate\n> **`hostinger-ecommerce`** group (`ecommerce_*` ops, OAuth-backed, no API token needed). If those\n> tools aren't available, add the server and **reconnect the session** before continuing:\n> ```bash\n> claude mcp add-json hostinger-ecommerce '{\"command\":\"npx\",\"args\":[\"--package=@hostinger/mcp@latest\",\"hostinger-ecommerce-mcp\"],\"env\":{\"USER_AGENT\":\"extension;claude-code;via-cursor;1.4.2\"}}' --scope user\n> ```\n> (For other tools, enable the **Ecommerce** MCP in the Hostinger Connector.) A newly added MCP server\n> only loads on a session restart/reconnect.\n>\n> **Can't restart your own session? Don't block — drive the server directly.** A coding agent usually\n> can't reconnect itself, so instead of stopping, run the stdio server yourself and speak MCP to it:\n> spawn `npx --package=@hostinger/mcp@latest hostinger-ecommerce-mcp` (same `USER_AGENT` as above) and\n> send newline-delimited JSON-RPC over its stdin/stdout — `initialize` → the `notifications/initialized`\n> notification → `tools/list` → `tools/call`, where the tools are `search` / `execute` / `multi-execute`\n> (call ops exactly as in-session: `{ \"name\": \"execute\", \"arguments\": { \"operation\": \"…\", \"params\": {…} } }`).\n> It self-authenticates with the same stored Hostinger OAuth creds as the other connected servers, so\n> there is **no token to handle** (keep any secret off the command line). Your sandbox may ask the user\n> to approve spawning it — ask once, then proceed. Only fall back to asking the user to restart if\n> driving the server directly isn't permitted.\n>\n> Verify the tools work (in-session or via the direct drive) with a search — e.g. \"list stores\" →\n> `ecommerce_stores_list` — before proceeding.\n> The authoritative, always-current build guide is `ecommerce_miscellaneous_custom-storefront-setup-instructions` — read it once per build.\n\nAll website-lifecycle calls go through the `hosting` connector (`execute`); all store calls through the\n`hostinger-ecommerce` MCP.\n\n## Step 1 — The headless website (frontend host)\n\nSame lifecycle as `../wordpress/SKILL.md` Steps 1–2, but `type: \"headless_ecommerce\"`:\n\n1. Detect: `hosting_websites_list` / `hosting_websites_list-setups` (exact-match the domain). `completed`\n → adopt. `running` → poll. `failed` → stop, point at hPanel. If `website_type` is not `headless`\n (plain WordPress, Node.js, …), stop and tell the user — don't overwrite another kind of site.\n2. Create (only if **no** site exists): `hosting_websites_start-setup { order_id, type: \"headless_ecommerce\", domain }`\n — **omit `domain`** for a temporary subdomain: the setup generates one itself and returns it (don't\n call a separate subdomain op first — there's no need in this flow; the API would also accept one you\n generated via `hosting_domains_generate-free-subdomain`, valid 24 h from the client that made it). The\n response carries the generated domain; if it differs from the workspace dir, move\n `~/.hostinger/<old>/` → `<new>/` (if `<new>/` already exists, stop and ask instead of moving\n into it) and update `brief.md`. Poll `list-setups` to `completed`. `website_type` will be `headless`.\n\n> **Onboarding handoff — an empty headless shell needs no new website.** If the domain already has an\n> empty headless shell (`website_type: headless`, setup `type: headless_empty`, nothing installed —\n> typically created in onboarding), **adopt it as the storefront host and skip the create.** A\n> `headless_ecommerce` website installs no store engine either, so an empty headless shell *is* the host\n> you need — there is no backend to \"swap\" on the website side. Record its `username`/`order_id` and go\n> straight to Step 2 (store + custom sales channel). Never call `start-setup` on a domain that already\n> has a site.\n\n> Don't expect `/cms` or any API on this domain — it serves the storefront you build (Step 6) and\n> deploy (Step 8).\n\n## Step 2 — Resolve or create the store + custom sales channel\n\n`ecommerce_stores_list` → reuse an existing store if this build already made one; otherwise create:\n\n```json\n{ \"operation\": \"ecommerce_stores_create\",\n \"params\": { \"name\": \"<store name>\", \"country_code\": \"US\", \"company_email\": \"<email>\",\n \"company_name\": \"<store name>\", \"language\": \"en\",\n \"sales_channel\": { \"type\": \"custom\", \"external_id\": \"<slug>-headless\" } } }\n```\n\nThis returns `store.id` (`store_*`) **and** a custom `sales_channel.id` (`scha_*`) in one call — the\ncustom channel is what the headless storefront is keyed on. (You can also add one later with\n`ecommerce_sales-channels_create { store_id, type:\"custom\", name }`.) Currency comes from\n`country_code` (`default_currency_code`); all prices are **minor units** (cents).\n\n## Step 3 — Store readiness: shipping + payment\n\nA store needs ≥1 shipping zone and ≥1 payment method to check out.\n\n- **Shipping:** `ecommerce_shipping_set-store { store_id, price }` (minor units; 0 = free).\n- **Payment:** `ecommerce_payments_enable-manual-method { store_id, title }`.\n\n> **Known gate — free accounts have no payment method, and therefore no checkout.** On a **free\n> account** (and any account/store without a paid Hostinger Ecommerce plan), `enable-manual-method`\n> returns **HTTP 402**, and checkout later fails with `400 payment_provider_not_configured`. This is\n> **expected, not an error** — payment, and therefore checkout, is simply unavailable on the plan. The\n> catalog and the storefront still work fully. **Never block, stop, loop or retry the build on this:**\n> - Record `payment_enabled: false` in `site.json` (set `shipping_enabled` as usual).\n> - Tell the user in one line that checkout needs a paid Ecommerce plan, and carry straight on.\n> - Treat the 402 as a normal outcome — don't surface it as a failure or re-attempt the call.\n> - Never ask the user for payment credentials.\n>\n> Every downstream stage reads `site.json → ecommerce.payment_enabled` and, when it is `false`, skips\n> all checkout wiring and verification (see content-model Step 5, frontend, deploy). A store with\n> `payment_enabled: false` is a **complete, valid build** — a browsable catalog without checkout.\n\n## Step 4 — Verify\n\n- `ecommerce_stores_metadata { store_id }` → `has_shipping` true; `has_payment_methods` (may be false,\n see the gate); read `default_currency` (`decimal_digits`, `template`) here, don't guess.\n- Public Storefront V2 answers for the channel (no auth):\n `GET https://api-ecommerce.hostinger.com/v2/channels/<scha_*>/products?limit=100` → 200 (empty until\n content-model seeds products — fine here).\n- `GET https://<domain>/` returns the Hostinger default page until the storefront is deployed — expected.\n\n## Step 5 — Record `site.json`\n\n```json\n{\n \"backend\": \"ecommerce\",\n \"website_type\": \"headless\",\n \"order_id\": 1007223428,\n \"username\": \"u…\",\n \"ecommerce\": {\n \"store_id\": \"store_…\",\n \"sales_channel\": { \"id\": \"scha_…\", \"type\": \"custom\", \"external_id\": \"…-headless\" },\n \"currency\": \"usd\",\n \"payment_enabled\": false,\n \"shipping_enabled\": true\n },\n \"urls\": {\n \"site\": \"https://<domain>\",\n \"storefront_api\": \"https://api-ecommerce.hostinger.com/v2/channels/<scha_*>\",\n \"store_admin\": \"https://ecommerce.hostinger.com/store/<store_*>\"\n },\n \"provisioned_at\": \"…\",\n \"verified_at\": \"…\"\n}\n```\n\nNo secrets in `site.json`. After deploy, link the channel to the live domain with\n`ecommerce_sales-channels_update { store_id, sales_channel_id, url }` (done in the deploy stage).\n"
162
162
  },
163
163
  {
164
164
  "uri": "skill://hostinger-headless/steps/03-provision/webapp/SKILL.md",
165
- "digest": "sha256:f93a16580c8aaa4b8122bf38450cbc763abf9e506065b90de91b619b6edc8a2d",
166
- "size": 9262,
165
+ "digest": "sha256:9b7aa3f3643b20c8a270ab755fa8ce380692faab479d00296d12c06d12a8f65a",
166
+ "size": 9334,
167
167
  "mimeType": "text/markdown",
168
- "text": "---\nname: headless-step-provision-webapp\ndescription: \"Provision a plain Node.js app host for the Hostinger Headless flow: create an empty website (no WordPress, no store engine, no /cms), optionally a MySQL database, then record it in site.json. For any webapp / SPA / app that brings its own code and data.\"\n---\n\n# headless-step-provision-webapp\n\nProvision a bare **Node.js app host** for `<domain>` and prove it is ready. This is the backend for\n**any webapp, SPA or app** that brings its own code (and, if it needs one, its own database) — there is\nno CMS and no store engine here. The site is an **empty website**: just the hosting slot that the\nfrontend step builds into and the deploy step ships to as a Node.js application. **Out:** `site.json`.\n\n> **Not WordPress, not Ecommerce.** Never provision `headless_wordpress` / `headless_ecommerce` for a\n> webapp, and never install WordPress at `/cms`. A webapp owns its own routes, data and rendering.\n\nAll website/database operations below go through the Hostinger connector (`execute`).\n\n> **Fire vs join (background provision).** Steps 1–2 up to `hosting_websites_start-setup` are the\n> **fire** half: once the setup is queued, record `provision.in_progress` and return to do prepare work\n> (see the router `../SKILL.md`). The readiness **poll** at the end of Step 2, plus Steps 3–5, are the\n> **join** half — run them when an instance-barrier step needs the backend (or immediately, if the\n> agent can't interleave). An already-`completed` site detected in Step 1 means provision is done:\n> 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. `hosting_websites_list-setups { \"domain\": \"<domain>\" }` — exact-match the row:\n - `completed` → already provisioned → skip to Step 3 (adopt it).\n - `running` → skip to Step 2 (poll).\n - `failed` → tell the user and point them at hPanel; do **not** call start-setup again on your own.\n3. If the site exists but is a different kind (`website_type` is `headless`, or a WordPress/store site),\n stop and tell the user — this domain already hosts something else.\n\n## Step 2 — Create (only when nothing 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 as an **empty website** — **omit `type`** (no WordPress, no store):\n\n ```json\n { \"operation\": \"hosting_websites_start-setup\",\n \"params\": { \"order_id\": <order_id>, \"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 and returns it in the response. Don't call a\n separate subdomain op to make one first: there's no need in this flow. (The API would also accept a\n subdomain you generated via `hosting_domains_generate-free-subdomain`, valid for 24 h from the client\n that made it — this flow just doesn't use that.) The response then carries the generated domain: if it\n 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.\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 3 — Database (only if the app needs one)\n\nSkip this step entirely for an app with no database (static SPA, an app that talks to an external API).\nThis is **join-half** work — it needs the live website, so run it once `provision.status == \"completed\"`.\n\n**Preferred — let the platform create the database and inject the credentials:**\n`hosting_databases_setup-website { username, domain }` creates a MySQL database, **generates the\npassword, writes `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DATABASE_URL` into the\nwebsite's environment variables, and restarts the app — in one call.** The password is never returned,\nso there is **nothing to persist and nothing to lose across sessions** — the app reads it from the\nenvironment. (It 422s if any of those env vars already exist; clear them first with\n`hosting_nodejs_replace-environment-variables`. Frameworks that bake vars into the build — Next.js,\n`NEXT_PUBLIC_*` — only pick up the values on the deploy build.)\n\n> **⚠️ A later `hosting_nodejs_replace-environment-variables` wipes these credentials.** That call is a\n> **full replace**, and when you list the current vars first the `DB_PASSWORD` (and `DATABASE_URL`) read\n> back **masked** (`********`) — the generated password is unknown, so you cannot resend it and the DB\n> env is lost. One rule, pick one:\n> - **(a) Set every other app env var first, and call `setup-website` last** — then nothing has to\n> replace the env again.\n> - **(b) If a later replace is unavoidable**, reset the password with\n> `hosting_databases_change-password { username, name, password }` and resend **all** of `DB_HOST`,\n> `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` and `DATABASE_URL` with the new password in that same\n> full replace.\n\n**Manual alternative** — only when you need a specific name/user or finer control:\n\n1. `hosting_databases_create` with a name, a user, and a password you generate — name and user are\n prefixed with the account username automatically. Read the full, prefixed `name`/`user` back from\n `hosting_databases_list`.\n2. **Which host the app connects to:** the app runs **on the website**, so Node.js connects to\n **`127.0.0.1:3306`** — never `localhost` (it can resolve to IPv6 `::1`, and not every database user\n has that grant), and never the external `host` from `hosting_databases_list` (`srvNNNN.hstgr.io`,\n which has no grant for connections from the server itself).\n3. **Set the env vars now, while you still hold the generated password.** The password is **not persisted\n anywhere** (never in `site.json`), so set `DB_HOST=127.0.0.1`, `DB_PORT=3306`, `DB_NAME`, `DB_USER`,\n `DB_PASSWORD`, plus `DATABASE_URL=mysql://USER:PASSWORD@127.0.0.1:3306/NAME` (URL-encode USER/PASSWORD)\n right here with `hosting_nodejs_replace-environment-variables` (a **full replace** — list current keys\n first; values come back masked; send the complete set with real values). Saving restarts the process;\n build-time vars need a fresh deploy (`hosting_deploy`) afterwards. **Don't defer this to deploy:** on\n a **cross-session resume** the generated password is gone, so you must **reset it** with\n `hosting_databases_change-password { username, name, password }` and set the env vars from the new one.\n4. **Never** put the password in source, a committed `.env`, client-side JavaScript, or a chat reply;\n never print it once stored.\n\n## Step 4 — Verify\n\n| Check | How | Expect |\n|---|---|---|\n| Website exists | `hosting_websites_list` exact row | the row is present, `username` known |\n| Setup finished | `hosting_websites_list-setups` exact row | `status: completed` |\n| Database reachable *(only if created)* | `hosting_databases_list { username }` | the prefixed db + user are listed |\n\n`GET https://<domain>/` returns the Hostinger default page (or 403) until the frontend is deployed —\nexpected, not a failure. There is **no `/cms` and no API** on this domain; the app itself serves every\nroute once it is deployed (Step 8).\n\nIf a check fails: retry once after ~30 s; if it still fails, stop and point the user at hPanel.\n\n## Step 5 — Record `site.json`\n\n```json\n{\n \"backend\": \"webapp\",\n \"website_type\": \"empty\",\n \"order_id\": 1007223428,\n \"username\": \"u…\",\n \"runtime\": \"nodejs\",\n \"database\": {\n \"enabled\": false,\n \"engine\": \"mysql\",\n \"name\": \"u…_appdb\",\n \"user\": \"u…_app\",\n \"host\": \"127.0.0.1\",\n \"port\": 3306\n },\n \"urls\": {\n \"site\": \"https://<domain>\"\n },\n \"provisioned_at\": \"…\",\n \"verified_at\": \"…\"\n}\n```\n\nNo passwords or tokens in `site.json` — only the non-secret connection shape (host/port/name/user). Set\n`\"database\": { \"enabled\": false }` (and drop the rest) when the app needs no database.\n\n## Deploy & content model (what happens next)\n\n- **Content model is skipped for a webapp** — there is no CMS or store to seed; the app owns its own\n data. The content-model router (`../../04-content-model/SKILL.md`) passes straight through to the\n frontend step for this backend.\n- **Deploy** is a standard Node.js application deploy, handled by `../../08-deploy/SKILL.md`: it archives\n the **source** (not the build output; exclude `node_modules/`, `.next/`, `dist/`, build output and\n `.git/`), then deploys with `hosting_deploy` (`type: \"nodejs\"`, upload + server-side `npm install` +\n build, one shot) passing the framework's `appType` + `outputDirectory` inline, and polls\n `hosting_get-deployment` until the build completes. Any supported `appType` works (Next.js, Nuxt,\n Express, NestJS, Fastify, Vite/React/Vue SPA, …). There is no `/cms` to preserve here, so the whole\n root is the app.\n"
168
+ "text": "---\nname: headless-step-provision-webapp\ndescription: \"Provision a plain Node.js app host for the Hostinger Headless flow: create an empty website (no WordPress, no store engine, no /cms), optionally a MySQL database, then record it in site.json. For any webapp / SPA / app that brings its own code and data.\"\n---\n\n# headless-step-provision-webapp\n\nProvision a bare **Node.js app host** for `<domain>` and prove it is ready. This is the backend for\n**any webapp, SPA or app** that brings its own code (and, if it needs one, its own database) — there is\nno CMS and no store engine here. The site is an **empty website**: just the hosting slot that the\nfrontend step builds into and the deploy step ships to as a Node.js application. **Out:** `site.json`.\n\n> **Not WordPress, not Ecommerce.** Never provision `headless_wordpress` / `headless_ecommerce` for a\n> webapp, and never install WordPress at `/cms`. A webapp owns its own routes, data and rendering.\n\nAll website/database operations below go through the Hostinger connector (`execute`).\n\n> **Fire vs join (background provision).** Steps 1–2 up to `hosting_websites_start-setup` are the\n> **fire** half: once the setup is queued, record `provision.in_progress` and return to do prepare work\n> (see the router `../SKILL.md`). The readiness **poll** at the end of Step 2, plus Steps 3–5, are the\n> **join** half — run them when an instance-barrier step needs the backend (or immediately, if the\n> agent can't interleave). An already-`completed` site detected in Step 1 means provision is done:\n> 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. `hosting_websites_list-setups { \"domain\": \"<domain>\" }` — exact-match the row:\n - `completed` → already provisioned → skip to Step 3 (adopt it).\n - `running` → skip to Step 2 (poll).\n - `failed` → tell the user and point them at hPanel; do **not** call start-setup again on your own.\n3. If the site exists but is a different kind (`website_type` is `headless`, or a WordPress/store site),\n stop and tell the user — this domain already hosts something else.\n\n## Step 2 — Create (only when nothing 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 as an **empty website** — **omit `type`** (no WordPress, no store):\n\n ```json\n { \"operation\": \"hosting_websites_start-setup\",\n \"params\": { \"order_id\": <order_id>, \"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 and returns it in the response. Don't call a\n separate subdomain op to make one first: there's no need in this flow. (The API would also accept a\n subdomain you generated via `hosting_domains_generate-free-subdomain`, valid for 24 h from the client\n that made it — this flow just doesn't use that.) The response then carries the generated domain: if it\n differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`\n (if `<new>/` already exists, stop and ask instead of moving into it),\n update the domain in `brief.md`, and tell the user the new domain.\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 3 — Database (only if the app needs one)\n\nSkip this step entirely for an app with no database (static SPA, an app that talks to an external API).\nThis is **join-half** work — it needs the live website, so run it once `provision.status == \"completed\"`.\n\n**Preferred — let the platform create the database and inject the credentials:**\n`hosting_databases_setup-website { username, domain }` creates a MySQL database, **generates the\npassword, writes `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DATABASE_URL` into the\nwebsite's environment variables, and restarts the app — in one call.** The password is never returned,\nso there is **nothing to persist and nothing to lose across sessions** — the app reads it from the\nenvironment. (It 422s if any of those env vars already exist; clear them first with\n`hosting_nodejs_replace-environment-variables`. Frameworks that bake vars into the build — Next.js,\n`NEXT_PUBLIC_*` — only pick up the values on the deploy build.)\n\n> **⚠️ A later `hosting_nodejs_replace-environment-variables` wipes these credentials.** That call is a\n> **full replace**, and when you list the current vars first the `DB_PASSWORD` (and `DATABASE_URL`) read\n> back **masked** (`********`) — the generated password is unknown, so you cannot resend it and the DB\n> env is lost. One rule, pick one:\n> - **(a) Set every other app env var first, and call `setup-website` last** — then nothing has to\n> replace the env again.\n> - **(b) If a later replace is unavoidable**, reset the password with\n> `hosting_databases_change-password { username, name, password }` and resend **all** of `DB_HOST`,\n> `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` and `DATABASE_URL` with the new password in that same\n> full replace.\n\n**Manual alternative** — only when you need a specific name/user or finer control:\n\n1. `hosting_databases_create` with a name, a user, and a password you generate — name and user are\n prefixed with the account username automatically. Read the full, prefixed `name`/`user` back from\n `hosting_databases_list`.\n2. **Which host the app connects to:** the app runs **on the website**, so Node.js connects to\n **`127.0.0.1:3306`** — never `localhost` (it can resolve to IPv6 `::1`, and not every database user\n has that grant), and never the external `host` from `hosting_databases_list` (`srvNNNN.hstgr.io`,\n which has no grant for connections from the server itself).\n3. **Set the env vars now, while you still hold the generated password.** The password is **not persisted\n anywhere** (never in `site.json`), so set `DB_HOST=127.0.0.1`, `DB_PORT=3306`, `DB_NAME`, `DB_USER`,\n `DB_PASSWORD`, plus `DATABASE_URL=mysql://USER:PASSWORD@127.0.0.1:3306/NAME` (URL-encode USER/PASSWORD)\n right here with `hosting_nodejs_replace-environment-variables` (a **full replace** — list current keys\n first; values come back masked; send the complete set with real values). Saving restarts the process;\n build-time vars need a fresh deploy (`hosting_deploy`) afterwards. **Don't defer this to deploy:** on\n a **cross-session resume** the generated password is gone, so you must **reset it** with\n `hosting_databases_change-password { username, name, password }` and set the env vars from the new one.\n4. **Never** put the password in source, a committed `.env`, client-side JavaScript, or a chat reply;\n never print it once stored.\n\n## Step 4 — Verify\n\n| Check | How | Expect |\n|---|---|---|\n| Website exists | `hosting_websites_list` exact row | the row is present, `username` known |\n| Setup finished | `hosting_websites_list-setups` exact row | `status: completed` |\n| Database reachable *(only if created)* | `hosting_databases_list { username }` | the prefixed db + user are listed |\n\n`GET https://<domain>/` returns the Hostinger default page (or 403) until the frontend is deployed —\nexpected, not a failure. There is **no `/cms` and no API** on this domain; the app itself serves every\nroute once it is deployed (Step 8).\n\nIf a check fails: retry once after ~30 s; if it still fails, stop and point the user at hPanel.\n\n## Step 5 — Record `site.json`\n\n```json\n{\n \"backend\": \"webapp\",\n \"website_type\": \"empty\",\n \"order_id\": 1007223428,\n \"username\": \"u…\",\n \"runtime\": \"nodejs\",\n \"database\": {\n \"enabled\": false,\n \"engine\": \"mysql\",\n \"name\": \"u…_appdb\",\n \"user\": \"u…_app\",\n \"host\": \"127.0.0.1\",\n \"port\": 3306\n },\n \"urls\": {\n \"site\": \"https://<domain>\"\n },\n \"provisioned_at\": \"…\",\n \"verified_at\": \"…\"\n}\n```\n\nNo passwords or tokens in `site.json` — only the non-secret connection shape (host/port/name/user). Set\n`\"database\": { \"enabled\": false }` (and drop the rest) when the app needs no database.\n\n## Deploy & content model (what happens next)\n\n- **Content model is skipped for a webapp** — there is no CMS or store to seed; the app owns its own\n data. The content-model router (`../../04-content-model/SKILL.md`) passes straight through to the\n frontend step for this backend.\n- **Deploy** is a standard Node.js application deploy, handled by `../../08-deploy/SKILL.md`: it archives\n the **source** (not the build output; exclude `node_modules/`, `.next/`, `dist/`, build output and\n `.git/`), then deploys with `hosting_deploy` (`type: \"nodejs\"`, upload + server-side `npm install` +\n build, one shot) passing the framework's `appType` + `outputDirectory` inline, and polls\n `hosting_get-deployment` until the build completes. Any supported `appType` works (Next.js, Nuxt,\n Express, NestJS, Fastify, Vite/React/Vue SPA, …). There is no `/cms` to preserve here, so the whole\n root is the app.\n"
169
169
  },
170
170
  {
171
171
  "uri": "skill://hostinger-headless/steps/03-provision/wordpress/SKILL.md",
172
- "digest": "sha256:4bfd1496aaf6b3507cd7cd5120b0c50372a0bf336afc9a6f445325335aa4cbcf",
173
- "size": 20310,
172
+ "digest": "sha256:0d0eb5038b5030b5371425fbbe647e6e257d88e3ce5fde120e1e3deed4b6b67c",
173
+ "size": 20382,
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 and returns it in the response. Don't call a\n separate subdomain op to make one first: there's no need in this flow. (The API would also accept a\n subdomain you generated via `hosting_domains_generate-free-subdomain`, valid for 24 h from the client\n that made it — this flow just doesn't use that.) 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"
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 and returns it in the response. Don't call a\n separate subdomain op to make one first: there's no need in this flow. (The API would also accept a\n subdomain you generated via `hosting_domains_generate-free-subdomain`, valid for 24 h from the client\n that made it — this flow just doesn't use that.) The response then carries the generated domain:\n if it differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`\n (if `<new>/` already exists, stop and ask instead of moving into it),\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",