---
title: "The WordPress REST API as an agent surface: what you can and cannot automate"
url: https://adityaarsharma.com/wordpress-rest-api-agent-surface/
date: 2026-09-11
modified: 2026-09-03
author: "Aditya Sharma"
description: "Route by route from core source: which capability each endpoint needs, why core has no plugin update route, and the constant that turns soft deletes hard."
categories:
  - "AI"
  - "WordPress"
image: https://adityaarsharma.com/wp-content/uploads/2026/09/8037df5e-bd2e-4d99-83ec-717207849cfc_2912x1632-1024x574.webp
word_count: 2009
---

# The WordPress REST API as an agent surface: what you can and cannot automate

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:

![The WordPress REST API caps every list request at 100 items.](https://adityaarsharma.com/wp-content/uploads/2026/09/8037df5e-bd2e-4d99-83ec-717207849cfc_2912x1632-scaled.png)

`{
"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](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/).

On this page

- यह नक्षत्र और पद असल में क्या दर्शाते हैं- यह प्लेसमेंट कैसे दिखता है- वह बात जो कम बताई जाती है- यह चंद्रमा कब मजबूत या कमज़ोर होता है- लोग अक्सर गलत समझते हैं- महादशा में यह कब जागता है- इस पद का नवांश गिनकर निकालिए- मेष नवांश क्या जोड़ता है- नक्षत्र-स्वामी का दशा-क्रम से रिश्ता- अक्सर पूछे जाने वाले सवाल

## 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](https://adityaarsharma.com/mcp-servers-for-wordpress-what-exists/) goes into what that exposes.

![The plugins endpoint reference in the WordPress REST API handbook.](https://adityaarsharma.com/wp-content/uploads/2026/09/fafc8a27-a70a-4efd-a513-5901a6001648_2880x1800-scaled.png)developer.wordpress.org/rest-api/reference/plugins/, screenshot taken 3 September 2026.

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

Newsletter

## Automating the boring half

I publish one researched piece a week on putting agents to work on real sites. What I built, what broke, and the commands to check it yourself.

Email address

Get it weekly

Free. One email a week. Unsubscribe in one click, and I do not send anything else.

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

![The per_page ceiling is 100, so a five thousand post site is fifty requests minimum.](https://adityaarsharma.com/wp-content/uploads/2026/09/0325ec1c-4d19-4808-bbd9-52ae157fc814_2912x1632-scaled.png)Built from WP_REST_Controller::get_collection_params() in wordpress-develop trunk, read 2 September 2026.

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

![Setting _fields brings the same posts query down to roughly two per cent of the payload.](https://adityaarsharma.com/wp-content/uploads/2026/09/20a5e0a0-4ddb-451c-9bb5-2b5eb6a07bcd_2400x2400.png)Built from rest_filter_response_fields() in wordpress-develop trunk, read 2 September 2026.

### 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](https://adityaarsharma.com/best-8-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](https://adityaarsharma.com/the-scanner-said-clean-the-site-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](https://adityaarsharma.com/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](https://adityaarsharma.com/guardrails-agent-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](https://adityaarsharma.com/wordpress-7-0-is-the-biggest-thing-to-happen-since-gutenberg-and-nobodys-talking-about-it/).

## Resources

- [WordPress REST API Handbook](https://developer.wordpress.org/rest-api/)- [class-wp-rest-posts-controller.php in wordpress-develop](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php)- [class-wp-rest-plugins-controller.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php)- [wp_authenticate_application_password() in wp-includes/user.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/user.php)- [wp-includes/rest-api.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/rest-api.php)- [wp_register_ability() in the code reference](https://developer.wordpress.org/reference/functions/wp_register_ability/)- [wp plugin update, for the upgrades REST cannot do](https://developer.wordpress.org/cli/commands/plugin/update/)

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](https://adityaarsharma.com/mcp-servers-for-wordpress-what-exists/)- [Guardrails for running an agent against a production WordPress site](https://adityaarsharma.com/guardrails-agent-production-wordpress/)- [Automating WordPress maintenance with agents: what is worth automating and what is not](https://adityaarsharma.com/automating-wordpress-maintenance-with-agents/)