The WordPress REST API as an agent surface: what you can and cannot automate
On this page, 11 sections
Here is the response an agent gets when it tries to publish a post with an editor-level application password on a post type it does not have rights to:

{
"code": "rest_cannot_publish",
"message": "Sorry, you are not allowed to publish posts in this post type.",
"data": { "status": 403 }
}
401 and 403 are different problems
That 403 is not a fixed number. rest_authorization_required_code() in wp-includes/rest-api.php is two lines: return is_user_logged_in() ? 403 : 401;.
Which means an agent that gets 401 has a credential problem and an agent that gets 403 has a capability problem, and those need completely different fixes.
Most agent retry loops treat them the same and burn tokens re-sending the same request.
This post goes route by route through what the WordPress REST API will actually let an agent do, using core source rather than the handbook, because on at least one point the two disagree.
Everything below was read from wordpress-develop trunk on 2 September 2026. It assumes you already have an agent wired up to the site, which is the subject of the writeup on running Claude Code against WordPress.
Authentication: application passwords and their two hard edges
Application passwords landed in WordPress 5.6. The agent sends HTTP Basic auth, WordPress picks it up from $_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW'], and wp_authenticate_application_password() does the rest.
One call confirms who the agent is: curl -s -u "agent:xxxx xxxx xxxx" "https://example.com/wp-json/wp/v2/users/me?context=edit" | jq '{id, name, capabilities}'.
Edge one. Application passwords are unavailable on plain HTTP. The gate is a single function: wp_is_application_passwords_supported() returns is_ssl() || 'local' === wp_get_environment_type().
What that means on staging
So a local dev install with WP_ENVIRONMENT_TYPE set to local works over HTTP, and a staging box on HTTP with the environment type unset returns application_passwords_disabled.
That error message says application passwords are not available, which reads like a site setting and is really a TLS check. The wp_is_application_passwords_available filter overrides it, and if you are reaching for that filter on anything internet-facing, fix the certificate instead.
Edge two, and this one is more interesting. Core strips every non-alphanumeric character from the supplied password before comparing, with $password = preg_replace( '/[^a-z\d]/i', '', $password );.
Why the spaces do not matter
The spaces WordPress shows you in the admin are presentational. abcd efgh ijkl and abcdefghijkl authenticate identically, which saves you an entire class of shell-quoting bug in agent config files.
Generated application passwords are alphanumeric by design, so nothing is lost.
There is also a filter deciding whether the current request even counts as an API request. By default $is_api_request is true when XMLRPC_REQUEST or REST_REQUEST is defined and true, and the value then passes through the application_password_is_api_request filter.
Why cookie auth is the wrong choice
Cookie authentication is the other option and it is the wrong one for agents. It needs an X-WP-Nonce header, and rest_cookie_check_errors() returns rest_cookie_invalid_nonce with a 403 when the nonce is stale.
Nonces expire. Agents run long. Do not build on it.
Which capability each route actually demands
This is the table I wish existed when I started. Every entry read from the controller source, not from documentation:
| Route and method | Capability checked | Source |
|---|---|---|
GET /wp/v2/posts?context=edit | edit_posts | class-wp-rest-posts-controller.php |
POST /wp/v2/posts | create_posts for the post type | same |
POST /wp/v2/posts with status publish, future or private | publish_posts as well | handle_status_param() |
POST /wp/v2/posts with another author | edit_others_posts | create_item_permissions_check() |
POST /wp/v2/posts with sticky | edit_others_posts or publish_posts | same |
PUT /wp/v2/posts/<id> | edit_post on that post | update_item_permissions_check() |
DELETE /wp/v2/posts/<id> | delete_post on that post | delete_item_permissions_check() |
GET or POST /wp/v2/settings | manage_options | class-wp-rest-settings-controller.php |
GET /wp/v2/plugins | activate_plugins | class-wp-rest-plugins-controller.php |
POST /wp/v2/plugins (install) | install_plugins, plus activate_plugins if status is not inactive | same |
DELETE /wp/v2/plugins/<plugin> | activate_plugins and delete_plugins | same |
GET /wp-json/wp-abilities/v1/abilities | read | class-wp-rest-abilities-v1-list-controller.php |
Two things that fall out of the table
Two things fall out of that table immediately. Reading posts in edit context needs edit_posts, so a subscriber-level password sees published content and nothing else, no drafts and no unfiltered raw content.
And listing abilities needs only read, which is the lowest bar in WordPress. The survey of WordPress MCP servers goes into what that exposes.

What you cannot automate at all
The gap people hit hardest: there is no plugin update route in core. The plugins controller registers PUT and PATCH on /wp/v2/plugins/<plugin>, and update_item() does exactly one thing.
It loads wp-admin/includes/plugin.php, reads the plugin data and its current status, and calls handle_plugin_status() when the requested status differs from the current one.
What you can and cannot do to a plugin
The only writable field is status, and its enum is inactive and active, plus network-active on multisite. There is no version parameter. You can install a plugin, activate it, deactivate it and delete it over REST.
You cannot upgrade one. Every fleet tool that offers one-click updates is doing it through its own agent plugin or over WP-CLI, not through core REST.
Delete has its own rule
Delete has its own rule, and it is the correct one: rest_cannot_delete_active_plugin with a 400 if the plugin is still active. Deactivate first, then delete.
Then there is the filesystem gate that catches people on managed hosts. is_filesystem_available() returns true when get_filesystem_method() is direct, or when request_filesystem_credentials() finds stored credentials. Otherwise it returns a WP_Error with the code fs_unavailable and status 500.
Why a managed host returns 500
If the host runs PHP as a different user from the file owner, get_filesystem_method() returns something other than direct, no FTP credentials are stored, and every plugin install or delete over REST returns a 500 with fs_unavailable.
Your agent will read that as a server fault and retry. It is a permissions design decision and no number of retries will change it.
The delete semantics that will eventually cost you a post
DELETE /wp/v2/posts/<id> without force=true trashes. With force=true it deletes permanently. That is the documented behaviour and it is true, right up until it is not. Here are the opening lines of wp_trash_post() in wp-includes/post.php:
function wp_trash_post( $post_id = 0 ) {
if ( ! EMPTY_TRASH_DAYS ) {
return wp_delete_post( $post_id, true );
}
When a soft delete is a hard delete
A site with define( 'EMPTY_TRASH_DAYS', 0 ); in wp-config.php has no trash. On that site, a soft delete is a hard delete, with no error, no warning, and a 200 response that looks exactly like a successful trash.
Plenty of performance-tuning guides recommend that constant. Plenty of hosts set it in a mu-plugin.
Two more shapes of the same trap
Two more shapes of the same trap. A post type that does not support trash returns rest_trash_not_supported with a 501 telling you to set force=true, and an agent that reads that message will happily comply.
And re-trashing an already-trashed post returns rest_already_trashed with a 410, which is the one case where the API is protecting you.
Check the constant before you let an agent delete anything: wp config get EMPTY_TRASH_DAYS --type=constant, falling back to a message saying it is not defined and therefore 30.
Pagination, and the ceiling nobody documents in the tutorials
per_page is capped in WP_REST_Controller::get_collection_params(). Its schema sets default 10, minimum 1 and maximum 100, sanitised with absint.

One hundred, hard
One hundred, hard. Asking for 500 returns rest_invalid_param with a 400. A five thousand post site is fifty requests minimum, and the agent needs to know that before it starts rather than discovering it at request forty.
The two headers that make pagination cheap
The two response headers that make pagination cheap are set in get_items(): X-WP-Total and X-WP-TotalPages. Read them from a per_page=1 probe and you know the shape of the job for the cost of one row.
curl -sI -u "agent:$APP_PASS" ".../wp-json/wp/v2/posts?per_page=1&status=any&context=edit" | grep -i '^x-wp-'.
_fields is the single biggest token saving available
A full wp/v2/posts item carries rendered content, rendered excerpt, every registered meta field, and the whole _links block.
On a page-builder site the rendered content of one post can run past 100KB, and an agent that pulls a hundred of those has spent its context window on markup it will never read.
rest_filter_response_fields() is registered on rest_post_dispatch and trims the response after the fact.
curl -s -u "agent:$APP_PASS" \
"https://example.com/wp-json/wp/v2/posts?per_page=100&status=any&_fields=id,slug,status,modified,link" \
| jq -c '.[]' | head -5

What it saves, and the one caveat
Same query, roughly two per cent of the payload. Make this the default in every tool wrapper you write rather than something you remember to add.
The one caveat in the source: _fields is skipped when the response is an error, so error bodies always come back whole.
Rate limits: there are none, and that is the problem
I read class-wp-rest-server.php and rest-api.php in trunk looking for throttling. There is no rate limiter in WordPress core. The only global gate is one filter: return apply_filters( 'rest_authentication_errors', null );.
What core says about restricting it
Core is explicit that this is the supported way to restrict the API. The deprecation notice on the old rest_enabled filter says the REST API can no longer be completely disabled and that rest_authentication_errors should be used to restrict access instead.
Whatever limit you hit is not WordPress
So whatever limit your agent hits is your host, your WAF or Cloudflare, and it will present as a 429 or a 403 with an HTML body rather than JSON.
An agent parsing JSON gets a decode error and reports the site as broken. Handle the non-JSON response explicitly in your tool wrapper, and put your own delay in the loop, because nothing below you will do it for you.
On the fleets I have read about, this is also the difference between a management tool that scales and one that gets your control IP blocked, which is part of why the dedicated WordPress management tools exist at all.
The check to run before you trust any of this
Every claim above is about core. Your site is not core. A plugin can register a route that ignores capabilities entirely, and its permission callback is the only thing standing between an agent and that route.
Enumerate what is actually mounted with curl -s "https://example.com/wp-json/" | jq -r '.routes | keys[]' | sed 's|/(?P<[^>]*>.*||' | sort -u.
Then read the schema of anything you do not recognise
Then for each namespace you do not recognise, pull the route schema and read its methods.
This is the same discipline as verifying a claim two ways, which I wrote about after a scanner reported a site clean while it was serving spam to Googlebot: what the tool tells you and what is actually reachable are two different sets, and only one of them is authoritative.
If you are handing this surface to clients rather than to yourself, the boundary questions get harder, and I covered the operational side of that in how to manage WordPress websites for clients.
One thing to do next
Create a dedicated WordPress user for the agent with the narrowest role that still does the job, generate its application password, and run GET /wp-json/wp/v2/users/me?context=edit.
The capabilities object in that response is the real blast radius, expressed as data. Read it before the agent does anything. If it contains manage_options or activate_plugins and the job is publishing posts, you have given away more than you meant to.
The guardrails that go around this surface, including how to scope an application password that core gives you no way to scope, are in the companion post on running an agent against production WordPress.
For where WordPress is taking this next, the Abilities API in 6.9 and after is the thread to follow, and I sketched the wider direction in the piece on WordPress 7.0.
Resources
- WordPress REST API Handbook
- class-wp-rest-posts-controller.php in wordpress-develop
- class-wp-rest-plugins-controller.php
- wp_authenticate_application_password() in wp-includes/user.php
- wp-includes/rest-api.php
- wp_register_ability() in the code reference
- wp plugin update, for the upgrades REST cannot do
Disclosure: I am CMO at POSIMYTH, which ships WordPress plugins across 500,000+ installs. The endpoint behaviour above is core behaviour and applies to every WordPress site, ours included.
More on agents on wordpress
- MCP servers for WordPress: what actually exists and what each one can do
- Guardrails for running an agent against a production WordPress site
- Automating WordPress maintenance with agents: what is worth automating and what is not