hostinger-api-mcp 3.28.6 → 3.30.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 (56) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +9 -40
  3. package/gemini-extension.json +1 -1
  4. package/package.json +1 -1
  5. package/server.json +2 -2
  6. package/skills/headless/SKILL.md +1 -0
  7. package/skills/headless/VERSION +2 -2
  8. package/skills/headless/hostinger-mcp.mjs +88 -0
  9. package/skills/headless/push-content.mjs +530 -0
  10. package/skills/headless/steps/04-content-model/SKILL.md +9 -9
  11. package/skills/headless/steps/04-content-model/wordpress/SKILL.md +83 -252
  12. package/skills/headless/steps/04-content-model/wordpress/by-hand.md +31 -0
  13. package/skills/headless/steps/04-content-model/wordpress/custom-plugins.md +2 -2
  14. package/skills/headless/steps/04-content-model/wordpress/woocommerce.md +21 -49
  15. package/skills/headless/steps/05-design/SKILL.md +1 -1
  16. package/skills/headless/wait.mjs +15 -95
  17. package/skills/headless/wp-mcp.mjs +125 -0
  18. package/skills/maintain-wordpress/SKILL.md +1 -1
  19. package/skills/troubleshoot-website/SKILL.md +1 -1
  20. package/src/core/runtime.js +96 -0
  21. package/src/core/runtime.ts +96 -0
  22. package/src/core/skills.js +55 -27
  23. package/src/core/skills.ts +55 -27
  24. package/src/core/tools/all.js +78 -4
  25. package/src/core/tools/all.ts +78 -4
  26. package/src/core/tools/hosting.js +78 -4
  27. package/src/core/tools/hosting.ts +78 -4
  28. package/src/servers/agency-hosting.js +1 -1
  29. package/src/servers/agency-hosting.ts +1 -1
  30. package/src/servers/agent-readiness.js +1 -1
  31. package/src/servers/agent-readiness.ts +1 -1
  32. package/src/servers/all.js +1 -1
  33. package/src/servers/all.ts +1 -1
  34. package/src/servers/billing.js +1 -1
  35. package/src/servers/billing.ts +1 -1
  36. package/src/servers/dns.js +1 -1
  37. package/src/servers/dns.ts +1 -1
  38. package/src/servers/domains.js +1 -1
  39. package/src/servers/domains.ts +1 -1
  40. package/src/servers/ecommerce.js +1 -1
  41. package/src/servers/ecommerce.ts +1 -1
  42. package/src/servers/gpu.js +1 -1
  43. package/src/servers/gpu.ts +1 -1
  44. package/src/servers/horizons.js +1 -1
  45. package/src/servers/horizons.ts +1 -1
  46. package/src/servers/hosting.js +1 -1
  47. package/src/servers/hosting.ts +1 -1
  48. package/src/servers/mail.js +1 -1
  49. package/src/servers/mail.ts +1 -1
  50. package/src/servers/reach.js +1 -1
  51. package/src/servers/reach.ts +1 -1
  52. package/src/servers/vps.js +1 -1
  53. package/src/servers/vps.ts +1 -1
  54. package/src/servers/wordpress.js +1 -1
  55. package/src/servers/wordpress.ts +1 -1
  56. package/types.d.ts +29 -0
@@ -1,275 +1,106 @@
1
1
  ---
2
2
  name: headless-step-content-model-wordpress
3
- description: "Push phase of Step 4 for a headless WordPress backend: open an authenticated session to the per-site WP MCP with the installation JWT, then create the content helper's Title/Description, categories, media, pages, posts and navigation through the MCP ability tools. wp/v2 REST is the public read surface, not a write path for this token. Idempotent, instance-gated."
3
+ description: "Push phase of Step 4 for a headless WordPress backend: write the drafted content into one content.json and run push-content.mjs, which creates the site title, categories, tags, images, pages, posts, menu, Yoast fields and the WooCommerce catalog through the per-site WP MCP and records the created ids. By-hand recipe only when the script can't run. Idempotent, instance-gated."
4
4
  ---
5
5
 
6
6
  # headless-step-content-model-wordpress
7
7
 
8
- **Push phase — needs the live instance.** Run only after `provision.status == "completed"`. It maps the backend-agnostic `content-model.md` onto real
9
- WordPress objects.
8
+ **Push phase — needs the live instance.** Run only after `provision.status == "completed"`.
10
9
 
11
- **In:** `content-model.md`, `site.json` (both prepared earlier), generated media under
12
- `~/.hostinger/<domain>/media/`. **Out:** content created in WordPress; created IDs recorded back into
13
- `content-model.md`.
14
-
15
- ## What creates what — and what authenticates it
16
-
17
- The Hostinger **management** MCP (`wordpress_*`) is infra-only: it installs WordPress, plugins and
18
- themes but has **no content endpoints**.
10
+ **In:** `content-model.md`, `site.json`, the drafts from the prepare phase. **Out:** the content in
11
+ WordPress, and the created-id map in `content-model.md`.
19
12
 
20
13
  Writes go through the **per-site WP MCP** (the Hostinger AI Assistant plugin), authenticated with the
21
- installation JWT. **The JWT authenticates the MCP endpoint only** — it is wired as the MCP server's
22
- `authenticate_request`, and the plugin registers no global `determine_current_user` /
23
- `rest_authentication_errors` filter. So a raw `POST /wp/v2/posts` with this token is **not**
24
- authenticated; the admin user context (`wp_set_current_user`) exists only *inside* an MCP session.
25
-
26
- | Surface | Base (from `site.json`) | Role |
27
- |---|---|---|
28
- | **Per-site WP MCP** | `urls.wp_mcp` (`mcp_url`) | **all writes** — create content through its ability tools |
29
- | **WP REST API** `wp/v2` | `urls.rest_api` | **public reads only** (no auth needed) — what the Step 6 frontend consumes; do not write to it with the JWT |
14
+ installation JWT. The management MCP (`wordpress_*`) has no content endpoints, and `wp/v2` REST is the
15
+ public read surface the frontend uses, not a write path for that token. `push-content.mjs` handles all
16
+ of this: you write one file and run one command.
30
17
 
31
- > **The WordPress content model is posts, pages and categories** (plus media). For **SEO**, **Yoast**
32
- > is already installed + activated at provision (every headless WordPress site — see provision Step 4),
33
- > so meta + schema come from a maintained plugin and the frontend reads each item's `yoast_head_json`.
34
- > This push only sets per-item Yoast meta (Step 4.7 below).
18
+ Also on this step, when the brief calls for them:
35
19
 
36
- > **A store on this site? See [`woocommerce.md`](woocommerce.md).** When the brief's **Suggested
37
- > backend** is `**WordPress + WooCommerce**` (`site.json → woocommerce.enabled: true`), this push also
38
- > seeds the WooCommerce catalog — products, categories and coupons — through the **same per-site WP MCP
39
- > session**, which exposes native `wc-products-*`, `wc-product-categories-*` and `wc-coupons-*` abilities
40
- > (no custom plugin, no consumer key). Do the WordPress content below as normal, then run the **WooCommerce
41
- > catalog** section before you hand off.
20
+ - **A store** (`site.json → woocommerce.enabled: true`): the catalog goes in the same content file
21
+ (`store` below). Checkout stays owner-completed; see [`woocommerce.md`](woocommerce.md) §4.
22
+ - **A contact form** (`site.json → contact_form.enabled: true`): after the push, follow
23
+ [`contact-form.md`](contact-form.md) to find the form id and deploy the spam guard.
24
+ - **Custom post types, taxonomies or a settings page:** deploy the plugin from
25
+ [`custom-plugins.md`](custom-plugins.md) before the push, and create its entries with `cpt-create` after.
42
26
 
43
- > **Needs more than posts and pages? See [`custom-plugins.md`](custom-plugins.md).** The per-site MCP can
44
- > create content *data* but can't register a **custom post type**, a **custom taxonomy**, or a **global
45
- > settings page** (footer text, phone, social links the frontend reads). When the content model calls for
46
- > those, author a small custom plugin and deploy it into the headless `/cms` with
47
- > `hosting_deploy { type: "wordpress-plugin", headless: true }`, exposing everything over REST for the
48
- > frontend. **Deploy the plugin first** (the schema — the deploy activates it), then the steps below populate
49
- > its entries through the MCP's native `cpt-create` ability (custom-taxonomy terms are seeded in the plugin — see the cookbook).
27
+ ## Step 1 — Write `content.json`
50
28
 
51
- > **A contact form on this site? See [`contact-form.md`](contact-form.md).** When the brief's
52
- > **Must-haves** include a contact form (`site.json → contact_form.enabled: true`), provision installed
53
- > **Contact Form 7**; this push resolves the form's numeric id + field names (in the **same session**)
54
- > and records them into `site.json` so the Step 6 frontend can submit to CF7's public feedback endpoint.
55
- > Run the **Contact form** section after the WordPress content below.
56
-
57
- ## Step 1 — Open the authenticated MCP session
58
-
59
- Mint a short-lived token through the Hostinger connector:
29
+ Write `~/.hostinger/<domain>/content.json` in one go, from the drafts you generated in the prepare phase.
30
+ Leave out any section the site doesn't have. Every field below is what the script accepts; anything
31
+ else is refused, with the field named.
60
32
 
61
33
  ```json
62
- { "operation": "wordpress_installations_jwt-token",
63
- "params": { "username": "<site.json username>", "software": "<site.json wordpress.install_id>" } }
34
+ {
35
+ "site": { "title": "Crumb & Co", "description": "Sourdough baked daily in Lisbon" },
36
+ "categories": [ { "name": "Bread", "slug": "bread" } ],
37
+ "tags": [ { "name": "Sourdough", "slug": "sourdough" } ],
38
+ "pages": [
39
+ { "slug": "about", "title": "About us", "content": "<p>…</p>", "excerpt": "…",
40
+ "image": { "url": "https://…", "alt": "…" },
41
+ "seo": { "title": "…", "description": "…" } }
42
+ ],
43
+ "posts": [
44
+ { "slug": "why-sourdough", "title": "Why sourdough", "content": "<h2>…</h2><p>…</p>", "excerpt": "…",
45
+ "categories": ["bread"], "tags": ["sourdough"],
46
+ "image": { "url": "https://images.unsplash.com/photo-…?w=1600&q=80&fm=jpg&fit=max",
47
+ "alt": "Loaves on a wooden shelf", "title": "Loaves on a shelf",
48
+ "filename": "crumb-loaves.jpg", "caption": "Photo by <Photographer> on Unsplash" },
49
+ "seo": { "title": "…", "description": "…", "focus_keyword": "…" } }
50
+ ],
51
+ "menu": { "name": "Primary", "items": [ { "title": "About", "page": "about" }, { "title": "Journal", "url": "/journal" } ] },
52
+ "store": {
53
+ "categories": [ { "name": "Breads", "slug": "breads" } ],
54
+ "products": [ { "sku": "CR-LOAF", "name": "Country loaf", "price": "6.50", "description": "<p>…</p>",
55
+ "short_description": "…", "categories": ["breads"], "image": { "url": "https://…", "alt": "…" } } ],
56
+ "coupons": [ { "code": "WELCOME10", "discount_type": "percent", "amount": "10" } ]
57
+ }
58
+ }
64
59
  ```
65
60
 
66
- Returns `token` (+ `mcp_url`, already stored as `urls.wp_mcp`). Connect to `urls.wp_mcp` as an MCP
67
- client (`initialize` → `tools/list` → `tools/call`) and send the token on **every** request as:
68
-
69
- - `X-Hostinger-JWT-Token: <token>` — the plugin's preferred header (checked first), **or**
70
- - `Authorization: Bearer <token>` — accepted as a fallback (case-insensitive `bearer ` prefix).
71
-
72
- The token lives ~24 h — mint on demand, **never print, log, or write it to `site.json` /
73
- `content-model.md`**. If a call returns `401/403` with `token_invalid`/`invalid_api_key`, the token
74
- expired or was revoked → re-mint and reconnect.
75
-
76
- > **Transport gotchas (streamable HTTP MCP).** The endpoint is session-based: `initialize` returns an
77
- > **`Mcp-Session-Id` response header** — capture it and send it on **every** subsequent request
78
- > (`tools/list`, `tools/call`), or they fail with `-32600 Missing Mcp-Session-Id header`. Send
79
- > `Accept: application/json, text/event-stream`. After `initialize`, post the `notifications/initialized`
80
- > notification, and **tolerate a non-2xx on it** (it can answer 404 with no body — harmless; don't let
81
- > it abort the session). A raw curl/HTTP client works fine for all this.
82
-
83
- ## Step 2 — Discover the ability tools
84
-
85
- The server advertises either every ability as its own tool, or three **discovery meta-tools**
86
- (discover → get-info → execute) to keep `tools/list` small. Call `tools/list` first and work with
87
- what's there; with discovery, list abilities, then execute by ability name. The content tools that
88
- cover this step (exact names come from discovery — don't hardcode them):
89
-
90
- `SettingsTools` · `CategoriesTools` · `TagsTools` · `MediaUploadTools` / `MediaTools` · `PagesTools` ·
91
- `PostsTools` · `MenusTools` / `NavigationTools` · `SeoTools`, plus **`cpt-*`** for custom post types. There
92
- is **no** generic `wp/v2` passthrough tool — every write goes through a dedicated ability, so a `wp/v2`
93
- route with no matching ability (e.g. the terms of a *custom* taxonomy) isn't reachable from the MCP;
94
- register and seed those in a custom plugin instead (see the backend's custom-plugins cookbook).
95
-
96
- Seen on a live install (Hostinger AI Assistant, discovery mode): the meta-tools are
97
- `hostinger-ai-discover-abilities`, `hostinger-ai-get-ability-info { name }` and
98
- `hostinger-ai-execute-ability { name, parameters }`. The abilities are named
99
- `hostinger-ai/<thing>-<action>`, e.g. `posts-create`, `posts-update`, `media-upload-from-url`,
100
- `media-delete`. Call `get-ability-info` for each schema before using it. Results come back as
101
- `{ ability, result: { data, … } }` for lists and `{ ability, result: {…} }` for single objects — so a
102
- create answers **`200`** (not `201`) and the new object's `id` is **inside** `result` (`result.id`, or
103
- `result.data.id`), never at the top level. A missing top-level `id` is not a failure; read it from
104
- `result`.
105
-
106
- ## Step 3 — Idempotency rule (makes resume safe)
107
-
108
- Every create is **list/get-by-slug first, then create only if absent** — a re-run must not duplicate.
109
- Use the matching read ability (`posts-search`, `pages-search`, `categories-list`, `tags-list`,
110
- `cpt-search`, `media-search`, …): a hit → reuse its `id`; empty → create. Capture each returned `id` and record the id map back into
111
- `content-model.md` so later references resolve without re-querying.
112
-
113
- ## Step 4 — Create in dependency order
114
-
115
- > **Custom types/settings? Deploy the plugin before this list.** If the content model needs a custom post
116
- > type, taxonomy or global settings page, deploy that plugin **first** (the deploy activates it — see
117
- > [`custom-plugins.md`](custom-plugins.md)) so its REST routes exist — then the steps below populate its
118
- > entries through the MCP's native `cpt-create` ability, as they do core posts and pages (custom-taxonomy
119
- > terms are seeded in the plugin — the MCP has no taxonomy-term path; see the cookbook).
120
-
121
- 0. **Remove WordPress's default content first.** A fresh install ships a **"Hello world!" post**
122
- (id 1), a **"Sample Page"**, and a default comment — they will otherwise show up on the live site.
123
- Delete the default post (`posts-delete { id: 1, force: true }`) and, if present, the sample page
124
- (find it via `pages-search` for slug `sample-page`, then `pages-delete`). This is idempotent: if
125
- they're already gone, skip. Leave the default `Uncategorized` category — it's harmless once no post
126
- uses it (the frontend only lists categories with posts).
127
- 1. **Site Title & Description** (the helper's global part) → the Settings ability.
128
- 2. **Categories / tags** — one per term in the helper → Categories / Tags abilities. Keep `name → id`.
129
- 3. **Media.** Upload every image in the helper, set `alt_text`, and keep `filename → id`. See
130
- *Media upload* below for all sources: a Hostinger-AI image URL, an Unsplash URL, or a generated
131
- file in `media/`. For a Hostinger-AI image, generate it now (its URL is temporary — see the
132
- content-model `SKILL.md`, *Images* Option A) and upload straight away, before `expires_at`.
133
- 4. **Pages** — each static/index page → Pages ability. Pass `title`, `content` and `excerpt` as
134
- **`{ "raw": "…" }` objects** (see the note below), plus `slug`, `status: publish`. Keep `slug → id`.
135
- 5. **Posts (articles)** — each → Posts ability: `title`, `content` and `excerpt` as **`{ "raw": "…" }`**
136
- (note below), plus `slug`, `status: publish`, `categories: [id]`, `tags: [id…]`,
137
- `featured_media: <id>`, `date`. (The plugin block-converts post content on insert via
138
- `rest_pre_insert_post`, so HTML bodies in `content.raw` are fine.)
139
-
140
- > **Create payload gotcha (`pages-create` / `posts-create`).** These abilities take `title`,
141
- > `content` and `excerpt` as WordPress's core **`{ "raw": "…" }` objects**, not bare strings. Pass a
142
- > bare string and the field is silently dropped — the create still answers `200`, so you get a
143
- > *published-but-empty* page or post with nothing to key off. Always send `{ "raw": "…" }` and
144
- > read the new `id` from `result` (see Step 2). Confirm the exact field shape with
145
- > `get-ability-info` if a schema differs.
146
- 6. **Navigation** — Menus / Navigation abilities: create the menu, then an item per nav entry
147
- referencing a page `id` (or a custom URL for listing routes). **Headless note:** the Step 6
148
- frontend renders nav from `content-model.md`, so the WP menu is for owners editing in wp-admin, not
149
- what the live site reads — create it, but it isn't the source of truth.
150
- 7. **SEO** — **Yoast SEO** (`wordpress-seo`) is already installed + activated at provision (Step 4 of
151
- the WordPress provision recipe), so it generates meta + schema and the frontend reads
152
- `yoast_head_json` per post/page. Here, just set each post's Yoast title/description if an ability
153
- exposes it; otherwise the excerpt + title are a fine fallback. Don't fail the push over SEO fields.
154
- If `site.json → seo.enabled` is `false` (the owner declined Yoast, or provision couldn't confirm the
155
- install), skip this and let the frontend generate tags from content — don't try to install the
156
- plugin from the content stage.
157
-
158
- ### Media upload
159
-
160
- **From a URL (a Hostinger-AI image, Unsplash, or any direct file URL).** Use
161
- `hostinger-ai/media-upload-from-url`. WordPress downloads the file and stores its own copy, so the
162
- site never depends on the external URL staying up — which is exactly why a **Hostinger-AI** image
163
- (whose `url` from `wordpress_ai-content_generation-result` is temporary) must be uploaded here, while
164
- it is still valid; drop the Unsplash-only `caption`/`description` for AI images:
165
-
166
- ```json
167
- { "name": "hostinger-ai/media-upload-from-url",
168
- "parameters": {
169
- "url": "https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max",
170
- "filename": "<site>-<subject>.jpg",
171
- "title": "<short title>",
172
- "alt_text": "<what the photo shows>",
173
- "caption": "Photo by <Photographer> on Unsplash",
174
- "description": "Source: https://unsplash.com/photos/<id> (Unsplash License)",
175
- "post_id": <post id, if the post already exists> } }
61
+ - **Slugs** are lower-case ASCII with dashes (`about-us`). References are slugs defined in the same
62
+ file: a post's `categories` and `tags`, a menu item's `page`, a product's `categories`, and a page's
63
+ or term's `parent`, which must be listed before its child.
64
+ - `content` and `excerpt` are HTML strings. A page can also take `parent` and `menu_order`; a term can take
65
+ `description` and `parent`.
66
+ - **Images:** `url` must be a direct `https` file URL that answers 200 with an image type: an Unsplash
67
+ web-size URL, or a Hostinger-AI image `url` (it expires, so generate right before the push; see the
68
+ content-model `SKILL.md`, *Images* Option A, and leave `caption` empty). `alt` is required. `title` (it
69
+ defaults to `alt`) is how a re-run finds the upload, so keep it unique per image. The same `url` used
70
+ twice uploads once.
71
+ - **`seo`** sets the Yoast title, description and focus keyword. It's skipped when `site.json →
72
+ seo.enabled` is `false`.
73
+ - **`store`** is only for a WooCommerce brief. `price` is a decimal string in major units (`"6.50"`). A
74
+ product passes any other WooCommerce product field as given (`sale_price`, `stock_quantity`,
75
+ `attributes`, …). Variations of a variable product are by hand. `discount_type` is `percent`,
76
+ `fixed_cart` or `fixed_product`.
77
+
78
+ ## Step 2 — Run the push
79
+
80
+ ```bash
81
+ node <skill>/push-content.mjs <domain>
176
82
  ```
177
83
 
178
- - `url` must return 200 with an image content type. A photo *page* URL fails.
179
- - The result carries the attachment `id` and `source_url`. WordPress also generates the smaller
180
- sizes (`300x200`, `768x…`, `1024x…`, …) under `media_details.sizes`, which the frontend can use
181
- for `srcset`.
182
- - Set `filename` yourself, so files in `wp-content/uploads/` have readable names.
183
-
184
- **From a local file in `media/`.** Use the Media-upload ability that discovery lists for local
185
- files.
186
-
187
- **Set the featured image.** Pass `featured_media: <id>` when creating the post (Step 4.5). If the
188
- post already exists (on resume, or when images are added later), use
189
- `hostinger-ai/posts-update { id, featured_media }`.
190
-
191
- **Idempotency trap: don't look up earlier uploads by filename.** `media-search` matches titles and
192
- content, not filenames or the slug you passed. A search for `<site>-lisbon-tram` finds nothing even
193
- though the file exists, so a re-run would upload the image again. WordPress then stores the duplicate
194
- as `…-1.jpg` and silently repoints the post at it. Check this way instead:
195
-
196
- 1. `posts-get { id }`. If `featured_media` is already set, skip that post. Its image exists.
197
- 2. Otherwise, search by the **title** you set on upload (`media-search { search: "<title>" }`),
198
- and reuse a hit.
199
- 3. Only upload when both checks come up empty.
200
-
201
- If duplicates do slip in, point each post back at the original with `posts-update`, then delete the
202
- duplicates with `media-delete { id, force: true }`.
203
-
204
- ## Step 5 — Verify and record
84
+ It mints the token itself (never printed), removes WordPress's "Hello world!" post and "Sample Page",
85
+ then creates the site title, terms, images, pages, posts with their featured images, the menu, the
86
+ Yoast fields and the store, each looked up first, so a re-run reuses what exists and updates pages,
87
+ posts and products to match the file. Then it checks the result over public `wp/v2` and the Store API
88
+ (every slug and SKU there once, featured images set, no duplicate uploads), and writes the id map into
89
+ `content-model.md` under `## Created in WordPress` and `## Created in WooCommerce`. Progress goes to
90
+ stderr; stdout is one JSON line with `counts`, `verify` and `ids`.
205
91
 
206
- - Read a couple of created objects back through `wp/v2` (public GET, no auth): e.g.
207
- `GET {urls.rest_api}/posts?per_page=1` and check the `X-WP-Total` header against the planned article
208
- count; fetch one post with `_embed` to confirm `featured_media` and `categories` resolved.
209
- - Media: `GET {urls.rest_api}/media?per_page=1` → `X-WP-Total` equals the number of images planned
210
- (more means duplicates). `GET {urls.rest_api}/media?include=<ids>` confirms `alt_text` and
211
- `caption` on each image. Each `source_url` must return 200 with an image type. Each post's
212
- `featured_media` must point at its image.
213
- - Write the created-id map into `content-model.md` (a `## Created in WordPress` section: object → slug
214
- → id) so the frontend data phase and any re-run resolve references directly. Include one row per
215
- media item: filename, credit and source page, the post it's featured on, and its id.
216
-
217
- ## WooCommerce catalog *(only for a `WordPress + WooCommerce` brief — skip otherwise)*
218
-
219
- When `site.json → woocommerce.enabled` is `true`, also seed the store **in this same authenticated MCP
220
- session**, using WooCommerce abilities the Assistant exposes alongside the content ones. Full recipe (incl.
221
- the storefront Store API and the checkout gate): [`woocommerce.md`](woocommerce.md). In short:
222
-
223
- 1. **Product categories** — `wc-product-categories-list` (reuse by slug/name) else
224
- `wc-product-categories-create { name, slug }`. Keep `name → id`.
225
- 2. **Products** — **idempotent by SKU**: `wc-products-search { sku }` → reuse, else
226
- `wc-products-create { name, type:"simple", sku, regular_price:"<decimal>", description, status:"publish",
227
- categories:[{id}] }`. For images, `media-upload-from-url` then `wc-products-update { id, images:[{id}] }`.
228
- 3. **Coupons** — **idempotent by code**: `wc-coupons-search { code }` → reuse, else
229
- `wc-coupons-create { code, discount_type, amount }`.
230
- 4. **Verify over the public Store API** (no auth): `GET {urls.store_api}/products?per_page=100` count +
231
- `prices` (minor units), `GET {urls.store_api}/products/categories`. Record the SKU→id map, category ids
232
- and coupon codes into `content-model.md` under `## Created in WooCommerce`.
233
-
234
- > **Don't seed or test checkout here.** A fresh WooCommerce install has no payment gateway/shipping and
235
- > that config isn't reachable headlessly (`site.json → woocommerce.payment_enabled` is `false` — the owner
236
- > finishes it in wp-admin). Seeding + verifying the catalog over the Store API is the whole job; never call
237
- > `/checkout` or block on it. See the cookbook §4.
238
-
239
- ## Contact form (Contact Form 7) *(only when `site.json → contact_form.enabled` is true — skip otherwise)*
240
-
241
- Provision installed Contact Form 7. Here, resolve the form the frontend submits to, deploy a tiny
242
- spam-guard plugin (a headless honeypot), and record both into `site.json`. Full recipe:
243
- [`contact-form.md`](contact-form.md). In short:
244
-
245
- 1. **Find the form id (idempotent).** The per-site MCP has **no generic REST ability** and
246
- `wpcf7_contact_form` is not a public CPT, so read the id by **probing the public feedback endpoint**:
247
- a **`multipart/form-data`** `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<id>/feedback` with
248
- `_wpcf7_unit_tag=wpcf7-f<id>-o1`, for `id` in `1`‒`30`. The hit is the id whose `200` JSON reply carries
249
- `"contact_form_id": <id>` (plus a CF7 `"status"`) — the default **"Contact form 1"** (fields `your-name`,
250
- `your-email`, `your-subject`, `your-message`); `404`/`wpcf7_not_found` is a miss, and anything else
251
- (`415`, `403`, `429`, `5xx`) is inconclusive, never a hit. **The content type matters:** CF7 answers
252
- `415` to a non-multipart body *before* it looks up the id, so a bare POST would wrongly pick id 1. Reuse
253
- it on re-run. CF7's default mail already sends to `[_site_admin_email]`, so no recipient change is needed.
254
- *(Fallback if nothing answers: hand the owner the one-click wp-admin login — `wordpress_login_create-links`
255
- — to read the id from Contact → Contact Forms, the WooCommerce-checkout-gate owner-handoff pattern.)* See
256
- the cookbook for the full method.
257
- 2. **Deploy the spam guard (honeypot).** The `contact-form-7-honeypot` add-on can't work headlessly (it
258
- needs a `[honeypot]` form-tag only an owner can add), so deploy a ~15-line `wpcf7_spam` plugin to `/cms`
259
- with `hosting_deploy { type: "wordpress-plugin", slug: "cf7-headless-spamguard", headless: true }` — it
260
- flags a submission as `spam` (no mail) when a fixed decoy field (`your_website`) is filled. Cookbook §2b.
261
- 3. **Record into `site.json`** — fill the `contact_form` block provision started: `form_id`,
262
- `feedback_url` (`{urls.cms}/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`), `fields`
263
- (the form's input names, e.g. `["your-name","your-email","your-subject","your-message"]`) and
264
- `honeypot_field` (`"your_website"`, the decoy the frontend renders empty). Note the form id in
265
- `content-model.md` under `## Created in WordPress`.
92
+ | Exit | Meaning | Do |
93
+ |---|---|---|
94
+ | `0` | Everything pushed and verified | Go on to Step 3 |
95
+ | `1` with `invalid` or `error` | The call or a file is wrong; nothing was pushed | Fix what it names and re-run |
96
+ | `1` with `failed` | Those items failed; the rest is in | Fix the cause the `error` names and re-run. If the same item fails twice, create just that item with [`by-hand.md`](by-hand.md) |
97
+ | `2` | It couldn't reach the site (sign-in, the site's MCP, a missing ability); `error` says why | Push with [`by-hand.md`](by-hand.md) |
266
98
 
267
- > **Don't send a real test here.** Resolving the form id + fields is the whole job for this stage; the
268
- > throwaway `mail_sent` test belongs to **validate** (Step 7), so one dummy email reaches the owner, not
269
- > several. See the cookbook for the feedback contract and the hidden `_wpcf7*` fields the frontend needs.
99
+ A run cut short (a tool timeout, a kill) is safe to repeat: just run it again.
270
100
 
271
- ## Step 6 — Hand off
101
+ ## Step 3 — Hand off
272
102
 
273
- Update `state.json` (`provision` stays `completed`; advance `stage` toward the frontend **data**
274
- phase) with a fresh `updated_at`. The backend now holds the content; Step 6's data wiring points the
275
- frontend at `urls.rest_api` (and, for a store, `urls.store_api`).
103
+ Run the contact-form and custom-type work above if the brief has them. Then update `state.json`
104
+ (`provision` stays `completed`; advance `stage` toward the frontend **data** phase) with a fresh
105
+ `updated_at`. The frontend reads `urls.rest_api` (and, for a store, `urls.store_api`), and nav from
106
+ `content-model.md`.
@@ -0,0 +1,31 @@
1
+ # headless — WordPress content push by hand
2
+
3
+ The recipe `push-content.mjs` runs, for when it exits 2, or for one item that failed twice (create only
4
+ that item). The content is the same as in `content.json` (`SKILL.md` Step 1).
5
+
6
+ 1. **Session.** Mint the token with `wordpress_installations_jwt-token { username, software: <site.json
7
+ wordpress.install_id> }`; never print, log or store it. POST JSON-RPC to `urls.wp_mcp` with the header
8
+ `X-Hostinger-JWT-Token: <token>` and `Accept: application/json, text/event-stream`. `initialize`
9
+ answers an `Mcp-Session-Id` header: send it on every later request. Then post
10
+ `notifications/initialized`; a 404 on it is harmless. A 401/403 means the token expired: re-mint.
11
+ 2. **Abilities.** `tools/list` shows either one tool per ability or the meta-tools
12
+ `hostinger-ai-discover-abilities`, `hostinger-ai-get-ability-info { name }` and
13
+ `hostinger-ai-execute-ability { name, parameters }`. Abilities are named `hostinger-ai/<thing>-<action>`;
14
+ read each schema before use. A result is `{ ability, result }`: the new `id` is `result.id`, a list is
15
+ `result.data`, and an error is a normal result holding `{ code, message, data: { status } }`.
16
+ 3. **Look up, then create.** Search first by slug (`posts-search`, `pages-search`, `categories-list`,
17
+ `tags-list`, `menus-search` take `slug: [<slug>]`), products by `sku`, coupons by `code`, and reuse a hit.
18
+ 4. **In this order:** delete `hello-world` (`posts-search`, `posts-delete`) and `sample-page`; then
19
+ `wp-settings-update { title, description }`; categories and tags; images with
20
+ `media-upload-from-url { url, filename, title, alt_text, caption }`; `pages-create` and
21
+ `posts-create` with `slug`, `status: "publish"`, `categories`, `tags`, `featured_media`; `menus-create`,
22
+ then `menu-items-create { title: { raw }, type: "post_type", object: "page", object_id, menus }` (or
23
+ `type: "custom", url`); `seo-bulk-update { posts: [ { id, seo_title, meta_description } ] }`; then
24
+ `wc-product-categories-create`, `wc-products-create { sku, name, type: "simple", regular_price,
25
+ categories: [ { id } ], images: [ { id } ] }` and `wc-coupons-create`.
26
+ 5. **Two traps.** Pass `title`, `content` and `excerpt` as `{ "raw": "…" }` objects: a bare string is
27
+ dropped while the create still succeeds. And `media-search` matches titles, not filenames, so before
28
+ uploading again, check the post's `featured_media` (`posts-get`), then search by the title you set.
29
+ 6. **Verify and record.** `GET {urls.rest_api}/posts?per_page=100` (and `/pages`, `/media`) and
30
+ `GET {urls.store_api}/products?per_page=100` show each item once with its image. Write the id map into
31
+ `content-model.md` under `## Created in WordPress` (and `## Created in WooCommerce`).
@@ -13,7 +13,7 @@ custom post types — but there is **no generic `wp/v2` passthrough** (no `RestE
13
13
 
14
14
  | Need | Plugin? | Do it with |
15
15
  |---|---|---|
16
- | Blog posts, static pages, categories, media, nav | **no** | per-site WP MCP (`SKILL.md` Step 4) |
16
+ | Blog posts, static pages, categories, media, nav | **no** | per-site WP MCP (`push-content.mjs`, `SKILL.md`) |
17
17
  | A **custom post type** — Team, Services, Properties, Testimonials, a restaurant's dishes | **yes** | *Custom post type* below |
18
18
  | A **custom taxonomy** — Property Type, Cuisine, Service Area | **yes** | *Custom taxonomy* below |
19
19
  | **Global site content** the frontend needs — footer text, phone, address, opening hours, social links, a CTA line | **yes** | *Settings page + public settings route* below |
@@ -25,7 +25,7 @@ custom post types — but there is **no generic `wp/v2` passthrough** (no `RestE
25
25
  > headless `/cms` with `hosting_deploy { type: "wordpress-plugin", headless: true }` (see *Deploy* below).
26
26
  > Author one only for the *structure and behaviour* the per-site MCP can't express — types, taxonomies,
27
27
  > settings, helpers. Don't write a **seed/config plugin** to load WooCommerce catalog data or page content;
28
- > that goes through the per-site MCP's native abilities (`woocommerce.md`, `SKILL.md` Step 4), never a plugin.
28
+ > that goes through the per-site MCP's native abilities (`woocommerce.md`, `push-content.mjs`), never a plugin.
29
29
 
30
30
  ---
31
31
 
@@ -13,11 +13,10 @@ the cart, coupons and checkout.
13
13
 
14
14
  ## 1 — The one rule: drive the store through the per-site WP MCP
15
15
 
16
- A **custom plugin cannot be installed into a headless `/cms`** from a coding agent (same limit that
17
- removed `wp-development.md`). So there is **no seed plugin, no WP-CLI, and no REST consumer key in this
18
- flow.** Instead, the **per-site WP MCP** (the Hostinger AI Assistant — the same JWT session the WordPress
19
- content push uses, see `SKILL.md` Step 1) exposes **native WooCommerce abilities**. Seed and manage the
20
- catalog through them, exactly as posts/pages are created.
16
+ There is **no seed plugin, no WP-CLI, and no REST consumer key in this flow**: catalog data goes through
17
+ the WP MCP. Instead, the **per-site WP MCP** (the Hostinger AI Assistant — the same JWT session the WordPress
18
+ content push uses, see `SKILL.md`) exposes **native WooCommerce abilities**. The push seeds the
19
+ catalog through them (§3); call them yourself only by hand or for work after the push.
21
20
 
22
21
  Relevant abilities (names from `hostinger-ai-discover-abilities`; call `get-ability-info` for each schema
23
22
  before use — they mirror the WooCommerce REST v3 shapes):
@@ -65,42 +64,18 @@ divisor: `prices.price` (e.g. `"1999"`) with `prices.currency_minor_unit` (e.g.
65
64
 
66
65
  ## 3 — Seeding the catalog (content-model push)
67
66
 
68
- Run after provision installed + activated WooCommerce (`site.json → woocommerce.enabled: true`), inside
69
- the authenticated per-site WP MCP session (`SKILL.md` Step 1). Seed from the prepared catalog in
70
- `content-model.md`. **Idempotent by SKU** (products) and **code** (coupons) — list/search first, create
71
- only if absent, so a re-run never duplicates.
72
-
73
- 1. **Product categories** — for each category: `wc-product-categories-list` (match by `slug`/`name`,
74
- reuse the id) else `wc-product-categories-create { name, slug }`. Keep `name → id`.
75
- 2. **Products** — for each product: `wc-products-search { sku: "<sku>" }`; a hit → reuse its id; empty →
76
- `wc-products-create`:
77
- ```json
78
- { "name": "...", "type": "simple", "sku": "<unique>", "regular_price": "39.99",
79
- "description": "...", "short_description": "...", "status": "publish",
80
- "categories": [ { "id": <catId> } ] }
81
- ```
82
- - `regular_price` is a **decimal string in major units** (`"39.99"`) on the ability side — the Store
83
- API re-exposes it as minor units to the storefront.
84
- - **Variable products:** create the parent (`type: "variable"`) with its attributes, then the
85
- variations (via the product update / variations ability that discovery lists).
86
- 3. **Product images** — `media-upload-from-url { url, filename, alt_text, title }` (WordPress downloads
87
- and stores its own copy; raster, HTTPS, direct file URL — source like the parent `SKILL.md` Unsplash
88
- flow). Attach the returned media id to the product (`wc-products-update { id, images: [ { id } ] }`).
89
- Idempotency: skip when the product already has images.
90
- 4. **Coupons** — for each coupon: `wc-coupons-search { code }`; empty → `wc-coupons-create`:
91
- ```json
92
- { "code": "WELCOME10", "discount_type": "percent", "amount": "10" }
93
- ```
94
- (`discount_type`: `percent` | `fixed_cart` | `fixed_product`.)
95
-
96
- Record the SKU → id map, category ids and coupon codes into `content-model.md` under
97
- `## Created in WooCommerce` so the frontend and any re-run resolve references directly.
98
-
99
- **Verify over the public Store API (no auth):**
100
- - `GET {store_api}/products?per_page=100` → count matches the plan; each has `prices` (minor units +
101
- `currency_minor_unit`) and, where seeded, an image.
102
- - `GET {store_api}/products/categories` → every planned category present.
103
- - Coupon: `wc-coupons-search { code }` returns it, or apply it to a throwaway cart and see the total drop.
67
+ The push seeds the catalog with the rest of the content: put product categories, products and coupons
68
+ under `store` in `content.json` and run `push-content.mjs` (`SKILL.md` Steps 1–2). It looks categories up
69
+ by slug, products by **SKU** and coupons by **code** before creating, so a re-run never duplicates; uploads
70
+ each product image once; checks every SKU and category over the Store API; and records the ids under
71
+ `## Created in WooCommerce` in `content-model.md`.
72
+
73
+ - `price` is a decimal string in **major units** (`"39.99"`); the Store API re-exposes it in minor units.
74
+ - Any other WooCommerce product field passes through as given (`sale_price`, `stock_quantity`,
75
+ `attributes`, …). For a **variable product**, the push creates the parent; add its variations by hand
76
+ through the product update / variations ability that discovery lists.
77
+ - Coupons: `discount_type` is `percent`, `fixed_cart` or `fixed_product`. WooCommerce stores codes in
78
+ lower case.
104
79
 
105
80
  ---
106
81
 
@@ -112,7 +87,7 @@ only writes standard WordPress settings (it ignores `woocommerce_*` options). So
112
87
 
113
88
  - **The catalog, product pages, client-side cart and coupon application all work headlessly.**
114
89
  - **Checkout (`POST /checkout`) does not work until the owner enables a payment method + a shipping zone
115
- in wp-admin** (WooCommerce → Settings → Payments / Shipping; reach it via the one-click login in Step 9).
90
+ in wp-admin** (WooCommerce → Settings → Payments / Shipping; reach it via the one-click login in `steps/09-done/SKILL.md`).
116
91
  Cash on Delivery is the zero-config choice once enabled.
117
92
  - Treat this exactly like the Hostinger Ecommerce `payment_enabled: false` gate: record
118
93
  `woocommerce.payment_enabled: false`, tell the user in one line that checkout needs that one setup step,
@@ -144,10 +119,7 @@ through the per-site MCP and the storefront uses the public Store API. Document
144
119
 
145
120
  ## 6 — Idempotency & verification checklist
146
121
 
147
- - **Idempotency keys:** products → **SKU** (`wc-products-search { sku }`); coupons → **code**
148
- (`wc-coupons-search { code }`); categories → **slug/name**; images → skip when the product already has
149
- `images`.
150
- - **Verify over the public Store API** (no auth) — products count + prices + categories, and a coupon via
151
- a cart apply. Record the id/SKU map into `content-model.md`.
152
- - **Checkout** is verified only when `payment_enabled` is `true`; while `false`, confirming the catalog +
153
- cart over the Store API is the whole job.
122
+ - **Idempotency keys:** products → SKU, coupons → code, categories → slug, images → skip when the product
123
+ already has `images`. `push-content.mjs` applies them; follow them too when seeding by hand.
124
+ - **Checkout** is verified only when `payment_enabled` is `true`; while `false`, the catalog and cart over
125
+ the Store API are the whole job.
@@ -117,7 +117,7 @@ Before inventing anything, gather what already constrains the design:
117
117
  | Brand assets | `brief.md` → *Assets* | Logo, brand colours and fonts are **fixed** in every direction. Never recolour a supplied logo; Step 6 uses it instead of drawing an icon |
118
118
  | Pages and entities | `content-model.md` → *Navigation*, *Pages*, *Articles*; if it doesn't exist yet, `brief.md` → *Pages* | Drives the component inventory and page composition |
119
119
  | Reference sites | URLs in the brief or the conversation | Cues to borrow (see below) |
120
- | Images already sourced | `content-model.md` → the image lines under *Articles* (skip if none yet) | Glance at a few at small size; keep accents from clashing with their dominant colours |
120
+ | Images already sourced | `content-model.md` → the image lines under *Articles*, or the `image` URLs in `content.json` on WordPress (skip if none yet) | Glance at a few at small size; keep accents from clashing with their dominant colours |
121
121
  | Current design (redesign only) | existing `design.md` | What to keep, what to move away from |
122
122
 
123
123
  **Reference sites.** Open each one in a real browser if you have one. Static fetches are often