Publish → WordPress
Publish a local markdown draft to WordPress using the WordPress REST API v2. Works with self-hosted WordPress and WordPress.com Business or higher.
Setup
Required:
WORDPRESS_BASE_URL — e.g. https://example.com (no trailing slash). The REST endpoint is {BASE}/wp-json/wp/v2.
WORDPRESS_USERNAME — WP user with edit_posts and upload_files capabilities.
WORDPRESS_APP_PASSWORD — an Application Password (Users → Profile → Application Passwords in WP admin). Not your login password.
Auth header for every request:
Authorization: Basic <base64(username:app_password)>
Content-Type: application/json
Optional:
SLACK_WEBHOOK_URL — Slack notification on publish.
Inputs
- Draft path — markdown file under
posts/.
- Post status —
draft, pending, private, publish. Default draft.
- Update existing? —
skip, update, create-new. Default update if a post with the same slug exists.
- Categories — comma-separated names or IDs. Will be created if they don't exist.
- Tags — comma-separated; same behavior as categories.
- Featured image — from the draft's
hero_image frontmatter, or user override path/URL.
- Field mapping override — optional JSON for custom post types or ACF fields.
Use AskUserQuestion for post status and existing-item handling.
Process
1. Preflight
GET {BASE}/wp-json/ to confirm REST is reachable and auth works. A 401 means wrong username or app password.
GET {BASE}/wp-json/wp/v2/users/me to confirm the user has the required capabilities.
- Cache the site's categories and tags for the session.
2. Load and transform
- Read the draft.
- Parse frontmatter.
- Convert markdown body to HTML. WordPress accepts HTML directly in the
content field. Preserve: headings, paragraphs, lists, code, blockquotes, images, links. Convert fenced code blocks to <pre><code class="language-{lang}">.
- Strip HTML comments and any markdown-specific artifacts (e.g. inline frontmatter).
3. Featured image upload
If hero_image is a local path:
POST {BASE}/wp-json/wp/v2/media
Content-Disposition: attachment; filename="{name}.webp"
Content-Type: image/webp (or png/jpeg)
<binary>
Capture the returned id → use it as featured_media. Set alt_text via:
POST {BASE}/wp-json/wp/v2/media/{id}
{ "alt_text": "{hero_alt}", "caption": "{optional}" }
If hero_image is a URL, download it first, then upload — don't point WP at a remote URL.
4. Resolve taxonomies
For each category:
- Look up by name in the cache. If found, use the ID.
- If not found:
POST /wp/v2/categories { "name": "{name}" }, use the returned ID.
Same for tags via /wp/v2/tags.
5. Check for existing post
GET /wp/v2/posts?slug={slug}&status=any&per_page=1
- Found +
skip → stop and report.
- Found +
update → POST /wp/v2/posts/{id} with updated fields.
- Found +
create-new → append -2, -3, … to the slug until unique.
- Not found → create.
6. Create or update
POST /wp/v2/posts
{
"title": "{title}",
"slug": "{slug}",
"status": "{status}",
"content": "{html body}",
"excerpt": "{meta_description}",
"featured_media": {media_id},
"categories": [ids],
"tags": [ids],
"meta": {
"_yoast_wpseo_title": "{meta_title}",
"_yoast_wpseo_metadesc": "{meta_description}",
"_yoast_wpseo_focuskw": "{keyword}"
}
}
Notes:
- Yoast and Rank Math meta keys are NOT writable via the WP REST API by default. The Yoast REST API is read-only, and neither plugin registers its meta fields with
show_in_rest: true. Writing them via the standard meta block will silently fail or return a 400 unless the site has either (a) register_post_meta() calls in functions.php exposing the keys, or (b) a bridge plugin such as "WP REST Yoast Meta" installed. Before posting, attempt a test write and read it back. If the write didn't take, skip the meta block, log a warning, and tell the user which fields couldn't be set so they can update them manually in WP admin.
- For Yoast, the meta keys are
_yoast_wpseo_title, _yoast_wpseo_metadesc, _yoast_wpseo_focuskw. For Rank Math, they are rank_math_title, rank_math_description, rank_math_focus_keyword. Ask which plugin is installed once per session and cache the answer.
- For ACF fields, post to
/wp/v2/posts/{id} with acf: { field_name: value } after creation (requires the "ACF to REST API" plugin or ACF Pro 5.11+).
- Date fields: set
date in ISO 8601 (site-local timezone). If publishing now, omit to use the server's current time.
7. Verify the live URL (if publishing)
- Fetch
GET /wp/v2/posts/{id} to confirm the new values took.
- If
status = publish, hit the returned link with a HEAD request and expect 200. If it's 404 or 5xx, warn the user.
8. Optional Slack notification
Same pattern as publish-webflow.
9. Publish log
Append to publish-log.md:
| {YYYY-MM-DD HH:mm} | wordpress | {status} | {title} | {post_id} | {link or "—"} | {result} |
10. Print summary
One block: post ID, status, live URL (if published), SEO plugin used, Slack notified, publish-log updated.
Fallbacks
- App password wrong or 2FA blocking: fail fast with the exact fix instruction ("Generate an Application Password under Users → Profile → Application Passwords").
- REST endpoint 404 or disabled: the site has REST API disabled by a security plugin; report the exact failing URL and tell the user to re-enable.
- Featured media upload fails: retry once; if it still fails, create the post without the featured image and log a warning.
- Yoast/Rank Math not installed: skip the SEO meta block and note it in the summary.
- Network error mid-upload: do not retry blindly — first check whether the post was created to avoid duplicates.
Safety
- Default status is
draft. Never publish live without explicit user confirmation in the current session.
- Never delete posts. On slug collision in
skip mode, stop and report.
Verification
GET /wp/v2/posts/{id} returns 200 with the expected title, slug, and body.
- Featured image field is populated if
hero_image was set.
- Category and tag IDs match the requested names.
- If published, the public URL returns 200.
publish-log.md has a new row.
1---2name: publish-wordpress3description: Use when the user wants to publish a blog post draft to WordPress via the REST API. Reads a markdown draft, maps frontmatter to post fields, uploads the hero image, creates or updates the post, and optionally publishes it live.4license: MIT5---67# Publish → WordPress89Publish a local markdown draft to WordPress using the [WordPress REST API v2](https://developer.wordpress.org/rest-api/). Works with self-hosted WordPress and WordPress.com Business or higher.1011## Setup1213Required:14- `WORDPRESS_BASE_URL` — e.g. `https://example.com` (no trailing slash). The REST endpoint is `{BASE}/wp-json/wp/v2`.15- `WORDPRESS_USERNAME` — WP user with `edit_posts` and `upload_files` capabilities.16- `WORDPRESS_APP_PASSWORD` — an Application Password (Users → Profile → Application Passwords in WP admin). Not your login password.1718Auth header for every request:19```20Authorization: Basic <base64(username:app_password)>21Content-Type: application/json22```2324Optional:25- `SLACK_WEBHOOK_URL` — Slack notification on publish.2627## Inputs28291. **Draft path** — markdown file under `posts/`.302. **Post status** — `draft`, `pending`, `private`, `publish`. Default `draft`.313. **Update existing?** — `skip`, `update`, `create-new`. Default `update` if a post with the same slug exists.324. **Categories** — comma-separated names or IDs. Will be created if they don't exist.335. **Tags** — comma-separated; same behavior as categories.346. **Featured image** — from the draft's `hero_image` frontmatter, or user override path/URL.357. **Field mapping override** — optional JSON for custom post types or ACF fields.3637Use `AskUserQuestion` for post status and existing-item handling.3839## Process4041### 1. Preflight4243- `GET {BASE}/wp-json/` to confirm REST is reachable and auth works. A 401 means wrong username or app password.44- `GET {BASE}/wp-json/wp/v2/users/me` to confirm the user has the required capabilities.45- Cache the site's categories and tags for the session.4647### 2. Load and transform4849- Read the draft.50- Parse frontmatter.51- Convert markdown body to HTML. WordPress accepts HTML directly in the `content` field. Preserve: headings, paragraphs, lists, code, blockquotes, images, links. Convert fenced code blocks to `<pre><code class="language-{lang}">`.52- Strip HTML comments and any markdown-specific artifacts (e.g. inline frontmatter).5354### 3. Featured image upload5556If `hero_image` is a local path:5758```59POST {BASE}/wp-json/wp/v2/media60Content-Disposition: attachment; filename="{name}.webp"61Content-Type: image/webp (or png/jpeg)6263<binary>64```6566Capture the returned `id` → use it as `featured_media`. Set `alt_text` via:6768```69POST {BASE}/wp-json/wp/v2/media/{id}70{ "alt_text": "{hero_alt}", "caption": "{optional}" }71```7273If `hero_image` is a URL, download it first, then upload — don't point WP at a remote URL.7475### 4. Resolve taxonomies7677For each category:78- Look up by name in the cache. If found, use the ID.79- If not found: `POST /wp/v2/categories { "name": "{name}" }`, use the returned ID.8081Same for tags via `/wp/v2/tags`.8283### 5. Check for existing post8485```86GET /wp/v2/posts?slug={slug}&status=any&per_page=187```8889- Found + `skip` → stop and report.90- Found + `update` → `POST /wp/v2/posts/{id}` with updated fields.91- Found + `create-new` → append `-2`, `-3`, … to the slug until unique.92- Not found → create.9394### 6. Create or update9596```97POST /wp/v2/posts98{99 "title": "{title}",100 "slug": "{slug}",101 "status": "{status}",102 "content": "{html body}",103 "excerpt": "{meta_description}",104 "featured_media": {media_id},105 "categories": [ids],106 "tags": [ids],107 "meta": {108 "_yoast_wpseo_title": "{meta_title}",109 "_yoast_wpseo_metadesc": "{meta_description}",110 "_yoast_wpseo_focuskw": "{keyword}"111 }112}113```114115Notes:116- **Yoast and Rank Math meta keys are NOT writable via the WP REST API by default.** The Yoast REST API is read-only, and neither plugin registers its meta fields with `show_in_rest: true`. Writing them via the standard `meta` block will silently fail or return a 400 unless the site has either (a) `register_post_meta()` calls in `functions.php` exposing the keys, or (b) a bridge plugin such as "WP REST Yoast Meta" installed. Before posting, attempt a test write and read it back. If the write didn't take, skip the meta block, log a warning, and tell the user which fields couldn't be set so they can update them manually in WP admin.117- For Yoast, the meta keys are `_yoast_wpseo_title`, `_yoast_wpseo_metadesc`, `_yoast_wpseo_focuskw`. For Rank Math, they are `rank_math_title`, `rank_math_description`, `rank_math_focus_keyword`. Ask which plugin is installed once per session and cache the answer.118- For ACF fields, post to `/wp/v2/posts/{id}` with `acf: { field_name: value }` after creation (requires the "ACF to REST API" plugin or ACF Pro 5.11+).119- Date fields: set `date` in ISO 8601 (site-local timezone). If publishing now, omit to use the server's current time.120121### 7. Verify the live URL (if publishing)122123- Fetch `GET /wp/v2/posts/{id}` to confirm the new values took.124- If `status = publish`, hit the returned `link` with a HEAD request and expect 200. If it's 404 or 5xx, warn the user.125126### 8. Optional Slack notification127128Same pattern as `publish-webflow`.129130### 9. Publish log131132Append to `publish-log.md`:133134```135| {YYYY-MM-DD HH:mm} | wordpress | {status} | {title} | {post_id} | {link or "—"} | {result} |136```137138### 10. Print summary139140One block: post ID, status, live URL (if published), SEO plugin used, Slack notified, publish-log updated.141142## Fallbacks143144- **App password wrong or 2FA blocking:** fail fast with the exact fix instruction ("Generate an Application Password under Users → Profile → Application Passwords").145- **REST endpoint 404 or disabled:** the site has REST API disabled by a security plugin; report the exact failing URL and tell the user to re-enable.146- **Featured media upload fails:** retry once; if it still fails, create the post without the featured image and log a warning.147- **Yoast/Rank Math not installed:** skip the SEO meta block and note it in the summary.148- **Network error mid-upload:** do not retry blindly — first check whether the post was created to avoid duplicates.149150## Safety151152- Default status is `draft`. Never publish live without explicit user confirmation in the current session.153- Never delete posts. On slug collision in `skip` mode, stop and report.154155## Verification1561571. `GET /wp/v2/posts/{id}` returns 200 with the expected title, slug, and body.1582. Featured image field is populated if `hero_image` was set.1593. Category and tag IDs match the requested names.1604. If published, the public URL returns 200.1615. `publish-log.md` has a new row.