Skip to content

Abilities API: Add a core/content-query ability - #12195

Closed
jorgefilipecosta wants to merge 99 commits into
WordPress:trunkfrom
jorgefilipecosta:add/core-content-ability
Closed

jorgefilipecosta wants to merge 99 commits into
WordPress:trunkfrom
jorgefilipecosta:add/core-content-ability

Conversation

@jorgefilipecosta

@jorgefilipecosta jorgefilipecosta commented Jun 16, 2026 •

Copy link
Copy Markdown
Member

Part of WordPress/ai#40.

This PR adds the read-only core/content-query ability to core. It ports the ability from the AI plugin (WordPress/ai#739, WordPress/ai#1002, and WordPress/ai#1025), with the shape from jorgefilipecosta#93.

This PR implements the following changes:

  • Adds the core/content-query ability in a new content category.
  • Adds a show_in_abilities post type argument. Only post types that set it to true can be queried; post and page do.
  • The input is { where, fields, pagination }, with no top-level oneOf or anyOf, which some AI providers reject. where.type is required, and the optional ids, slugs, status, author_slug, and parent filters combine with AND.
  • The output is always { items, pagination: { total, total_pages } }, newest posts first, 10 per page by default, or 100 with ids or slugs.
  • The ability requires a logged-in user, and permissions follow the REST API posts endpoint: posts the user cannot read are left out, and raw fields are only returned for posts the user can edit. As in REST, pagination.total can count posts left out of items.

Querying the posts of an author, two per page:

await wp.apiFetch( {
	path: wp.url.addQueryArgs( '/wp-abilities/v1/abilities/core/content-query/run', {
		input: {
			where: { type: 'post', author_slug: 'jane' },
			pagination: { per_page: 2 },
		},
	} ),
} );
{
	"items": [
		{
			"id": 57,
			"type": "post",
			"status": "publish",
			"date": { "local": "2026-10-10T09:00:00+00:00", "gmt": "2026-10-10T09:00:00+00:00" },
			"slug": "release-notes",
			"title": { "rendered": "Release notes" }
		},
		{
			"id": 56,
			"type": "post",
			"status": "publish",
			"date": { "local": "2026-10-05T09:00:00+00:00", "gmt": "2026-10-05T09:00:00+00:00" },
			"slug": "our-roadmap",
			"title": { "rendered": "Our roadmap" }
		}
	],
	"pagination": { "total": 3, "total_pages": 2 }
}

Querying pages by slug, with some fields. With ids or slugs, every status the user may query is included, so an administrator also gets the draft page:

await wp.apiFetch( {
	path: wp.url.addQueryArgs( '/wp-abilities/v1/abilities/core/content-query/run', {
		input: {
			where: { type: 'page', slugs: [ 'about', 'contact' ] },
			fields: [ 'status', 'title', 'link' ],
		},
	} ),
} );
{
	"items": [
		{
			"id": 59,
			"status": "draft",
			"link": "http://localhost:8918/?page_id=59",
			"title": { "raw": "Contact", "rendered": "Contact" }
		},
		{
			"id": 58,
			"status": "publish",
			"link": "http://localhost:8918/about/",
			"title": { "raw": "About", "rendered": "About" }
		}
	],
	"pagination": { "total": 2, "total_pages": 1 }
}

The same query as a subscriber only returns the published page, without the raw title:

{
	"items": [
		{
			"id": 58,
			"status": "publish",
			"link": "http://localhost:8918/about/",
			"title": { "rendered": "About" }
		}
	],
	"pagination": { "total": 1, "total_pages": 1 }
}

Trac ticket: https://core.trac.wordpress.org/ticket/66268

Testing

Verify unit tests are passing:

npm run test:php -- --group 66268

Open the post editor and run the examples above in the browser console, using an author and page slugs from your site.

Use of AI Tools

AI assistance: Yes. Claude Code was used to sync this PR with the plugin, apply the review feedback, test it, and draft this description; I reviewed the changes.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

@github-actions

Copy link
Copy Markdown

Hi there! 👋

Thank you for your contribution to WordPress! 💖

It looks like this is your first pull request to wordpress-develop. Here are a few things to be aware of that may help you out!

No one monitors this repository for new pull requests. Pull requests must be attached to a Trac ticket to be considered for inclusion in WordPress Core. To attach a pull request to a Trac ticket, please include the ticket's full URL in your pull request description.

Pull requests are never merged on GitHub. The WordPress codebase continues to be managed through the SVN repository that this GitHub repository mirrors. Please feel free to open pull requests to work on any contribution you are making.

More information about how GitHub pull requests can be used to contribute to WordPress can be found in the Core Handbook.

Please include automated tests. Including tests in your pull request is one way to help your patch be considered faster. To learn about WordPress' test suites, visit the Automated Testing page in the handbook.

If you have not had a chance, please review the Contribute with Code page in the WordPress Core Handbook.

The Developer Hub also documents the various coding standards that are followed:

Thank you,
The WordPress Project

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-ability branch from 87c0275 to 3cf4957 Compare June 18, 2026 10:12
@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-ability branch from 103a8d6 to 8123116 Compare July 10, 2026 16:00
@jorgefilipecosta jorgefilipecosta changed the title [in progress] Abilities API: Add a core/content ability Abilities API: Add a core/read-content ability Jul 10, 2026
@jorgefilipecosta
jorgefilipecosta marked this pull request as ready for review July 10, 2026 16:06
@jorgefilipecosta
jorgefilipecosta requested a review from gziolo July 10, 2026 16:06
@github-actions

github-actions Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props jorgefilipecosta, peterwilsoncc, gziolo, ugyensupport.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@gziolo

gziolo commented Jul 13, 2026

Copy link
Copy Markdown
Member

Thanks @jorgefilipecosta! I went through this against the companion plugin PR WordPress/ai#739. The Core class is a truthful representation of what is proposed there. After accounting for the expected adaptations (no namespace, no ai text domain, and dropping the plugin-only init(), category self-registration, and override blocks), there is no divergent logic. The new show_in_abilities post type arg is clean and mirrors the settings approach, the wiring and category registration look right, and CI is green.

On sequencing: the AI plugin PR should go first. Since #739 is still open, we are matching a proposal, not merged code, so this PR should wait on that merge to avoid the two drifting.

One small thing to add: the Trac ticket reference is missing. There should already be one for this ability, like the sibling PRs have (users links #64657 and settings links #64605). Could you add it to the description and the test @ticket tags?

@gziolo
gziolo removed their request for review September 3, 2026 09:41

@peterwilsoncc peterwilsoncc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a few notes inline. LMK if this is no longer the canonical URL as there are a few linked to the ticket.

Comment thread src/wp-includes/abilities/class-wp-content-abilities.php Outdated
Comment thread src/wp-includes/class-wp-post-type.php Outdated
Comment thread tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php Outdated
Comment thread tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php Outdated
Comment thread tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php Outdated
Comment thread src/wp-includes/abilities/class-wp-content-abilities.php Outdated
Comment thread src/wp-includes/abilities/class-wp-content-abilities.php Outdated
Comment thread src/wp-includes/abilities/class-wp-abilities-content.php
@gziolo

gziolo commented Sep 30, 2026

Copy link
Copy Markdown
Member

In addition to addressing the feedback from @peterwilsoncc, we need to sync the latest fixes applied in the AI plugin. Most importantly:

@gziolo gziolo changed the title Abilities API: Add a core/read-content ability Abilities API: Add a core/content-query ability Oct 1, 2026
@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-ability branch from 670cd55 to b8ffb86 Compare October 5, 2026 21:26
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 6, 2026
get_post_type_capabilities() always sets the capabilities these checks
read for registered post types, and the REST posts controller reads
them directly too, so the guard isn't needed. This matches the change
made in review of WordPress/wordpress-develop#12195.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 6, 2026
get_post_type_capabilities() always sets the capabilities these checks
read for registered post types, and the REST posts controller reads
them directly too, so the guard isn't needed. This matches the change
made in review of WordPress/wordpress-develop#12195.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 6, 2026
WordPress/wordpress-develop#12195 now has the same ID lookup, post type
check, shared schemas, author_slug filter and field, and title guard as
the query here, so the markers that said what core does instead were
wrong. Remove them, and put back core's wording in the comments they
were added to. The class docblock now says that only the write
abilities and the helpers they alone use are missing from core.
@gziolo

gziolo commented Oct 7, 2026

Copy link
Copy Markdown
Member

The previous review feedback is addressed, and local verification passed for the focused tests and the broader Abilities API suite on both single-site and multisite.

Two follow-ups remain before approval:

  • Please remove open_world and its accompanying comment/assertion, since WordPress Core does not support this annotation.

  • The rendering helpers do not fully restore loop globals when there is no surrounding post. To reproduce:

    1. Start with an empty global WP_Query and no $GLOBALS['post'].
    2. Execute core/content-query for a published post with fields: ['content_rendered'].
    3. Inspect $GLOBALS['pages']: it still contains the rendered post’s content.

    setup_postdata() populates these globals, but wp_reset_postdata() cannot restore them because the main query has no post. The excerpt helper uses the same cleanup pattern. Please preserve and restore the affected globals, including their previously unset state, and add regression coverage.

For awareness, this PR incorporates the author_slug contract from the still-open WordPress/ai#1025. Keep the implementations synchronized as that PR settles.

@gziolo gziolo left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a full pass over the class, in addition to my earlier comment. Permission handling looks solid. Every lookup is checked, raw fields need edit access, and filters that cannot be applied fail closed. I left a few inline notes:

  • Before approval: limit show_in_abilities to a boolean for now, and remove the core/users-query references (or land that ability first).
  • Question: the network-wide author lookup on multisite.
  • Docs and minor: the future ability name, an explicit limit in the slug lookup, and the @param alignment.

Comment thread src/wp-includes/post.php Outdated
Comment thread src/wp-includes/abilities/class-wp-content-abilities.php Outdated
Comment thread src/wp-includes/abilities/class-wp-content-abilities.php Outdated
Comment thread src/wp-includes/abilities/class-wp-content-abilities.php Outdated
Comment thread src/wp-includes/abilities/class-wp-abilities-content.php Outdated
Comment thread src/wp-includes/abilities/class-wp-content-abilities.php Outdated
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
get_permalink() reads the post's terms for `%category%` permalinks and its
author for `%author%` permalinks. A query requesting `link` primed neither,
so each returned post ran its own term or user query, up to 100 per page.

`link` now primes the post meta and term caches, as the rendered excerpt
and content do, and the author caches, as `author_slug` does. This matches
WordPress/wordpress-develop#12195.

The tests' query counter now loads the current user again after it flushes
the cache. On multisite, capability checks load that user to check for a
super admin, which would otherwise be counted as a query of the callback.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
The comments said a GET request delivers list inputs as comma-separated
strings and integers as strings, "until core sanitizes ability input
itself". Since WordPress 7.1, the REST run controller converts input to
the ability's input schema, so REST input arrives typed there.

Schema validation still accepts integer and comma-separated strings, so a
caller that bypasses the run controller, such as a direct
WP_Ability::execute() call, can still pass them. The comments now say so,
with core's wording from WordPress/wordpress-develop#12195, and a Plugin:
note that a GET request on WordPress 7.0 can pass them too.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
The rendered excerpt and content set the global post and call
setup_postdata(), then restored only the global post. When the main query
has no post, as in a REST request, wp_reset_postdata() does nothing, so
`$id`, `$authordata`, `$currentday`, `$currentmonth`, `$page`, `$pages`,
`$multipage`, `$more`, and `$numpages` kept the rendered post's data. When a
post was set up before, running setup_postdata() for it again recomputed
those globals instead of restoring them, undoing a template's `$more = 1`.

set_up_post_context() now saves the loop globals before rendering, and
restore_post_context() gives each its previous value back or unsets it
again, as in WordPress/wordpress-develop#12195. When a post was set up
before, setup_postdata() still runs for it first, so callbacks on
`the_post` can restore their own globals.
Remove the tests that repeat others, and share one data provider of
the inputs without a post type between the schema and the callback
tests. Turn the loops over input values into data providers.

Fix the checks that could not fail. The order test creates the posts
out of date order, so a query ordered by ID fails it, the status
filter test lists a draft next to the published post, and the checks
that only expected an array compare the IDs of the posts returned.

Compare lists of keys with `assertSameSets()`, check the exact keys of
a post when fields are requested, cover `content.raw` and
`content.rendered` as field paths, and rename the tests named after the
removed single-post and query modes.
A `status` list that is given but holds no status fell through to the
default statuses. With `ids` or `slugs`, those cover every status the
current user may query, so an empty list widened the query instead of
narrowing it, unlike an `ids` or `slugs` list with no valid entry,
which fails as an invalid filter.

Require at least one status in the input schema, and reject a list
with no status left in the execute callback, as for `ids` and `slugs`.
Pin `minItems` on the `status` filter in the input schema test, and
cover an empty list and a list without a status name in the test of
the statuses the execute callback rejects.
…ors.

Build the title, excerpt and content of a post from one table, as the
dates are, instead of three copies of the same checks. Set the `id`
without a check, since the requested fields always include it.

Inline the not-found error at its only call site, keeping the note
that a direct call to the execute callback is the only way to reach it.

Describe what the `ids` filter does with a digit string beyond the
integer range: it saturates, as REST sanitization does, rather than
being dropped like a float too large to hold its integer.
Say that the totals can count posts the current user cannot read,
such as another author's draft listed in `ids`, and that this is
deliberate: a REST collection request with `include` and `status`
reveals the same.

Say why the inherited-parent read check stays, although no input can
request `inherit`: a query filter can still add the status. Also note
that posts in internal statuses cannot be fetched by ID.

Give the default page size in the `per_page` description: 10, or 100
when `where.ids` or `where.slugs` is given.
Wrap rest_is_field_included() in an is_field_requested() method, used
for every requested field path, and describe there how a whole field
and its paths match, as the REST API's `_fields` parameter matches
them.
…query`.

Register a post type with its own capabilities, and check that the
status gate, the private posts a query covers, and the author_slug
lookup follow the capabilities of that post type rather than those of
posts. Also cover a user who can read private posts but cannot edit
posts.
@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-ability branch from 0abd894 to 00349ab Compare October 15, 2026 16:55
…throw.

Save the loop globals before the post is set up, and set it up inside
the try block, so a `the_post` callback that throws while the post is
set up no longer leaves the post and its loop globals in place after
the ability returns its error.

Also put the saved globals back when a `the_post` callback throws while
the surrounding post is set up again.
Throw from `the_post` while the requested post is set up, and while the
surrounding post is set up again, and check that the query fails with
the callback exception error and that the surrounding loop globals are
restored as they were.
@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-ability branch from e1b2fd6 to 0670065 Compare October 15, 2026 17:21
Schema validation reads a scalar given for an array as a one-item list,
so `where.slugs` sent as the number 2024 passed validation. The execute
callback then dropped every list value that was not a string, and failed
with `content_invalid_filter`. A scalar is now read as its string form,
as schema validation reads it.
Query `where.slugs` as the number 2024, next to the comma-separated
string and single value cases.
… schema.

The Gemini API rejects `uniqueItems` in the `parameters` of a function
declaration, which is where the Google provider plugin (1.2.0) sends an
ability's input schema. Every Gemini request that offered the ability
failed with a 400 before the model could call it.

A repeated value is harmless: the execute callback already deduplicates
IDs and slugs, and a repeated status or field path changes nothing. The
REST API posts endpoint does not declare `uniqueItems` on its `include`,
`slug` and `status` parameters either.
Check that the input schema declares no `uniqueItems` at any depth, and
that a repeated ID, slug, status or field path returns the same result
as the input without the repeat. The duplicate IDs and slugs cases leave
the invalid input cases.
…t schema.

This reverts d5b3624 and 19043a5. Without `uniqueItems`, the input
schema qualifies for OpenAI's strict mode, which the OpenAI provider
plugin (1.2.0) gets by default because it does not set `strict`. Strict
mode treats every property as required, so the model filled every
`where` filter with made-up values and never found the posts it asked
for.

Gemini still rejects `uniqueItems` through the Google provider plugin
(1.2.0). The fix there is to send the schema as `parametersJsonSchema`.
@jorgefilipecosta

Copy link
Copy Markdown
Member Author

I tested core/content-query with real models using the snippet below. Add your API keys to the constants at the top, then visit /?ai-content-test as an administrator or editor, or /?ai-content-test=openai to test a single provider.

Snippet
<?php
/*
 * API keys for Settings > Connectors. Core reads these constants ahead of a key
 * saved on that screen; leave one empty to use the saved key instead.
 */
define( 'ANTHROPIC_API_KEY', '' );
define( 'OPENAI_API_KEY', '' );
define( 'GOOGLE_API_KEY', '' );

/**
 * Asks a provider a question that it answers by calling `core/content-query`.
 *
 * @param string $question The question.
 * @param string $provider The AI Client provider ID, such as 'anthropic', 'openai' or 'google'.
 * @param array  $calls    Receives the arguments of each ability call.
 * @param string $model    Receives the ID of the model that answered.
 * @return string|WP_Error The answer, or the error.
 */
function my_plugin_ai_content_answer( string $question, string $provider, array &$calls = array(), string &$model = '' ) {
	$resolver = new WP_AI_Client_Ability_Function_Resolver( 'core/content-query' );
	$messages = array(
		new \WordPress\AiClient\Messages\DTO\UserMessage(
			array( new \WordPress\AiClient\Messages\DTO\MessagePart( $question ) )
		),
	);

	for ( $i = 0; $i < 5; $i++ ) {
		$result = wp_ai_client_prompt( $messages )
			->using_provider( $provider )
			->using_system_instruction( 'You are a WordPress assistant. Use abilities when needed.' )
			->using_abilities( 'core/content-query' )
			->generate_text_result();

		if ( is_wp_error( $result ) ) {
			return $result;
		}
		$model = $result->getModelMetadata()->getId();

		// OpenAI returns each function call as its own candidate, so keep them all, not just toMessage().
		$model_messages = array();
		foreach ( $result->getCandidates() as $candidate ) {
			$model_messages[] = $candidate->getMessage();
		}
		array_push( $messages, ...$model_messages );

		$call_messages = array_filter( $model_messages, array( $resolver, 'has_ability_calls' ) );
		if ( ! $call_messages ) {
			return $result->toText();
		}

		foreach ( $call_messages as $message ) {
			foreach ( $message->getParts() as $part ) {
				if ( $part->getFunctionCall() ) {
					$calls[] = $part->getFunctionCall()->getArgs();
				}
			}
			$messages[] = $resolver->execute_abilities( $message );
		}
	}

	return new WP_Error( 'ai_tool_loop_limit', 'Too many ability rounds.' );
}

// Demo: visit /?ai-content-test as an administrator or editor, or /?ai-content-test=google for one provider.
add_action(
	'template_redirect',
	function () {
		if ( ! isset( $_GET['ai-content-test'] ) || ! current_user_can( 'edit_others_posts' ) ) {
			return;
		}

		$providers = array( 'anthropic', 'openai', 'google' );
		if ( in_array( $_GET['ai-content-test'], $providers, true ) ) {
			$providers = array( $_GET['ai-content-test'] );
		}

		$prompts = array(
			'How many published posts does this site have, and which are the three most recent? Give the title, author and date of each one.',
			'Which posts are drafts or pending review? Give the title, status and author of each one.',
			'Which pages have a parent page? Give the title of each one and of its parent.',
			'What does the post with the slug hello-world say?',
		);

		set_time_limit( 600 );
		header( 'Content-Type: text/plain; charset=utf-8' );

		foreach ( $providers as $provider ) {
			if ( ! \WordPress\AiClient\AiClient::defaultRegistry()->isProviderConfigured( $provider ) ) {
				echo "## {$provider}: skipped, no API key or provider plugin\n\n";
				continue;
			}

			foreach ( $prompts as $prompt ) {
				$calls  = array();
				$model  = '';
				$answer = my_plugin_ai_content_answer( $prompt, $provider, $calls, $model );

				echo "## {$provider}", $model ? " ({$model})" : '', ": {$prompt}\n";
				foreach ( $calls as $args ) {
					echo 'core/content-query ', wp_json_encode( $args ), "\n";
				}
				echo is_wp_error( $answer ) ? 'Error: ' . $answer->get_error_message() : $answer, "\n\n";
			}
		}
		exit;
	}
);

To test Gemini, the Google provider needs WordPress/ai-provider-for-google#45 applied. Version 1.2.0 sends the schema in a format that rejects uniqueItems, so every request fails with a 400 before the model can call the ability. With a free tier key, the default gemini-4-argon model has no quota, so for my Gemini runs I added ->using_model_preference( 'gemini-3.8-flash' ).

All the answers were correct. The site had 17 published posts, three drafts, two posts pending review and two child pages. The provider plugins came from WordPress.org: Anthropic 1.0.5, OpenAI 1.2.0 and Google 1.2.0 with the PR above.

Prompt Claude claude-sonnet-5-5 GPT gpt-6.1-sol Gemini gemini-3.8-flash
How many published posts does this site have, and which are the three most recent? ✅ ✅ ✅
Which posts are drafts or pending review? ✅ ✅ ✅
Which pages have a parent page? ✅ ✅ ✅
What does the post with the slug hello-world say? ✅ ✅ ✅
Full output
## anthropic (claude-sonnet-5-5): How many published posts does this site have, and which are the three most recent? Give the title, author and date of each one.
core/content-query {"where":{"type":"post","status":["publish"]},"pagination":{"per_page":3,"page":1},"fields":["id","title.rendered","author.name","date"]}
The site has **17 published posts**. These are the three most recent:

| # | Title | Author | Date |
|---|-------|--------|------|
| 1 | Hello world! | admin | 2026-10-15 17:34 |
| 2 | CQ p_admin_pub | admin | 2026-09-01 10:00 |
| 3 | CQ p_author_pub | Ana Author | 2026-08-01 10:00 |

The dates are in site-local time, which is the same as GMT here.

## anthropic (claude-sonnet-5-5): Which posts are drafts or pending review? Give the title, status and author of each one.
core/content-query {"where":{"type":"post","status":["draft","pending"]},"fields":["id","title","status","author"],"pagination":{"per_page":100}}
I found 5 posts that are drafts or pending review:

| Title | Status | Author |
|---|---|---|
| CQ p_author_draft | Draft | Ana Author |
| CQ p_author_pending | Pending | Ana Author |
| CQ p_author2_draft | Draft | Bea Author |
| CQ p_contrib_draft | Draft | cq_contributor |
| CQ p_contrib_pending | Pending | cq_contributor |

I only searched the standard "post" type. Pages and custom post types weren't checked. I can search those too if you'd like.

## anthropic (claude-sonnet-5-5): Which pages have a parent page? Give the title of each one and of its parent.
core/content-query {"where":{"type":"page","status":["publish","future","draft","pending","private"]},"fields":["id","title.rendered","parent","status"],"pagination":{"per_page":100}}
Two pages have a parent page:

| Page | Parent page |
|---|---|
| CQ pg_child_a | CQ pg_parent_a |
| CQ pg_child_b | CQ pg_parent_b |

I checked pages with these statuses: published, scheduled, draft, pending and private. The other four pages are top-level: Privacy Policy (a draft), Sample Page, CQ pg_parent_a and CQ pg_parent_b.

## anthropic (claude-sonnet-5-5): What does the post with the slug hello-world say?
core/content-query {"where":{"type":"post","slugs":["hello-world"]},"fields":["id","title","content","status","date","author.name"]}
The post with the slug `hello-world` is titled **"Hello world!"**. It says:

> Welcome to WordPress. This is your first post. Edit or delete it, then start writing!

This is the default WordPress starter post. Its details:
- **ID:** 1
- **Status:** Published
- **Author:** admin
- **Date:** 2026-10-15

## openai (gpt-6.1-sol): How many published posts does this site have, and which are the three most recent? Give the title, author and date of each one.
core/content-query {"where":{"type":"post","status":["publish"]},"fields":["title.rendered","author.name","date"],"pagination":{"per_page":3,"page":1}}
The site has **17 published posts**. The three most recent are:

| Title | Author | Date |
|---|---|---|
| Hello world! | admin | October 15, 2026 |
| CQ p_admin_pub | admin | September 1, 2026 |
| CQ p_author_pub | Ana Author | August 1, 2026 |

## openai (gpt-6.1-sol): Which posts are drafts or pending review? Give the title, status and author of each one.
core/content-query {"where":{"type":"post","status":["draft","pending"]},"fields":["title","status","author"],"pagination":{"per_page":100}}
| Title | Status | Author |
|---|---|---|
| CQ p_author_draft | Draft | Ana Author |
| CQ p_author_pending | Pending review | Ana Author |
| CQ p_author2_draft | Draft | Bea Author |
| CQ p_contrib_draft | Draft | cq_contributor |
| CQ p_contrib_pending | Pending review | cq_contributor |

**Total:** 3 drafts and 2 pending review.

## openai (gpt-6.1-sol): Which pages have a parent page? Give the title of each one and of its parent.
core/content-query {"where":{"type":"page"},"fields":["title.rendered","parent"],"pagination":{"per_page":100,"page":1}}
| Page | Parent page |
|---|---|
| CQ pg_child_a | CQ pg_parent_a |
| CQ pg_child_b | CQ pg_parent_b |

## openai (gpt-6.1-sol): What does the post with the slug hello-world say?
core/content-query {"where":{"type":"post","slugs":["hello-world"]},"fields":["title.rendered","content.rendered"]}
The post **“Hello world!”** says:

> Welcome to WordPress. This is your first post. Edit or delete it, then start writing!

## google (gemini-3.8-flash): How many published posts does this site have, and which are the three most recent? Give the title, author and date of each one.
core/content-query {"pagination":{"page":1,"per_page":3},"where":{"status":["publish"],"type":"post"},"fields":["id","title","author","date"]}
This site currently has **17** published posts.

The three most recent published posts are:

1. **Hello world!**
   - **Author:** admin
   - **Date:** October 15, 2026, 17:34:14 UTC

2. **CQ p_admin_pub**
   - **Author:** admin
   - **Date:** September 1, 2026, 10:00:00 UTC

3. **CQ p_author_pub**
   - **Author:** Ana Author
   - **Date:** August 1, 2026, 10:00:00 UTC

## google (gemini-3.8-flash): Which posts are drafts or pending review? Give the title, status and author of each one.
core/content-query {"where":{"status":["draft","pending"],"type":"post"},"fields":["id","title.rendered","status","author.name"]}
Here are the posts that are drafts or pending review:

1. **Title:** CQ p_author_draft  
   **Status:** Draft  
   **Author:** Ana Author  

2. **Title:** CQ p_author_pending  
   **Status:** Pending Review  
   **Author:** Ana Author  

3. **Title:** CQ p_author2_draft  
   **Status:** Draft  
   **Author:** Bea Author  

4. **Title:** CQ p_contrib_draft  
   **Status:** Draft  
   **Author:** cq_contributor  

5. **Title:** CQ p_contrib_pending  
   **Status:** Pending Review  
   **Author:** cq_contributor

## google (gemini-3.8-flash): Which pages have a parent page? Give the title of each one and of its parent.
core/content-query {"where":{"type":"page","status":["publish","draft","pending","private","future"]},"fields":["id","title.rendered","parent","status"],"pagination":{"per_page":100}}
core/content-query {"fields":["id","title.rendered","parent","status"],"pagination":{"per_page":100},"where":{"type":"page","status":["cq_showcase","cq_review"]}}
The following pages have a parent page:

1. **Page:** CQ pg_child_a  
   **Parent:** CQ pg_parent_a

2. **Page:** CQ pg_child_b  
   **Parent:** CQ pg_parent_b

## google (gemini-3.8-flash): What does the post with the slug hello-world say?
core/content-query {"fields":["title","content","slug"],"where":{"type":"post","slugs":["hello-world"]}}
The post titled **"Hello world!"** contains the following content:

> "Welcome to WordPress. This is your first post. Edit or delete it, then start writing!"

Tested and drafted with AI assistance (Claude Code).

`build_post_fields()` always sets the post `id`, whether or not it was
requested, so prepending `id` to the requested fields had no effect.
Move the reason the `id` is always included to that method.
Remove tests and assertions that another test, or a stronger assertion
in the same test, already covers, and provider cases for input shapes
that were never shipped.

Fold the smaller checks into the closest tests: the `title.rendered`
path into the sub-field cases, the protected excerpt into the protected
content test, the permalink filter into the post context test, and a
parent of 0 into the shared slug test. Test the public status that is
not viewable for every role in one test, measure author priming once,
and compare empty results with an `EMPTY_RESULT` constant.
Remove the REST tests that only add a 200 status check to what the
ability tests already cover, and the test for an `id` filter that was
never shipped. Send the string values of the query string test in one
request, and name the remaining 400 test after the input it sends.

@gziolo gziolo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks ready to commit from my side. Thank you for working through all the iterations.

The input is now always { where, fields, pagination }, and the output is always { items, pagination }. This gives callers a clear and predictable shape. Field selection works well. Every item keeps its id. Raw fields the user cannot see are left out, but the readable post is still returned. Password-protected content stays protected.

The explicit show_in_abilities opt-in, the private callbacks, and the tests for permissions, input normalization, pagination, and restoring the post context address my earlier concerns.

A few small documentation improvements would help callers. These fit well in the schema descriptions:

  • List the default fields (id, type, status, date, slug, title.rendered), and clarify that omitting fields or passing fields: [] uses them.
  • Explain that requested raw sub-fields are left out when the user cannot edit the post, and that fields the post type does not support are left out too. So a readable item may contain only id. Distinguish this from password-protected posts, where content.rendered and excerpt.rendered are returned as an empty string, and protected tells callers why.
  • Document that pagination.page defaults to 1. A page past the end returns an error. The only exception is when the query matches no posts at all: then the response has empty items and zero totals. Note that empty items does not always mean zero totals, because posts the user cannot read are still counted.

None of these need API changes, so they can be done together with the developer documentation. As a separate follow-up, it would be good to add a _doing_it_wrong() notice when a post type with show_in_abilities is registered after the ability.

…t-query` run.

Rendering a post calls setup_postdata(), which fires `the_post`. When
the ability runs before any global post is set, for example on `init`,
restore_post_context() unsets the loop globals again, but the action
stays counted. Once WP::register_globals() sets the main post,
get_the_content() called without a post, as the_content() outside the
loop and the Post Content block do, reads the unset `$pages` instead of
the post and throws a TypeError, which breaks the page.

The count is reset only when the ability was the first thing to fire
`the_post` and no global post was set. That puts back the state from
before the call, so get_the_content() reads the post again. When a loop
had already fired the action, or a post was set, the count is left
alone.
Check that rendering a field with no global post leaves `the_post`
uncounted, so get_the_content() reads a main post set afterwards, and
that the count of a loop that had already started is kept.
… fields.

List the default fields in the `fields` description, and say that a
requested field is left out of a post it does not apply to, such as a
raw sub-field the current user cannot edit or a field the post type
does not support, so a post may hold only its `id`. Contrast this with
password-protected posts, whose rendered excerpt and content are
returned as empty strings.

Say that `pagination.page` defaults to 1, that a query matching no post
returns empty `items` and zero totals for any page, and that `items` can
be empty while the totals count posts the current user cannot read.
@jorgefilipecosta

Copy link
Copy Markdown
Member Author

Thank you for the review @gziolo, the documentation suggestions were applied to the schema descriptions.

wporg-sync pushed a commit that referenced this pull request Oct 16, 2026
Adds a read-only `core/content-query` ability that returns a page of the posts of one post type, optionally filtered by IDs, slugs, status, author, or parent. Until now, Core had no ability to read posts. The code lives in the new private `WP_Abilities_Content` class, and the ability is registered in a new `content` category.
Post types opt in with the new `show_in_abilities` argument of `register_post_type()`, which defaults to `false`. Only `true` exposes a post type, and the `post` and `page` post types set it.

Developed in WordPress/ai#739 and #12195.

Props jorgefilipecosta, gziolo, peterwilsoncc, ovidiu-galatan, justlevine, ugyensupport, dkotter, jason_the_adams, jasonbahl, jeffpaul.
Fixes #66268.

git-svn-id: https://develop.svn.wordpress.org/trunk@64388 602fd350-edb4-49c9-b593-d223f7449a82
@github-actions

Copy link
Copy Markdown

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 64388
GitHub commit: 213ecca

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

@github-actions github-actions Bot closed this Oct 16, 2026
wporg-sync pushed a commit to WordPress/WordPress that referenced this pull request Oct 16, 2026
Adds a read-only `core/content-query` ability that returns a page of the posts of one post type, optionally filtered by IDs, slugs, status, author, or parent. Until now, Core had no ability to read posts. The code lives in the new private `WP_Abilities_Content` class, and the ability is registered in a new `content` category.
Post types opt in with the new `show_in_abilities` argument of `register_post_type()`, which defaults to `false`. Only `true` exposes a post type, and the `post` and `page` post types set it.

Developed in WordPress/ai#739 and WordPress/wordpress-develop#12195.

Props jorgefilipecosta, gziolo, peterwilsoncc, ovidiu-galatan, justlevine, ugyensupport, dkotter, jason_the_adams, jasonbahl, jeffpaul.
Fixes #66268.
Built from https://develop.svn.wordpress.org/trunk@64388


git-svn-id: http://core.svn.wordpress.org/trunk@63538 1a063a9b-81f0-0310-95a4-ce76da25c4cd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants