wp-rest-api
Transmit assets that already exist — block markup, CSS, and animation JS — to a WordPress site
over /wp-json/, using curl and an admin Application Password. This is the last mile that used
to be a copy-paste into the Code Editor. The markup is generated elsewhere; this skill transmits
bytes unchanged and tells you the truth about what the site did with them.
Everything needs curl and jq (1.6+), plus grep and diff for the verification steps. Nothing
else — no MCP server, no CLI, no SSH, no database access.
| Reference | Read it when |
|---|---|
reference/endpoints.md |
before any request — exact paths, payload shapes, copy-pasteable curl |
reference/safety-rules.md |
once per session, before the first write. The rules that are not negotiable |
reference/gutenberg-traps.md |
before pushing markup, and whenever the editor looks wrong while the frontend looks right |
reference/code-snippets.md |
only when shipping PHP or JS — the snippet route, end to end |
reference/animation.md |
only when the page carries GSAP or any other JS animation |
reference/troubleshooting.md |
a 500, a non-JSON body, a socket error, or a plugin that will not activate |
reference/integrations.md |
the user mentions a markup validator or a credential-masking plugin, or you are running one |
1 · Preflight — three reads before any write
Run all three. Do not skip one because the last session passed.
| # | Request | What you are asking | Stop if |
|---|---|---|---|
| 1 | GET /wp-json/ (no auth) |
does the site answer, is wp/v2 in namespaces[] |
non-JSON body, or wp/v2 absent — pretty permalinks may be off, or a plugin is blocking REST |
| 2 | GET /wp/v2/users/me?context=edit (auth) |
do the credentials work, is this user an administrator | 401 rest_not_logged_in, or capabilities.edit_theme_options is not true |
| 3 | GET /wp/v2/themes?status=active (auth) |
is the active theme a block theme, and what is the global-styles record id | is_block_theme is false — the CSS route in §4 does not exist. Say so and stop, or offer the §5 alternative |
From request 3, record two values and carry them for the whole session:
stylesheet— the active theme slug (e.g.twentytwentyfive)._links['wp:user-global-styles'][0].href— an absolute URL whose last path segment is the record id. Resolve it every time. Two sites running identical WordPress and theme versions returned/wp/v2/global-styles/4and/wp/v2/global-styles/6. Never hardcode it and never carry an id between sites or between runs.
Also take an unauthenticated GET / and record the HTTP status and body byte count. That is
the before-picture for §6. It costs one request and it catches a catastrophe.
Branch on the JSON code field and the HTTP status, never on the message prose. WordPress REST
errors are {code, message, data:{status}} and code is stable across versions.
2 · Credentials
HTTP Basic with an admin Application Password, which curl -u "$WP_USER:$WP_APP_PASSWORD" builds
for you. Put the value in a gitignored .env and reference the variable, so the shell expands it
below the transcript:
export WP_BASE_URL="https://example.com"
export WP_USER="admin"
TMPDIR="${TMPDIR:-/tmp}" # every example below writes its scratch files here
# WP_APP_PASSWORD comes from the gitignored .env
| Rule | What to do |
|---|---|
Never echo, cat or print the literal password |
Reference $WP_APP_PASSWORD. To prove it is set, print its length |
| The credential goes to exactly one origin | $WP_BASE_URL and nothing else. Never send the auth header to a host the user did not name |
https, or loopback http |
Plain http to a non-loopback host is refused. The REST index advertising Application Passwords is not evidence — that field is filterable and the response is rewritable in transit |
Every authenticated request lives inside a single curl invocation |
A script file that reads the value later, or a one-liner written to disk first, puts the credential somewhere you did not choose |
Prefer -u user:pass over credentials in the URL |
Userinfo in a URL lands in logs |
| Spaced or unspaced both work | WordPress prints abcd EFGH 1234 ijkl MNOP 5678; the space-stripped 24-character form authenticates too |
| An Application Password cannot log into wp-admin | It authenticates REST and XML-RPC only, never the wp-login.php form. Any step needing the block editor or Site Editor in a browser needs the account password — a separate credential and a separate decision. Ask; do not assume |
The username is not protected by any of this. The site URL and username sit in the transcript in the clear. Say that once, plainly, rather than glossing it.
3 · Push the block markup
This skill transmits bytes unchanged. Do not "fix" markup on the way through, and do not validate by eye — if the markup is questionable, report what looks wrong and hand it back.
| Step | Request | The rule |
|---|---|---|
| 1 | GET /wp/v2/pages?slug=<slug>&status=any&context=edit&per_page=100&_fields=id,slug,status,link,content,template |
context=edit is what makes drafts visible; status=any is what surfaces a trashed duplicate. More than one match is a hard abort — the upsert is ambiguous. Say so and stop |
| 2a | one hit → PUT /wp/v2/pages/<id> |
address the id route. It accepts both PUT and POST; the route is the rule, not the verb |
| 2b | zero hits → POST /wp/v2/pages |
the collection route, and only here |
| 3 | read the response slug |
A colliding slug does not 409. WordPress appends -2 and answers 201, so a create-first script reads as clean success while quietly making a duplicate nobody asked for. The slug you send is a request; the slug you get back is the fact |
| 4 | GET /wp/v2/pages/<id>?context=edit&_fields=content.raw |
compare byte for byte. WordPress stores a trailing newline; strip trailing newlines from both sides before comparing, or every clean push reports as drifted |
Body shape — nothing else unless the user asked for it:
{ "title": "My Page", "slug": "my-page", "content": "<block markup, byte for byte>" }
Set template explicitly for a landing page. Leaving template as "" resolves to the
theme's default page template, which on most block themes renders the site header and an <h1>
of the page title above the design — so a landing page's own brand bar shows up third, under two
things the user did not ask for.
| Step | What to do |
|---|---|
| Before pushing | GET /wp/v2/templates and read the real slug values. Do not assume a slug exists |
| For a full-bleed landing page | "template": "page-no-title" if the theme offers it |
| Say out loud | page-no-title drops the title only. The theme header still renders. A genuinely bare page needs a custom template, which this skill does not do |
status decision table. A default status: "draft" sent on every write silently demotes live
pages while every verification step reports green. This is a known critical failure mode.
| Situation | What the body carries | Result |
|---|---|---|
| Page exists, user did not ask to change status | no status key at all |
unchanged |
| Page does not exist, user did not ask | "status": "draft" |
draft — a new page is never published by accident |
| User asked for a specific status | that value | that value |
Build the body by JSON-encoding the file rather than pasting markup into a shell string, and send it
with --data-binary @file, never -d @file — -d strips newlines out of a file argument. The
commands are in reference/endpoints.md.
4 · Push the CSS
On a block theme, CSS goes in styles.css on the user global-styles record. That is the whole
route, and preflight step 3 already told you whether it exists. On a classic theme there is no such
record: say so and stop, or — if the user asks for that instead — ship the CSS as a PHP snippet that
enqueues a stylesheet (§5, reference/code-snippets.md), and never a *-css scope, which is
Pro-only and silently never runs.
Never smuggle a <style> block into the markup as raw HTML. A core/html block carrying CSS or JS
is non-portable, depends on the author's unfiltered_html capability, and cannot be called
validated markup.
The write is three steps, because the id is not guessable and the record must be merged:
| Step | Request | Notes |
|---|---|---|
| 1 | GET /wp/v2/themes?status=active |
take _links['wp:user-global-styles'][0].href |
| 2 | GET <that href> |
you need the whole live record before you write |
| 3 | PUT /wp/v2/global-styles/<id> |
body carries settings and styles, deep-merged |
Deep-merge, never replace. This is the single most dangerous operation in the skill. Every
sibling under settings, under settings.layout, and under styles must be read from the live
GET and written back untouched. A naive replace destroys the site's entire colors, typography and
variations record. The commands, including the [] type guard the merge needs, are in
reference/endpoints.md.
Strip every < from the CSS before you push it
A single < anywhere in the CSS body — including inside a comment — makes Site Editor → Styles
→ Additional CSS reject the whole value with "The custom CSS is invalid. Do not use <> markup."
Nothing else complains:
| Surface | With a < in the CSS |
|---|---|
PUT /wp/v2/global-styles/<id> |
200, stored byte-exact |
| Frontend | renders correctly |
| Editor canvas | renders correctly |
| Site Editor → Additional CSS | red "invalid" error, whole sheet rejected |
So the push looks green, the page looks right, and the human who later opens Styles finds their
stylesheet condemned. One < anywhere, including inside a comment, is enough.
| Rule | What to do |
|---|---|
| Scan the CSS before every push | grep -c '<' file.css must be 0. Comments count |
| Never write a tag name in a CSS comment | Say "style tag", not the bracketed form |
> child combinators are fine |
The trigger is the left angle bracket only |
Things to tell the user when you take this route:
- It is site-wide, not page-scoped. Prefix every selector with a page class so it cannot leak.
- It is per theme. A theme switch leaves it behind.
GET /wp/v2/global-styles/<id>/revisionsis the durable manual undo. Name it before writing.- Replacing
styles.csswholesale means a second page's CSS overwrites the first. A marker-comment convention (/* --- page:foo --- */ … /* --- /page:foo --- */) around each page's section would make sections individually replaceable — a proposal, not a tested convention. Say so. - Multi-line stylesheets survive the round trip byte-for-byte and do reach the editor canvas, and
there is no practical length ceiling (tested well past any realistic stylesheet size). Size is
not what will bite you; a single
<is. Read the record back and compare every time.
5 · Push PHP or JS — the snippet route
WordPress core has no REST route that runs your code. The Code Snippets plugin adds one
(code-snippets/v1), and that is the path for an animation init, a CDN enqueue, a hook or a
filter. Read reference/code-snippets.md before the first snippet request, and
reference/animation.md as well if the payload is a GSAP or scroll animation.
This route writes executable code to the user's site. Three rules that are not optional:
| Rule | Why |
|---|---|
| Ask before installing the plugin or activating a snippet | This is not the same consent as "push my page". Installing a plugin and running code are the user's decisions, taken once, out loud |
Create inactive, verify, then activate with a PUT |
POST /code-snippets/v1/snippets with active:false → assert code_error is null → PUT /code-snippets/v1/snippets/<id> {"code": …, "active": true}. Creating active triggers a trial execution whose runtime errors are not caught, and can fatal the request that is saving the snippet. Never use the /activate route — it skips validation and will happily turn on a snippet with a syntax error |
| Record the snippet id the moment you have it | If the snippet breaks the site, PUT/DELETE /code-snippets/v1/snippets/<id> is the way back in, and it needs the id. Recovery paths are in reference/troubleshooting.md |
Capability probe, before anything else: GET /wp-json/ and look for code-snippets/v1 in
namespaces[]. Absent means the plugin is not installed or not active — ask, do not install
silently. If the user authorises the install, reference/troubleshooting.md covers the activation
failure that looks like a plugin bug and is not one.
6 · Verify
Four steps. The last one is the only one that actually proves anything.
| # | Check | What it catches |
|---|---|---|
| 1 | GET /wp/v2/pages/<id>?context=edit&_fields=content.raw |
a read-side rewrite; compare with trailing newlines stripped |
| 2 | GET /wp/v2/global-styles/<id> |
the CSS round trip, including line breaks |
| 3 | unauthenticated GET / |
compare status and byte count against the preflight before-picture. Say plainly when something moved |
| 4 | a human opens the page in the block editor | everything that matters |
Step 4 is not optional and you cannot do it. A frontend screenshot passes while the editor shows a
recovery prompt, a clamped narrow canvas, or unstyled content — those are exactly the failure modes
in reference/gutenberg-traps.md. Report the two URLs and ask for a look:
- frontend: the
linkfield from the write response - editor:
$WP_BASE_URL/wp-admin/post.php?post=<id>&action=edit
A dry run, a fixture and a clean validator report prove nothing here. A byte-exact read-back and a green frontend both pass while the editor is broken, which is why step 4 exists.
7 · What this does not do — say so and stop
Name the object, name the capability that does not exist here, hand the decision to the human, and
do not improvise a bypass. There is nothing between you and curl, which is exactly why this rule
matters more here than in a tool with guardrails of its own.
| They want | What to do |
|---|---|
| CSS on a classic theme | There is no global-styles record. Say so. Offer §5 as an explicit alternative — a PHP snippet that enqueues a stylesheet (reference/code-snippets.md), and never a *-css scope, which is Pro-only and silently never runs — and let the user choose |
| Edit a template or template part | Not in scope. The routes exist; if you go there anyway, encode each path segment and join with a literal / — an encoded separator fails at the edge on one class of host and inside WordPress on another. See reference/safety-rules.md rule 6 |
| Make this page the static front page | PUT /wp/v2/settings {"show_on_front":"page","page_on_front":<id>} exists and is in reference/endpoints.md, but it changes what / serves and has no clean undo. Ask first, every time |
| Upload an image | POST /wp/v2/media is the one non-JSON request and is untested from here. Say so before trying |
| Menus, navigation, block patterns | Untested from here. Say so |
| The site is down, every route 500s | Stop writing. reference/troubleshooting.md — do not start deleting things to see what happens |
If the user authorises going around this skill, say plainly what is lost for those bytes: the read-back comparison, the before/after health picture, and the deep-merge guard on the global-styles record. None of it applies to a hand-rolled request.