# Publish Wordpress

> 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.

- Skill: `busyeugene/publish-wordpress` (Agent Skill)
- Install (CLI): `npx skillmds@latest add busyeugene/publish-wordpress`
- Raw SKILL.md: https://api.skillmd.com/api/skills/busyeugene/publish-wordpress/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- License: MIT
- Author: busyeugene (https://skillmd.com/u/busyeugene)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/busyeugene/publish-wordpress

---


# Publish → WordPress

Publish 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.

## 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

1. **Draft path** — markdown file under `posts/`.
2. **Post status** — `draft`, `pending`, `private`, `publish`. Default `draft`.
3. **Update existing?** — `skip`, `update`, `create-new`. Default `update` if a post with the same slug exists.
4. **Categories** — comma-separated names or IDs. Will be created if they don't exist.
5. **Tags** — comma-separated; same behavior as categories.
6. **Featured image** — from the draft's `hero_image` frontmatter, or user override path/URL.
7. **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

1. `GET /wp/v2/posts/{id}` returns 200 with the expected title, slug, and body.
2. Featured image field is populated if `hero_image` was set.
3. Category and tag IDs match the requested names.
4. If published, the public URL returns 200.
5. `publish-log.md` has a new row.

