hostinger-api-mcp 3.27.4 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/gemini-extension.json +1 -1
  3. package/package.json +1 -1
  4. package/server.json +2 -2
  5. package/skills/headless/SKILL.md +49 -21
  6. package/skills/headless/VERSION +3 -3
  7. package/skills/headless/run-log.mjs +21 -1
  8. package/skills/headless/steps/01-check-state/SKILL.md +12 -13
  9. package/skills/headless/steps/03-provision/SKILL.md +11 -10
  10. package/skills/headless/steps/03-provision/ecommerce/SKILL.md +5 -2
  11. package/skills/headless/steps/03-provision/webapp/SKILL.md +3 -2
  12. package/skills/headless/steps/03-provision/wordpress/SKILL.md +6 -7
  13. package/skills/headless/steps/04-content-model/SKILL.md +39 -21
  14. package/skills/headless/steps/04-content-model/wordpress/SKILL.md +1 -2
  15. package/skills/headless/steps/07-validate/SKILL.md +3 -3
  16. package/skills/headless/steps/08-deploy/SKILL.md +27 -14
  17. package/skills/headless/steps/09-done/SKILL.md +3 -2
  18. package/skills/headless/wait.mjs +245 -0
  19. package/src/core/skills.js +43 -36
  20. package/src/core/skills.ts +43 -36
  21. package/src/servers/agency-hosting.js +1 -1
  22. package/src/servers/agency-hosting.ts +1 -1
  23. package/src/servers/agent-readiness.js +1 -1
  24. package/src/servers/agent-readiness.ts +1 -1
  25. package/src/servers/all.js +1 -1
  26. package/src/servers/all.ts +1 -1
  27. package/src/servers/billing.js +1 -1
  28. package/src/servers/billing.ts +1 -1
  29. package/src/servers/dns.js +1 -1
  30. package/src/servers/dns.ts +1 -1
  31. package/src/servers/domains.js +1 -1
  32. package/src/servers/domains.ts +1 -1
  33. package/src/servers/ecommerce.js +1 -1
  34. package/src/servers/ecommerce.ts +1 -1
  35. package/src/servers/gpu.js +1 -1
  36. package/src/servers/gpu.ts +1 -1
  37. package/src/servers/horizons.js +1 -1
  38. package/src/servers/horizons.ts +1 -1
  39. package/src/servers/hosting.js +1 -1
  40. package/src/servers/hosting.ts +1 -1
  41. package/src/servers/mail.js +1 -1
  42. package/src/servers/mail.ts +1 -1
  43. package/src/servers/reach.js +1 -1
  44. package/src/servers/reach.ts +1 -1
  45. package/src/servers/vps.js +1 -1
  46. package/src/servers/vps.ts +1 -1
  47. package/src/servers/wordpress.js +1 -1
  48. package/src/servers/wordpress.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ 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.28.0 — 2026-11-04
8
+
9
+ ### Also in this release
10
+
11
+ - Dependencies: `package-lock.json`
12
+ - Bundled agent skills: 14 files
13
+ - Other files: `src/core/skills.js`, `src/core/skills.ts`
14
+
7
15
  ## v3.27.4 — 2026-11-03
8
16
 
9
17
  ### Also in this release
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "3.27.4",
3
+ "version": "3.28.0",
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.27.4",
3
+ "version": "3.28.0",
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.27.4",
9
+ "version": "3.28.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@hostinger/mcp",
14
- "version": "3.27.4",
14
+ "version": "3.28.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -58,7 +58,7 @@ The order is sequencial run and check the whole headless flow:
58
58
  | — | **connect** — connect the Hostinger connector, auth, get active plan | `connect/SKILL.md` → `connect/tools/<tool>.md` | `connect.json` | no |
59
59
  | 1 | **check state** — where does this domain stand? resume / switch / start over | `steps/01-check-state/SKILL.md` | `state.json` | no |
60
60
  | 2 | **refine** — discover what the user wants, in conversation | `steps/02-refine/SKILL.md` | `brief.md` | no |
61
- | 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 |
61
+ | 3 | **provision** — choose the backend from the brief, then provision it | `steps/03-provision/<backend>/SKILL.md` | `site.json` | **bg** — fire, then wait at the join |
62
62
  | 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** |
63
63
  | 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 |
64
64
  | 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** |
@@ -89,7 +89,7 @@ the user rather than improvising the stage from its description.
89
89
  The **Instance?** column says whether a stage touches the live backend:
90
90
  `no` = pure prepare work that can run during the provision wait;
91
91
  `push`/`data`/`yes` = a commit phase that joins the instance barrier;
92
- `bg` = provision itself, the background task.
92
+ `bg` = provision itself: Hostinger runs the job while you do the prepare work.
93
93
 
94
94
  **Always end with the final report.** Whenever a run ends, give the user the final report defined
95
95
  in `steps/09-done/SKILL.md`: an outcome paragraph, a *Steps completed* table (one row per stage that
@@ -99,8 +99,8 @@ missing step, a failed provision, or a user pause. Every run also saves a **buil
99
99
  Waiting for the user's answer to a question is not a run ending: end your turn with the question
100
100
  alone, with no final report and no build report.
101
101
 
102
- **Log the run as you go.** Wrap every stage (and each phase of a split stage) in
103
- `run-log.mjs stage-start` / `stage-end`, and log an event the moment something goes wrong, the user
102
+ **Log the run as you go.** Wrap every stage (and each phase of a split stage) in the run log —
103
+ `run-log.mjs next <stage>` at each boundary — and log an event the moment something goes wrong, the user
104
104
  corrects you, or a skill is missing or wrong — before you continue. See *Run log and build report*.
105
105
 
106
106
  **Why this order.** connect first, so account/plan failures surface before the user spends time
@@ -178,17 +178,37 @@ which of these cases applies and records it, then provision takes the matching p
178
178
  3. **User wants a webapp.** Provision is a plain Node.js app host, not headless — `webapp` ignores
179
179
  headless shells entirely and is only reached by experimental actions, never the onboarding flow.
180
180
 
181
- Either way the create/install is asynchronous and returns `queued` immediately, then it's polled
182
- every ~15 s for a few minutes — **what you poll depends on the path**: a fresh
183
- `hosting_websites_create` is polled with `hosting_websites_list-setups` until `completed`, while a
184
- shell swap (`wordpress_installations_install`) creates no new setup, so it's polled with
185
- `wordpress_installations_list` until the `/cms` row is `is_valid`. Don't block on that poll. The
186
- single rule that makes the overlap safe:
181
+ Either way the create/install is asynchronous and returns `queued` immediately, then it takes a few
182
+ minutes — **what to wait on depends on the path**: a fresh `hosting_websites_create` is `wait.mjs setup`
183
+ (until `hosting_websites_list-setups` says `completed`), while a shell swap
184
+ (`wordpress_installations_install`) creates no new setup, so it's `wait.mjs cms` (until the `/cms` row of
185
+ `wordpress_installations_list` is valid). Run it at the join, not at the fire: by then the prepare work
186
+ has usually outlasted the job, so it returns at once. The single rule that makes the overlap safe:
187
187
 
188
- > **Fire provision as a background task, then do every stage's prepare work while it settles. The
188
+ > **Fire provision (Hostinger runs it asynchronously), then do every stage's prepare work while it settles. The
189
189
  > moment a step must write to the live instance, it waits for provision to report ready (via the
190
190
  > backend step's readiness poll — see above). That wait is the *instance barrier*.**
191
191
 
192
+ ### Waiting on Hostinger: `wait.mjs`
193
+
194
+ `wait.mjs` (next to this file) waits for a slow Hostinger job in one command, so you don't spend a turn
195
+ on every check:
196
+
197
+ ```bash
198
+ node <skill>/wait.mjs setup <domain> # a fresh site's setup → completed
199
+ node <skill>/wait.mjs cms <domain> <username> # a shell swap's WordPress install at /cms → valid
200
+ node <skill>/wait.mjs build <domain> <buildUuid> # the deploy build → completed, then the root serves 200
201
+ node <skill>/wait.mjs live <domain> # the root serves 200
202
+ ```
203
+
204
+ Run it in the foreground, with a `--timeout` just under your tool's command limit (e.g. `--timeout 9m`
205
+ when commands may run 10 minutes); on `timed_out`, run the same command again: it picks the job up
206
+ where it is. It prints one JSON line and
207
+ exits 0 with `ok: true` and what it waited for; 1 with `ok: false` when the job failed, timed out
208
+ (`timed_out`, `last_status`) or hit an error that waiting won't fix (`error`); 2 when it couldn't start
209
+ the local Hostinger MCP, with `fallback` naming the connector operation to poll yourself, every ~15 s.
210
+ Never write your own `sleep` loops.
211
+
192
212
  ---
193
213
 
194
214
  ## State and artifacts
@@ -243,14 +263,15 @@ provision runs in the background, its status lives in its own `provision` field
243
263
  }
244
264
  ```
245
265
 
246
- - `provision` is **absent** until provision fires, `{"status":"in_progress", …}` while the
247
- background poll runs, and `{"status":"completed", …}` once `hosting_websites_list-setups` confirms
248
- it. `domain` is the poll target.
266
+ - `provision` is **absent** until provision fires, `{"status":"in_progress", …}` from the fire until
267
+ the join's `wait.mjs` returns `ok: true`, then `{"status":"completed", …}`. `domain` is the wait's
268
+ target.
249
269
  - Any **instance-barrier** step (content-model push, frontend data, deploy) must read
250
- `provision.status == "completed"` — and re-verify it against the connector — before it runs. A
251
- prepare step ignores `provision` entirely.
252
- - On **resume**, `provision.status` is a hint only: re-poll `hosting_websites_list-setups` to learn
253
- the truth, then update the field.
270
+ `provision.status == "completed"` before it runs. The connector is asked twice per run: at
271
+ check-state (or the provision join that sets `completed`), and in deploy's pre-flight, because deploy
272
+ overwrites the site. Steps in between trust `state.json`. A prepare step ignores `provision` entirely.
273
+ - On **resume**, `provision.status` is a hint only: check-state re-polls `hosting_websites_list-setups`
274
+ to learn the truth, then updates the field.
254
275
  - Write `state.json` however is easiest, rewriting the whole file included: the run log lives in
255
276
  `run-log.json`, which only `run-log.mjs` writes.
256
277
  - The **onboarding slug** is how hPanel names the coding tool: `claude` for Claude Code, else
@@ -269,9 +290,10 @@ in. Write it out in full each time (a shell variable holding the command breaks
269
290
  ```bash
270
291
  node <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> # check-state, first thing
271
292
  node <skill>/run-log.mjs <domain> run-update --kind resume # check-state, once the route is agreed (or redesign / iterate)
272
- node <skill>/run-log.mjs <domain> stage-start frontend # before a stage
273
- node <skill>/run-log.mjs <domain> stage-end frontend ok_after_retries --note "second framework tried"
274
- node <skill>/run-log.mjs <domain> stage-start frontend --phase shell # split stages log each phase
293
+ node <skill>/run-log.mjs <domain> next frontend # at a stage boundary: ends the open stage as ok, starts this one
294
+ node <skill>/run-log.mjs <domain> next frontend --phase shell # split stages log each phase
295
+ node <skill>/run-log.mjs <domain> stage-end frontend ok_after_retries --note "second framework tried" # any other result, or a note
296
+ node <skill>/run-log.mjs <domain> stage-start frontend # start without ending anything
275
297
  node <skill>/run-log.mjs <domain> event issue '{"stage":"content_model","symptom":"…","cause":"…","workaround":"…","severity":"medium","resolved":true}'
276
298
  node <skill>/run-log.mjs <domain> report --outcome live # done, or wherever the run stops
277
299
  node <skill>/run-log.mjs <domain> report-sent <run> # after the report was sent (see below)
@@ -280,6 +302,12 @@ node <skill>/run-log.mjs <domain> unsent # check-state:
280
302
  node <skill>/run-log.mjs <domain> run-resume --reason "about/contact layout fix" # follow-up in the same session
281
303
  ```
282
304
 
305
+ **Don't spend a turn on the log.** Put each run-log call in the same shell command as the work next
306
+ to it, joined with `&&` (e.g. `node <skill>/run-log.mjs <domain> next deploy && cd … && zip …`), and use
307
+ `next` at a boundary rather than a `stage-end` plus a `stage-start`. When the stage ending wasn't plain
308
+ `ok` or needs a note, put its `stage-end` first in the same command. `next` refuses when two stages are
309
+ open and names them; end them with `stage-end`.
310
+
283
311
  **Follow-ups continue the run.** When the user asks for a change after the final report in the
284
312
  same session (a layout fix, a copy tweak, a redeploy), that work belongs to the run it follows:
285
313
  call `run-resume`, log the feedback and the stages it re-runs as usual, then `report` again. That
@@ -1,3 +1,3 @@
1
- version=3.27.4
2
- commit=10d66593f943
3
- built=2026-11-03
1
+ version=3.28.0
2
+ commit=0b30a7c65edb
3
+ built=2026-11-04
@@ -6,6 +6,7 @@
6
6
  // node run-log.mjs <domain> run-resume [--reason "<text>"]
7
7
  // node run-log.mjs <domain> stage-start <stage> [--phase <phase>] [--started-ago 6m | --started-at <ISO time>]
8
8
  // node run-log.mjs <domain> stage-end <stage> <result> [--phase <phase>] [--note "<text>"] [--started-ago 6m | --started-at <ISO time>]
9
+ // node run-log.mjs <domain> next <stage> [--phase <phase>]
9
10
  // node run-log.mjs <domain> stage-fix <stage> [--phase <phase>] (--started-ago 6m | --started-at <ISO time>)
10
11
  // node run-log.mjs <domain> event <issue|feedback|skill_gap|good> '<json>'
11
12
  // node run-log.mjs <domain> report [--outcome live|stopped|failed|abandoned] [--tokens '<json>']
@@ -239,6 +240,22 @@ function stageEnd(ws, [stage, result], flags) {
239
240
  console.log(`${stage}${phase ? `/${phase}` : ''} ended: ${result}`);
240
241
  }
241
242
 
243
+ // One call at a stage boundary: ends the open stage (or phase) as ok and starts <stage>, unless the open
244
+ // one is <stage> itself, so re-running a chained `next X && …` doesn't close X as ok. With nothing open
245
+ // it only starts; with more than one open it can't tell which ends, so it refuses.
246
+ function next(ws, [stage], flags) {
247
+ if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"; one of ${STAGES.join(', ')}`);
248
+ const log = loadLog(ws);
249
+ const run = currentRun(log);
250
+ const open = (log.history ?? []).filter((h) => h.run === run.id && !h.ended_at);
251
+ const label = (h) => `${h.stage}${h.phase ? ` --phase ${h.phase}` : ''}`;
252
+ if (open.length > 1) fail(`${open.map(label).join(' and ')} are open; end each with stage-end <stage> <result>, then call next`);
253
+ const phase = flags.phase ?? null;
254
+ const [current] = open;
255
+ if (current && !(current.stage === stage && current.phase === phase)) stageEnd(ws, [current.stage, 'ok'], { phase: current.phase ?? undefined });
256
+ stageStart(ws, [stage], { phase: flags.phase });
257
+ }
258
+
242
259
  function stageFix(ws, [stage], flags) {
243
260
  if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"`);
244
261
  const log = loadLog(ws);
@@ -663,6 +680,9 @@ switch (command) {
663
680
  case 'stage-end':
664
681
  stageEnd(ws, rest, flags);
665
682
  break;
683
+ case 'next':
684
+ next(ws, rest, flags);
685
+ break;
666
686
  case 'stage-fix':
667
687
  stageFix(ws, rest, flags);
668
688
  break;
@@ -696,5 +716,5 @@ switch (command) {
696
716
  reportRejected(ws, rest);
697
717
  break;
698
718
  default:
699
- fail('command must be run-start, run-update, run-resume, stage-start, stage-end, stage-fix, event, report, unsent, report-sent or report-rejected');
719
+ fail('command must be run-start, run-update, run-resume, stage-start, stage-end, next, stage-fix, event, report, unsent, report-sent or report-rejected');
700
720
  }
@@ -32,8 +32,9 @@ present only once provision has fired):
32
32
  "updated_at": "…" }
33
33
  ```
34
34
 
35
- `provision.status` is `in_progress` or `completed` — but it is a **hint**: always re-verify it against
36
- `hosting_websites_list-setups` (Step 1) before trusting it to gate an instance-barrier step.
35
+ `provision.status` is `in_progress` or `completed` — but it is a **hint** from an earlier session:
36
+ re-verify it against `hosting_websites_list-setups` here (Step 1). See *State and artifacts* in the
37
+ top-level `SKILL.md` for when the connector is asked again.
37
38
 
38
39
  ---
39
40
 
@@ -43,18 +44,14 @@ Before anything else, start a run and the stage (see *Run log and build report*
43
44
  `SKILL.md`):
44
45
 
45
46
  ```bash
46
- node <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version>
47
- node <skill>/run-log.mjs <domain> stage-start check_state
47
+ node <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> && \
48
+ node <skill>/run-log.mjs <domain> next check_state && \
49
+ node <skill>/run-log.mjs <domain> unsent
48
50
  ```
49
51
 
50
52
  If an earlier run on this domain never wrote its report, `run-start` closes it as `abandoned` first.
51
-
52
- Then send any build reports that haven't reached Hostinger yet (that abandoned run, or a send that
53
- failed last time):
54
-
55
- ```bash
56
- node <skill>/run-log.mjs <domain> unsent
57
- ```
53
+ `unsent` lists the build reports that haven't reached Hostinger yet (that abandoned run, or a send
54
+ that failed last time).
58
55
 
59
56
  Each line is `<run> <path>`. For each, send the file through `hosting_headless_submit-build-report`
60
57
  and record the result, as described in *Run log and build report* in the top-level `SKILL.md`. One
@@ -70,7 +67,8 @@ Keep two things separate, and **never guess from a timestamp.**
70
67
  `hosting_websites_list-setups { "domain": "<domain>" }` and exact-match the domain row:
71
68
  - `status: running` → provisioning is underway **right now** (a background provision from an earlier
72
69
  turn). Don't start a second one — reconcile `state.json`'s `provision` to `in_progress`, and
73
- either resume prepare work while it finishes or poll it to `completed` at the barrier.
70
+ either resume prepare work while it finishes or wait for it at the barrier with
71
+ `wait.mjs setup <domain>` (see *Waiting on Hostinger* in the top-level `SKILL.md`).
74
72
  - `status: failed` → not running; route to provision to self-heal (Step 3).
75
73
  - `completed` → provision is done; set `provision.status` to `completed`. Instance-barrier steps
76
74
  (content push, frontend data, deploy) are now unblocked.
@@ -200,7 +198,8 @@ When the intent is ambiguous, **ask** — don't guess. A skipped tool result is
200
198
 
201
199
  Write `~/.hostinger/<domain>/state.json` with the agreed next `stage` and a fresh timestamp. If the
202
200
  route isn't a new build, record it with `run-log.mjs <domain> run-update --kind resume` (or
203
- `redesign` / `iterate`), then end the stage with `stage-end check_state ok --note "<the route>"`.
201
+ `redesign` / `iterate`) and end the stage with the route as its note, in one shell command:
202
+ `… run-update --kind resume && … stage-end check_state ok --note "<the route>"`.
204
203
  Then:
205
204
 
206
205
  - **Handing straight to the next stage in this same session?** Keep the `.lock` (you are still
@@ -10,9 +10,9 @@ matching subfolder, which provisions it.
10
10
 
11
11
  **In:** `brief.md`. **Out:** `site.json` (written by the subfolder).
12
12
 
13
- **This stage runs in the background.** Provisioning is mostly a poll wait, so it splits in two: a
14
- **fire** half (detect + create, returns straight away) and a **join** half (poll to `completed`,
15
- verify, write `site.json`). Fire it, record `provision.in_progress`, then go do the prepare work of
13
+ **This stage is asynchronous.** Provisioning is mostly a wait on Hostinger, so it splits in two: a
14
+ **fire** half (detect + create, returns straight away) and a **join** half (`wait.mjs` in the
15
+ foreground, verify, write `site.json`). Fire it, record `provision.in_progress`, then go do the prepare work of
16
16
  the other stages (content-model prepare, frontend shell) while it settles. Only cross back to
17
17
  the join half when an instance-barrier step needs the live backend. See *Background provision & the
18
18
  instance barrier* in the top-level `SKILL.md`. An agent that can't interleave simply does fire and
@@ -98,7 +98,7 @@ So provision has **two entry shapes**, and each subfolder's *detect* step picks
98
98
  you not to expect an empty shell.
99
99
 
100
100
  This only changes *detect*. Everything after the swap (verify, `site.json`, the WooCommerce tail, the
101
- background fire/join split) is identical to a fresh provision. A swap that installs WordPress is still
101
+ fire/join split) is identical to a fresh provision. A swap that installs WordPress is still
102
102
  asynchronous — fire it, record `provision.in_progress`, and join at the barrier just like a fresh create.
103
103
 
104
104
  ## Step 2 — Fire (don't block)
@@ -118,8 +118,8 @@ asynchronous — fire it, record `provision.in_progress`, and join at the barrie
118
118
  > shell from an already-provisioned backend.
119
119
 
120
120
  Follow the chosen subfolder's `SKILL.md` through **detect + create** (its `hosting_websites_create` call). The
121
- moment the create is accepted and queued, record it in `state.json` and **return** — do not sit on the
122
- readiness poll:
121
+ moment the create is accepted and queued, record it in `state.json` and **return** — do not wait for
122
+ it yet:
123
123
 
124
124
  ```json
125
125
  { "stage": "content_model",
@@ -132,7 +132,8 @@ shell). The subfolder's readiness poll, verification and `site.json` are the **j
132
132
  ## Step 3 — Join (at the instance barrier)
133
133
 
134
134
  When an instance-barrier step is about to run (content-model push, frontend data wiring, deploy) — or
135
- right away, if the agent can't interleave — finish the subfolder's `SKILL.md`: **poll to `completed`**,
136
- run its verification, and write `site.json` (which records the chosen `backend`). Then set
137
- `provision.status` to `"completed"` in `state.json` with a fresh `updated_at`. On resume, treat a
138
- stored `in_progress` as a hint only — re-poll `hosting_websites_list-setups` for the truth first.
135
+ right away, if the agent can't interleave — finish the subfolder's `SKILL.md`: run its `wait.mjs` (it
136
+ returns at once if the job is already done), run its verification, and write `site.json` (which
137
+ records the chosen `backend`). Then set `provision.status` to `"completed"` in `state.json` with a
138
+ fresh `updated_at`.
139
+
@@ -48,14 +48,17 @@ All website-lifecycle calls go through the `hosting` connector (`execute`); all
48
48
  Same lifecycle as `../wordpress/SKILL.md` Steps 1–2, but `type: "headless_ecommerce"`:
49
49
 
50
50
  1. Detect: `hosting_websites_list` / `hosting_websites_list-setups` (exact-match the domain). `completed`
51
- → adopt. `running` → poll. `failed` → stop, point at hPanel. If `website_type` is not `headless`
51
+ → adopt. `running` → record `provision.in_progress` and wait at the join. `failed` → stop, point at hPanel.
52
+ If `website_type` is not `headless`
52
53
  (plain WordPress, Node.js, …), stop and tell the user — don't overwrite another kind of site.
53
54
  2. Create (only if **no** site exists): `hosting_websites_create { order_id, type: "headless_ecommerce", domain }`
54
55
  — **omit `domain`** for a temporary subdomain: the setup generates one itself and returns it (don't
55
56
  call a separate subdomain op first — there's no need in this flow). The response carries the
56
57
  generated domain; if it differs from the workspace dir, move
57
58
  `~/.hostinger/<old>/` → `<new>/` (if `<new>/` already exists, stop and ask instead of moving
58
- into it) and update `brief.md`. Poll `list-setups` to `completed`. `website_type` will be `headless`.
59
+ into it) and update `brief.md`. Don't wait here. At the join, `node <skill>/wait.mjs setup <domain>`
60
+ returns once the setup is `completed` (see *Waiting on Hostinger* in the top-level `SKILL.md`).
61
+ `website_type` will be `headless`.
59
62
 
60
63
  > **Onboarding handoff — an empty headless shell needs no new website.** If the domain already has an
61
64
  > empty headless shell (`website_type: headless`, setup `type: headless_empty`, nothing installed —
@@ -57,8 +57,9 @@ All website/database operations below go through the Hostinger connector (`execu
57
57
  `~/.hostinger/<new>/`
58
58
  (if `<new>/` already exists, stop and ask instead of moving into it),
59
59
  update the domain in `brief.md`, and tell the user the new domain.
60
- 3. Poll `hosting_websites_list-setups { "domain": "<domain>" }` every ~15 s until `completed`
61
- (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel.
60
+ 3. Don't wait here. At the join, `node <skill>/wait.mjs setup <domain>` (see *Waiting on Hostinger* in
61
+ the top-level `SKILL.md`) returns once the setup is `completed`, usually at once by then. A `failed`
62
+ setup → stop, point at hPanel.
62
63
 
63
64
  ## Step 3 — Database (only if the app needs one)
64
65
 
@@ -64,8 +64,9 @@ All operations below go through the Hostinger connector (`execute`).
64
64
  (if `<new>/` already exists, stop and ask instead of moving into it),
65
65
  update the domain in `brief.md`, and tell the user the new domain.
66
66
  Admin credentials are generated by the platform; never ask the user for a password.
67
- 3. Poll `hosting_websites_list-setups { "domain": "<domain>" }` every ~15 s until `completed`
68
- (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel.
67
+ 3. Don't wait here. At the join, `node <skill>/wait.mjs setup <domain>` (see *Waiting on Hostinger* in
68
+ the top-level `SKILL.md`) returns once the setup is `completed`, usually at once by then. A `failed`
69
+ setup → stop, point at hPanel.
69
70
 
70
71
  ## Step 2b — Swap an empty headless shell to WordPress (only when onboarding already made the shell)
71
72
 
@@ -93,11 +94,9 @@ install WordPress straight into its `/cms` directory. This is the onboarding han
93
94
  - A new database is created for the install unless you pass `database.name` of an existing one; the
94
95
  generated database counts against the plan's limit.
95
96
  3. The call is **asynchronous** — a success response only means the install job was **queued**. This is
96
- the *fire*: record `provision.in_progress` and return to prepare work. Poll
97
- `wordpress_installations_list { username, domain, ownership: "all" }` (exact row) at the join; when a
98
- row with `directory: "cms"` and `is_valid: true` appears, WordPress is ready. Allow 1–2 minutes;
99
- `wordpress_installations_list` can lag a few seconds behind the job finishing and return `[]` — retry
100
- briefly before failing.
97
+ the *fire*: record `provision.in_progress` and return to prepare work. At the join,
98
+ `node <skill>/wait.mjs cms <domain> <username>` returns the `/cms` install row (`directory: "cms"`,
99
+ valid) once it exists, usually within 1–2 minutes of the install.
101
100
 
102
101
  ## Step 3 — Verify (all must pass)
103
102
 
@@ -96,7 +96,7 @@ not an answer, see *When you ask the user* in the top-level `SKILL.md`):
96
96
  | Option | Tool | Backends | Needs the live instance? |
97
97
  |---|---|---|---|
98
98
  | **A — Hostinger AI image generation** *(default for WordPress / WooCommerce)* | `wordpress_ai-content_generate-images` | WordPress, WordPress + WooCommerce | **yes** — runs at the instance barrier |
99
- | **B — Unsplash stock photos** *(default for Ecommerce / Webapp; the fallback everywhere)* | Unsplash API, or web search + web fetch | any | no — pure prepare work |
99
+ | **B — Unsplash stock photos** *(default for Ecommerce / Webapp; the fallback everywhere)* | Unsplash API, or web fetch of Unsplash search pages | any | no — pure prepare work |
100
100
 
101
101
  > **Do not silently fall back to stock on a WordPress site.** On WordPress / WooCommerce the default is
102
102
  > **Hostinger AI (Option A)** — on-brand, generated from `design.md`. Using Unsplash on a WordPress build
@@ -165,29 +165,47 @@ runs **at the instance barrier** (after `provision.status == "completed"`), usin
165
165
  This is still prepare work and needs no live backend: you pick the photos and record their URLs here,
166
166
  and the push phase uploads them.
167
167
 
168
- 1. **Find candidates.** Use one photo per article, matching its subject and destination.
169
- - With an `UNSPLASH_ACCESS_KEY` in the environment, use the official API:
170
- `GET https://api.unsplash.com/search/photos?query=<subject>&orientation=landscape` with header
171
- `Authorization: Client-ID <key>`.
172
- - Without a key, use web search limited to `unsplash.com` (e.g. "unsplash photo Lisbon tram") and
173
- take the photo id from the result URL: the last segment of `unsplash.com/photos/<slug>-<id>`, or
174
- the whole segment for `unsplash.com/photos/<id>`.
175
- 2. **Resolve a direct file URL.** With the API, the result's `urls.raw` is the file and `user.name`
176
- the photographer; for each photo you keep, also `GET` its `links.download_location` (same header),
177
- as the API terms require. Without a key, open the photo page (`https://unsplash.com/photos/<…>`)
178
- with your **web-fetch tool** and ask it for the main photo's `images.unsplash.com/photo-<…>` URL,
179
- quoted exactly, and the photographer's name. Either way, drop the query string and request a web
180
- size: `https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max`. Check that it returns
181
- 200 with `image/jpeg`: the upload ability needs this direct file URL, not the photo page.
182
- - Every other `unsplash.com` request (curl on the page, `/photos/<id>/download`, `/napi/…`,
168
+ Source every image in a few batched calls, not a search and a fetch per photo:
169
+
170
+ 1. **Find candidates, a page of them per fetch.** Group the image briefs into a few themes (e.g.
171
+ "sourdough bread", "bakery interior", "Lisbon street").
172
+ - With an `UNSPLASH_ACCESS_KEY` in the environment, use the official API, one call per theme:
173
+ `GET https://api.unsplash.com/search/photos?query=<theme>&orientation=landscape&per_page=20` with
174
+ header `Authorization: Client-ID <key>`. Each result's `urls.raw` is the file, `alt_description`
175
+ what it shows and `user.name` the photographer; for each photo you keep, also `GET` its
176
+ `links.download_location` (same header), as the API terms require.
177
+ - Without a key, open the theme's search page `https://unsplash.com/s/photos/<theme-with-dashes>`
178
+ with your **web-fetch tool**, once per theme, and ask it to list every free photo on the page with
179
+ its `images.unsplash.com/photo-<…>` URL quoted exactly (the part before `?`), the photo page
180
+ link, the alt text and the photographer. One fetch returns 10–20 candidates. Skip the
181
+ `plus.unsplash.com/premium_photo-…` results: they are Unsplash+ and not free. Fetch the themes in
182
+ parallel when your tool allows several calls at once. Fall back to a web search limited to
183
+ `unsplash.com` plus a fetch of one photo page (`https://unsplash.com/photos/<…>`) only for a
184
+ subject no search page covers.
185
+ - Every other `unsplash.com` request (curl on any page, `/photos/<id>/download`, `/napi/…`,
183
186
  `/oembed`) hits a bot challenge: a `307` to `/.within.website` (Anubis) or a 401. If your fetch
184
187
  tool gets the challenge page too, stop: no user-agent spoofing or other workarounds. On a
185
188
  WordPress backend where Option A hasn't already failed, offer it; otherwise ask the user for an
186
189
  `UNSPLASH_ACCESS_KEY` or record the gap (step 5).
187
- 3. **Look before you keep it.** Download a small preview (`?w=500`) and view it. Search snippets and
188
- fetch summaries only describe the page, and can hand you a related photo's thumbnail instead of
189
- the main one; a photo also often isn't quite what its title says. Reject anything off-topic.
190
- If nothing fits exactly, take the closest real photo and note the gap rather than mislabel it.
190
+ 2. **Check them all in one command.** Pick a candidate per image brief from the alt text, then in one
191
+ shell command request each web-size URL
192
+ (`https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max`, query string replaced) and
193
+ confirm 200 `image/jpeg`, and save a small preview (`?w=500`) of each into one folder, e.g.:
194
+
195
+ ```bash
196
+ mkdir -p ~/.hostinger/<domain>/media/previews && cd ~/.hostinger/<domain>/media/previews && \
197
+ for id in photo-<a> photo-<b>; do
198
+ curl -s -o /dev/null -w "$id %{http_code} %{content_type}\n" "https://images.unsplash.com/$id?w=1600&q=80&fm=jpg&fit=max"
199
+ curl -s -o "$id.jpg" "https://images.unsplash.com/$id?w=500&q=60&fm=jpg&fit=max"
200
+ done
201
+ ```
202
+
203
+ The upload ability needs this direct file URL, not the photo page.
204
+ 3. **Look before you keep it.** View the previews, all in one turn where your tool can open several
205
+ images at once. Alt text from a search page describes the photo better than a search snippet, but
206
+ a photo often isn't quite what its title says. Reject anything off-topic and take the next
207
+ candidate from the same list. If nothing fits exactly, take the closest real photo and note the gap
208
+ rather than mislabel it.
191
209
  4. **Write alt text from what the photo shows**, not from the article's image brief. For example,
192
210
  use "Shopfronts and parked scooters on a Hanoi street" even if the article is about rain.
193
211
  5. **Record it** under each article in `content-model.md`:
@@ -212,7 +230,7 @@ Then update `state.json`: set `stage` to the next prepare stage still to do, fre
212
230
 
213
231
  ## Push phase — create it the backend's way (needs the instance)
214
232
 
215
- Only once `provision.status == "completed"` (re-verified against `hosting_websites_list-setups`),
233
+ Only once `provision.status == "completed"` (see *State and artifacts* in the top-level `SKILL.md`),
216
234
  route on the brief's **Suggested backend** and follow that subfolder, which maps the helper onto the
217
235
  live backend — for WordPress, through the per-site WP MCP's ability tools (the installation JWT
218
236
  authenticates that MCP session; `wp/v2` REST is the public read surface, not a write path for the
@@ -5,8 +5,7 @@ description: "Push phase of Step 4 for a headless WordPress backend: open an aut
5
5
 
6
6
  # headless-step-content-model-wordpress
7
7
 
8
- **Push phase — needs the live instance.** Run only after `provision.status == "completed"` (re-verify
9
- against `hosting_websites_list-setups`). It maps the backend-agnostic `content-model.md` onto real
8
+ **Push phase — needs the live instance.** Run only after `provision.status == "completed"`. It maps the backend-agnostic `content-model.md` onto real
10
9
  WordPress objects.
11
10
 
12
11
  **In:** `content-model.md`, `site.json` (both prepared earlier), generated media under
@@ -16,9 +16,9 @@ description: "Step 7 (validate) of the Hostinger Headless flow. OPTIONAL quick g
16
16
 
17
17
  ## 1 — Instance status (always, quick)
18
18
 
19
- `provision.status == "completed"` **and** re-verify against the connector:
20
- `hosting_websites_list-setups { "domain": "<domain>" }`, exact-match the row → `completed`. Anything
21
- else → not ready; return to the instance barrier, don't deploy.
19
+ `provision.status == "completed"` in `state.json`. Anything else → not ready; return to the instance
20
+ barrier, don't deploy. Deploy's pre-flight asks the connector again (see *State and artifacts* in the
21
+ top-level `SKILL.md`).
22
22
 
23
23
  ## 2 — Backend API status (WordPress / WooCommerce / Ecommerce, always, quick)
24
24
 
@@ -21,7 +21,7 @@ This is an **instance-barrier** step: run it only after `provision.status == "co
21
21
  > settings passed exactly as the table below says. Run it from the project, since it links the current directory:
22
22
  > `cd ~/.hostinger/<domain>/src && hostinger up --domain <domain> --type nodejs --app-type <appType> --output-directory <dir> --build-script build --node-version 22 --package-manager npm --yes --format json`.
23
23
  > It uploads, builds, waits and returns `data.buildUuid` and `data.buildState`; add `--no-wait` to
24
- > return at once and poll with `hostinger hosting nodejs build <domain> <uuid>`. Then continue at
24
+ > return at once and wait with `node <skill>/wait.mjs build <domain> <uuid>`. Then continue at
25
25
  > "Verify" below. Recent CLI versions see that the site is headless (`data.headless: true`) and
26
26
  > deploy only its Node.js frontend, leaving `/cms` alone; still pass the settings above.
27
27
 
@@ -74,8 +74,9 @@ framework's correct values** — don't rely on auto-detection (it returns null
74
74
 
75
75
  ## Step 1 — Pre-flight
76
76
 
77
- 1. **Instance live.** `provision.status == "completed"`, re-verified with
78
- `hosting_websites_list-setups { "domain": "<domain>" }`.
77
+ 1. **Instance live (the deploy gate).** `provision.status == "completed"`, re-verified with
78
+ `hosting_websites_list-setups { "domain": "<domain>" }`: deploy overwrites the site root, so it never
79
+ trusts `state.json` alone.
79
80
  2. **Build output exists locally** (the framework's output dir — e.g. `.next/`, `dist/`, `.output/`) and
80
81
  the project builds clean (frontend/validate already did this). If not, go back.
81
82
  - **Next.js 16+:** `package.json`'s `build` script is `next build --webpack`, not plain `next build` —
@@ -128,15 +129,26 @@ website root** (expected) and keeps `/cms` intact. `type` is always `"nodejs"` f
128
129
  **never `"static"`** (see the gate below). Passing `appType`/`outputDirectory` explicitly is what
129
130
  keeps the root from serving 403 (see the rule above).
130
131
 
131
- ## Step 4 — Poll to completion
132
+ ## Step 4 — Wait for the build and the live root
132
133
 
133
- Poll `hosting_get-deployment { "domain": "<domain>", "buildUuid": "<buildUuid>" }` with backoff until
134
- `build.state` is `completed`. On `failed`, the same response carries the end of the build log and the
135
- failure analysis — read it, fix locally, re-archive and redeploy (pass `fromLine`, the line count from
136
- the previous call, to read only new log output). `hosting_get-deployment` also returns the build settings
137
- stored for the site: `appType`/`outputDirectory`/`nodeVersion` should echo what you passed; if they read
138
- `null`, the settings didn't take — fix the deploy call before continuing. For an SSR app that built but
139
- hangs, `hosting_nodejs_restart-application { username, domain }` recovers the server without a rebuild.
134
+ Wait for it in one command (see *Waiting on Hostinger* in the top-level `SKILL.md`):
135
+
136
+ ```bash
137
+ node <skill>/wait.mjs build <domain> <buildUuid>
138
+ ```
139
+
140
+ It waits for `hosting_get-deployment` to report the build `completed`, then for `https://<domain>/` to
141
+ serve 200. What its result means here:
142
+
143
+ - `ok: true`: the build is done and the root serves 200. Go on to Step 5.
144
+ - `ok: false` with `build.state: failed`: read `log_tail` (the end of the build log) and `analysis`, fix
145
+ locally, re-archive and redeploy (pass `fromLine` to `hosting_get-deployment` to read only new log
146
+ output).
147
+ - `ok: false` with `build settings missing`: the settings didn't take (the root would 403 for good). Fix
148
+ the deploy call and redeploy.
149
+ - `ok: false` with `timed_out`: `last_status` says whether the build was still running or the root
150
+ wasn't serving 200 yet (see Step 5's note on a 403). For an SSR app that built but hangs,
151
+ `hosting_nodejs_restart-application { username, domain }` recovers the server without a rebuild.
140
152
 
141
153
  ## Step 5 — Verify the live site (all must pass)
142
154
 
@@ -163,9 +175,10 @@ hangs, `hosting_nodejs_restart-application { username, domain }` recovers the se
163
175
  > 200 is a passing, complete deploy. Never fail or hold the deploy because checkout is unavailable.
164
176
  > - **Webapp:** no content backend at all — the whole root is the app. Skip the check; `/cms` won't resolve.
165
177
 
166
- A `completed` build whose root 403s almost always means the build settings weren't passed to
167
- `hosting_deploy` (null settings) — pass them and redeploy. Caching can briefly serve a stale root;
168
- re-check after ~30 s.
178
+ The root check is already done when `wait.mjs build` returned `ok: true`; check the rest in one shell
179
+ command (`curl` each URL). A `completed` build whose root 403s almost always means the build settings
180
+ weren't passed to `hosting_deploy` (null settings) — pass them and redeploy. Caching can briefly serve a
181
+ stale root; re-check after ~30 s.
169
182
 
170
183
  ## Step 6 — Record `deploy.json`
171
184
 
@@ -160,8 +160,9 @@ from `run-log.json`, events from `events.jsonl`) into `reports/<run>.json` and `
160
160
  HTML to `report.html` in the workspace:
161
161
 
162
162
  ```bash
163
- node <skill>/run-log.mjs <domain> event good '{"text":"<something that went well>"}' # one per thing, if not logged yet
164
- node <skill>/run-log.mjs <domain> stage-end done ok
163
+ # one shell command; one `event good` per thing that went well, if not logged yet
164
+ node <skill>/run-log.mjs <domain> event good '{"text":"<something that went well>"}' && \
165
+ node <skill>/run-log.mjs <domain> stage-end done ok && \
165
166
  node <skill>/run-log.mjs <domain> report --outcome <live|stopped|failed> [--tokens '<json>']
166
167
  ```
167
168