WordPress Content
Manage WordPress content exclusively through the built-in REST API. Do not install or invoke WP-CLI. Require bash, curl, and Python 3; do not require local PHP.
Required Inputs
Ensure these values are available through explicit input, environment variables, or local configuration:
- HTTPS WordPress site URL
- WordPress username
- WordPress Application Password
- requested operation and content type
- exact object ID when changing existing content
- final title, content, status, date, taxonomy, media, or navigation details required by the operation
Do not guess an object ID from a title alone. Search and resolve ambiguity before writing.
Verbatim Publishing Default
Treat user-supplied body content as authoritative. Before creating or updating any body content, read references/verbatim-publishing.md completely and follow its comparison workflow.
- Do not rewrite, summarize, translate, reformat, normalize, or repair supplied content.
- Do not convert between plain text, Markdown, HTML, Classic content, and Gutenberg blocks unless explicitly requested.
- Keep title, excerpt, taxonomy, dates, and media assignments separate from the body; do not duplicate them inside
content. - Use
verbatim-createorverbatim-updatefor every body write; the genericrequestaction refuses executable JSON writes containingcontent. - Submit the user's payload without changing its requested status. If WordPress changes or sanitizes the body, report the mismatch as a warning; do not stop, unpublish, return to draft, or roll back solely because of that difference.
Verbatim source preservation does not copy the originating application's CSS or override the WordPress theme. Treat visual fidelity as a separate, explicit styling task.
Credential Resolution
Prefer the bundled wrapper for configuration and authenticated requests:
bash <skill-path>/scripts/wordpress_rest.sh status
On first use, run status. It checks local configuration and authenticates against /wp-json/wp/v2/users/me. If any credential is missing, direct the user to the Frevana WordPress Integration guide to obtain the site URL, username, and Application Password. The wrapper prints this guide automatically for incomplete configuration. Do not invent these values.
Resolve each value independently in this order:
- explicit options:
--url,--username,--app-password - environment:
WORDPRESS_URL,WORDPRESS_USERNAME,WORDPRESS_APP_PASSWORD - local config file
- interactive prompt when the process has a terminal
This allows, for example, the URL to come from the config file while the Application Password is temporarily overridden through the environment.
The default config path is:
${WORDPRESS_CONFIG_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/wordpress-content/config}
Override it with --config FILE or WORDPRESS_CONFIG_FILE. The file format is:
WORDPRESS_URL=https://example.com
WORDPRESS_USERNAME=editor
WORDPRESS_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
Matching single or double quotes around a complete value are accepted when reading the file.
Configure interactively; the Application Password prompt is hidden:
bash <skill-path>/scripts/wordpress_rest.sh configure
Or save explicit values:
bash <skill-path>/scripts/wordpress_rest.sh configure \
--url "https://example.com" \
--username "editor" \
--app-password "xxxx xxxx xxxx xxxx xxxx xxxx"
The wrapper creates the config directory with mode 0700 and the config file with mode 0600. Prefer the interactive prompt or environment variables over --app-password when command-line arguments may be visible to other local processes.
Clear the selected config only when requested:
bash <skill-path>/scripts/wordpress_rest.sh clear-config
Treat the Application Password as a secret. Never print it, persist it in repository files, include it in URLs, or expose a generated Authorization header.
Requests ignore the user's default .curlrc and use connection and total timeouts. Override the defaults only when needed with WORDPRESS_CONNECT_TIMEOUT and WORDPRESS_MAX_TIME.
Request Wrapper
Use the wrapper for real requests so configuration, environment variables, and interactive input all work consistently:
bash <skill-path>/scripts/wordpress_rest.sh request \
--endpoint "/wp-json/wp/v2/users/me?context=edit"
GET, HEAD, and OPTIONS execute immediately. POST, PUT, PATCH, and DELETE dry-run by default:
bash <skill-path>/scripts/wordpress_rest.sh request \
--method POST \
--endpoint "/wp-json/wp/v2/posts" \
--data-file /absolute/path/post.json
After reviewing the redacted plan, add --execute to perform the write. Use --binary-file, --content-type, and --filename for media uploads. Use --headers and --output for pagination or saved responses.
For body content, use verbatim-create or verbatim-update instead. These actions dry-run by default, submit the supplied JSON unchanged, and perform an advisory content.raw comparison through the bundled Python helper.
The read-only direct curl examples below show the underlying REST request shape and assume the three environment variables are already populated. Use the wrapper for every write.
Connect and Discover
Normalize the URL and inspect the REST index:
WORDPRESS_URL="${WORDPRESS_URL%/}"
curl --fail-with-body --silent --show-error \
"$WORDPRESS_URL/wp-json/"
Verify authenticated access:
curl --fail-with-body --silent --show-error \
--user "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/users/me?context=edit"
Use only HTTPS for Application Password authentication. If /wp-json/ is unavailable, inspect the site's API discovery link before assuming the REST root. Do not fall back to WP-CLI.
Discover routes and accepted methods before using a site-specific or unfamiliar endpoint:
curl --fail-with-body --silent --show-error \
--request OPTIONS \
--user "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/posts"
WordPress plugins, themes, versions, permissions, and custom post-type registration can change which routes and fields are available. Treat the REST index and OPTIONS response as the source of truth for the target site.
Safety Contract
Before modifying or deleting an existing object:
- Fetch the current object with
context=edit. - Record its ID, type, slug, status, modified time, and fields being changed.
- Send only fields that must change.
- Preserve the current editor format and unrelated plugin-managed fields.
- Preserve the status in the approved payload; do not force a draft or add a second publication step.
- Preview exact targets for publishing, scheduling, navigation changes, taxonomy renames, deletes, and bulk updates.
- Trash posts and pages by default. Use
force=trueonly when permanent deletion was explicitly requested. - Verify the response and fetch the object again after each write.
For resources such as taxonomy terms that do not support Trash and require permanent deletion, explain that behavior and obtain explicit approval first.
Core Endpoints
| Resource | Base endpoint |
|---|---|
| Posts | /wp-json/wp/v2/posts |
| Pages | /wp-json/wp/v2/pages |
| Media | /wp-json/wp/v2/media |
| Categories | /wp-json/wp/v2/categories |
| Tags | /wp-json/wp/v2/tags |
| Post types | /wp-json/wp/v2/types |
| Taxonomies | /wp-json/wp/v2/taxonomies |
| Classic menus | /wp-json/wp/v2/menus |
| Classic menu items | /wp-json/wp/v2/menu-items |
| Menu locations | /wp-json/wp/v2/menu-locations |
| Block navigation | /wp-json/wp/v2/navigation |
Do not assume every endpoint is enabled. Confirm it through /wp-json/ or OPTIONS.
Read and Search
Fetch editable source, not only rendered public HTML:
curl --fail-with-body --silent --show-error \
--user "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/posts/123?context=edit&_fields=id,type,slug,status,date,date_gmt,modified,link,title,content,excerpt,featured_media,categories,tags,meta"
Search before resolving an ID:
curl --fail-with-body --silent --show-error \
--user "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/posts?context=edit&search=phrase&per_page=20&_fields=id,type,slug,status,modified,title"
Use URL encoding for user-supplied query values. Do not interpolate raw search text, slugs, or filters into a URL without encoding.
Create Posts and Pages
Write JSON payloads to a temporary or workspace file using the host's file-editing tools. Avoid complex inline shell quoting.
Example post payload:
{
"title": "Post title",
"slug": "post-slug",
"content": "<!-- wp:paragraph --><p>Post content.</p><!-- /wp:paragraph -->",
"excerpt": "Short summary",
"status": "draft",
"categories": [3, 5],
"tags": [10, 12]
}
Create the post:
bash <skill-path>/scripts/wordpress_rest.sh verbatim-create \
--endpoint "/wp-json/wp/v2/posts" \
--data-file /absolute/path/post.json
Review the dry-run, then add --execute. The action sends the payload file directly, including its original content and requested status. A content.raw mismatch is only a warning and never triggers an automatic status change or rollback. A requested publication status that was not applied is a real failure.
Create a page by sending the appropriate payload to /wp-json/wp/v2/pages. Page-specific fields can include parent, menu_order, and template when supported by the endpoint schema.
Update Existing Content
Fetch and save the current editable response first:
curl --fail-with-body --silent --show-error \
--user "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/posts/123?context=edit" \
--output /absolute/path/post-123-before.json
Prepare a minimal update payload, for example:
{
"title": "Updated title",
"excerpt": "Updated summary"
}
Update:
bash <skill-path>/scripts/wordpress_rest.sh request \
--method POST \
--endpoint "/wp-json/wp/v2/posts/123" \
--data-file /absolute/path/post-update.json
Review the dry-run, then add --execute. Do not resend content unless content is intended to change. When the update includes content, use verbatim-update with --backup /absolute/path/post-123-before.json instead.
Gutenberg Blocks
Treat content.raw from context=edit as structured block source:
<!-- wp:heading {"level":2} -->
<h2 class="wp-block-heading">Section title</h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>Paragraph text.</p>
<!-- /wp:paragraph -->
When editing:
- retain matching block comments
- preserve block JSON attributes unless the requested change requires them
- preserve plugin blocks and shortcodes outside the requested scope
- avoid wrapping existing blocks in extra HTML
- verify the returned
content.raw
Do not convert Gutenberg content to Classic HTML or Classic content to blocks unless requested.
Schedule and Publish
WordPress accepts date in the site's timezone and date_gmt in UTC. Inspect /wp-json/wp/v2/settings when the authenticated user has permission:
curl --fail-with-body --silent --show-error \
--user "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/settings?_fields=timezone"
If the timezone cannot be read, ask for the intended timezone instead of guessing.
Schedule with an explicit local date and future status:
{
"status": "future",
"date": "2030-12-01T09:00:00"
}
Publish an approved draft with:
{
"status": "publish"
}
POST the payload to the exact post or page endpoint, then verify status, date, date_gmt, and link.
Media and Featured Images
Upload binary media:
bash <skill-path>/scripts/wordpress_rest.sh request \
--method POST \
--endpoint "/wp-json/wp/v2/media" \
--binary-file /absolute/path/hero.jpg \
--content-type "image/jpeg" \
--filename "hero.jpg"
Review the dry-run, then add --execute.
Capture the returned media id. Then update its metadata:
{
"title": "Hero image",
"alt_text": "Descriptive alternative text",
"caption": "Optional caption",
"post": 123
}
POST that payload to /wp-json/wp/v2/media/<id>. Set it as the featured image by updating the post with:
{
"featured_media": 456
}
Use meaningful alt text that describes the image's purpose. Use an empty value for purely decorative images. Do not invent image details that were not inspected.
Categories, Tags, and Custom Taxonomies
Search existing terms before creating new ones:
curl --fail-with-body --silent --show-error \
--user "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/categories?search=News&hide_empty=false&context=edit"
Create a category:
{
"name": "News",
"slug": "news",
"description": "Company news and updates",
"parent": 0
}
POST to /wp-json/wp/v2/categories. Create tags through /wp-json/wp/v2/tags. Assign terms by sending numeric category or tag IDs in the post payload.
Discover custom taxonomy routes through /wp-json/wp/v2/taxonomies and the REST index. Do not derive a route from the taxonomy name without checking its registered REST base.
Custom Post Types and Meta
Discover post types:
curl --fail-with-body --silent --show-error \
--user "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/types?context=edit"
Use each type's returned rest_namespace and rest_base. A custom post type must be registered for REST access.
Only registered REST-visible meta fields can be read or written through meta. Inspect OPTIONS for the target endpoint before sending custom fields. Preserve Advanced Custom Fields and SEO-plugin data unless the site exposes a documented REST field and the user requested that change.
Navigation
Discover available navigation routes first.
For classic menus:
- list or create menus through
/wp-json/wp/v2/menus - list or create items through
/wp-json/wp/v2/menu-items - filter items by their
menusterm ID - inspect assignments through
/wp-json/wp/v2/menu-locations
For block themes, manage wp_navigation content through /wp-json/wp/v2/navigation while preserving its block markup.
Read the current menu and all affected items before reordering or deleting. Send numeric IDs and explicit positions; never infer the target menu from its display name alone.
Delete and Restore
Trash a post or page:
bash <skill-path>/scripts/wordpress_rest.sh request \
--method DELETE \
--endpoint "/wp-json/wp/v2/posts/123"
Bypass Trash only after explicit approval:
bash <skill-path>/scripts/wordpress_rest.sh request \
--method DELETE \
--endpoint "/wp-json/wp/v2/posts/123?force=true"
Restore trashed editorial content by POSTing {"status":"draft"} when the endpoint and permissions support it.
Term deletion requires force=true because terms do not support Trash. Treat it as permanent.
Pagination and Bulk Operations
WordPress caps per_page at 100. Capture response headers:
curl --fail-with-body --silent --show-error \
--dump-header /absolute/path/headers.txt \
--output /absolute/path/posts-page-1.json \
--user "$WORDPRESS_USERNAME:$WORDPRESS_APP_PASSWORD" \
"$WORDPRESS_URL/wp-json/wp/v2/posts?context=edit&per_page=100&page=1"
Read X-WP-Total and X-WP-TotalPages, then fetch remaining pages.
For bulk writes:
- Build and display an explicit list of target endpoint paths and IDs.
- Save the intended payload for each target.
- Obtain approval for the exact set when the request did not already make it unambiguous.
- Send requests sequentially or in small bounded groups.
- Stop on the first unexpected error.
- Record successes and failures to avoid duplicate retries.
- Fetch every changed object again.
Use the batch endpoint only if it appears in the target site's REST index and its limits are understood. Do not assume batch support.
Response Handling
Treat HTTP status and JSON body together:
200: successful read or update201: successful creation400: invalid field, payload, or unsupported parameter401: missing or invalid authentication403: authenticated user lacks the required capability, or a security layer blocked the request404: wrong REST root, disabled route, wrong endpoint, or missing object409: conflicting state or duplicate resource429: rate limited; honorRetry-Afterwhen present5xx: server or plugin failure; do not blindly retry writes
Use --fail-with-body --silent --show-error so errors retain the WordPress JSON response. Never treat a successful HTTP response alone as proof that the rendered page is correct.
Verification and Handoff
After a write, report:
- site URL
- endpoint and HTTP method
- object type and ID
- title, slug, status, and modified or scheduled date
- admin edit URL when derivable
- public or preview link returned by WordPress
- taxonomy and featured-media IDs changed
- any skipped or partially failed targets
Fetch the object with context=edit and compare every written field. If browser access is available and presentation matters, open the preview or live link and visually verify the rendered page.
Use the official references when exact behavior is uncertain: