WordPress has had a built-in AI Client since 7.0, and it has a filter, wp_ai_client_prevent_prompt, that runs before any prompt leaves your site. Return true from it for users who lack a capability you choose, and AI features are off for them, however many editors you have. No plugin setting is needed, and it covers every plugin that uses the client.
The same client fires an action after every completed request, with the token count the provider reported, so you can count usage per user and per site and stop prompts once a daily or monthly cap is reached. Below is a small must-use plugin that does both, tested on WordPress 7.1, followed by the gaps you still need to close at the provider.
Key takeaways
- WordPress 7.0’s built-in AI Client lets you control AI use from one place. The wp_ai_client_prevent_prompt filter runs before any prompt leaves your site, and it covers every plugin that uses the client. arvindkumar.ca
- Core runs a fixed order of hooks on every generating call, and when the prevent filter returns true the provider is never contacted. The generate methods return a WP_Error with the code prompt_prevented and a 503 status. arvindkumar.ca
- To limit AI by role, create a must-use plugin and grant a custom capability such as use_ai to the roles you choose. Users without it are blocked, and you can swap core’s generic error for a message that makes sense to an editor. arvindkumar.ca
- The after-request action reports the token count, so you can count requests and tokens per user and per site. Once a daily or monthly cap is reached, further prompts are stopped. arvindkumar.ca
- A simple dashboard widget for users with manage_options can show administrators the numbers: the month’s total against the token cap and today’s requests by user. arvindkumar.ca
- Also set a spending limit at the provider. Set it to a number you would be annoyed to pay, use one key per site, and revoke keys for plugins you remove. arvindkumar.ca
- The AI Client hooks do not cover plugins that call a provider directly with their own API key and wp_remote_post(). For those, the provider cap is the only control. arvindkumar.ca
Summarised from this post, formatted for answer engines.
How do AI requests flow through WordPress 7.0?
Three parts: a provider, a caller and the hooks between them. An administrator installs a provider plugin, such as AI Provider for OpenAI, AI Provider for Google or AI Provider for Anthropic, and enters a key under Settings, Connectors. Core stores it in an option named connectors_ai_openai_api_key (the pattern is the same for each provider) or reads it from a constant or environment variable such as OPENAI_API_KEY. Then a plugin asks for something: wp_ai_client_prompt( $text )->generate_text(). The official AI plugin does exactly this inside its abilities, which the block editor runs over the REST API, so the PHP side is where everything meets.
Inside every generating call, core runs these in order:
| Hook | Type | When it runs | What you get |
|---|---|---|---|
wp_supports_ai | filter | First. The WP_AI_SUPPORT constant is checked before it. | A boolean. False turns AI off for the whole site. |
wp_ai_client_prevent_prompt | filter | Before a model is chosen. Also runs for is_supported_* checks. | $prevent (false) and a copy of the prompt builder. |
wp_ai_client_before_generate_result | action | After a model is chosen, before the HTTP request. | An event with the messages, the model and the capability. |
wp_ai_client_after_generate_result | action | After the provider answers. | An event whose getResult() includes getTokenUsage(). |
When the filter returns true, the generate methods return a WP_Error with the code prompt_prevented, the message “Prompt execution was prevented by a filter.” and status 503, the is_supported methods return false, and the provider is never contacted. All of this happens inside the request that asked for the prompt, so current_user_can() tells you who is asking. The 7.0 AI Client dev note on make.wordpress.org shows the filter blocking everyone but administrators; what follows is the same idea with a capability you can hand out, plus the accounting.
Step 1: block prompts for users without a capability
Create wp-content/mu-plugins/mysite-ai-limits.php. Must-use plugins load before normal plugins and cannot be switched off from the admin, which is what you want for a budget guard. Start with a capability. Roles live in the database, so grant it once and let a role editor plugin manage it afterwards. This grants Administrators and Editors; change the list to suit.
<?php
/**
* Plugin Name: AI access and usage limits
* Description: Allows AI Client prompts only for users with the use_ai capability, counts usage and enforces caps.
*/
use WordPress\AiClient\Events\AfterGenerateResultEvent;
/**
* Grants use_ai to Administrators and Editors, once. Edit the list, or move
* the capability around later with a role editor plugin.
*/
add_action( 'init', 'mysite_ai_grant_capability' );
function mysite_ai_grant_capability() {
if ( get_option( 'mysite_ai_caps_granted' ) ) {
return;
}
foreach ( array( 'administrator', 'editor' ) as $role_name ) {
$role = get_role( $role_name );
if ( $role ) {
$role->add_cap( 'use_ai' );
}
}
update_option( 'mysite_ai_caps_granted', 1 );
}
The capability shows up in role editor plugins like any other, which answers the forum question about there being no AI capability to tick. One person can be added without touching roles: $user->add_cap( 'use_ai' ).
Now the filter. It only accepts true or false, so core writes the error message itself, and “Prompt execution was prevented by a filter.” is not what an editor needs to read. The fix is to remember why you blocked and swap your own message in when the WP_Error is created, through the wp_error_added action. The next block does both.
/**
* Blocks prompts for users without use_ai, and for anyone over a cap.
*/
add_filter( 'wp_ai_client_prevent_prompt', 'mysite_ai_prevent_prompt', 10, 2 );
function mysite_ai_prevent_prompt( $prevent, $builder ) {
if ( $prevent ) {
return true;
}
// Cron and WP-CLI run with no user. This lets them through, counted against the site cap only.
$is_background = wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI );
if ( ! $is_background && ! current_user_can( 'use_ai' ) ) {
mysite_ai_block_reason( __( 'AI features are not enabled for your account. Ask an administrator for access.', 'mysite' ) );
return true;
}
$reason = mysite_ai_over_limit( get_current_user_id(), $is_background );
if ( '' !== $reason ) {
mysite_ai_block_reason( $reason );
return true;
}
return false;
}
/**
* Remembers why the current request was blocked, so the error can say so.
*/
function mysite_ai_block_reason( $new_reason = null ) {
static $reason = '';
if ( is_string( $new_reason ) ) {
$reason = $new_reason;
}
return $reason;
}
/**
* Core's message is "Prompt execution was prevented by a filter." Swap in ours.
* unsupported_model is the code the AI plugin uses when is_supported_* says no.
*/
add_action( 'wp_error_added', 'mysite_ai_explain_block', 10, 4 );
function mysite_ai_explain_block( $code, $message, $data, $error ) {
$reason = mysite_ai_block_reason();
if ( '' !== $reason && in_array( $code, array( 'prompt_prevented', 'unsupported_model' ), true ) ) {
$error->errors[ $code ] = array( $reason );
mysite_ai_block_reason( '' );
}
}
Three details. The $builder argument is a copy of the prompt builder with no getters, so you cannot read the prompt text; decide on the user and the context instead, and do not call its is_supported or generate methods inside the filter, because that runs the filter again. Cron and WP-CLI have no logged-in user; this version lets them through and counts them against the site cap, and you can delete that line if background jobs should be blocked too. And the unsupported_model code is there for the AI plugin: it calls is_supported_for_text_generation() before generating, gets false, and returns its own “connected provider” message. Swapping that one as well means its users read your explanation.
Step 2: count usage and enforce the caps
The after action hands you a GenerativeAiResult, and getTokenUsage()->getTotalTokens() is the prompt plus completion count the provider reported. Count requests as well, so a result that reports no tokens still registers. This block keeps a month total for the site and today’s totals per user in two options that reset themselves when the period changes, and it defines the caps that Step 1 checks. Zero turns a cap off, and the mysite_ai_limits filter changes the numbers from anywhere.
/**
* The caps. Zero disables a cap. Override with the mysite_ai_limits filter.
*/
function mysite_ai_limits() {
return apply_filters(
'mysite_ai_limits',
array(
'site_monthly_tokens' => 2000000,
'site_monthly_requests' => 3000,
'user_daily_requests' => 50,
)
);
}
/**
* This month's site-wide usage. Resets itself when the month changes.
*/
function mysite_ai_site_usage() {
$usage = get_option( 'mysite_ai_site_usage' );
$month = current_time( 'Y-m' );
if ( ! is_array( $usage ) || ! isset( $usage['period'] ) || $usage['period'] !== $month ) {
$usage = array(
'period' => $month,
'requests' => 0,
'tokens' => 0,
);
}
return $usage;
}
/**
* Today's usage per user ID. Resets itself when the day changes.
*/
function mysite_ai_today_usage() {
$usage = get_option( 'mysite_ai_today_usage' );
$day = current_time( 'Y-m-d' );
if ( ! is_array( $usage ) || ! isset( $usage['period'] ) || $usage['period'] !== $day ) {
$usage = array(
'period' => $day,
'users' => array(),
);
}
return $usage;
}
/**
* Returns a message when a cap has been reached, or an empty string.
*/
function mysite_ai_over_limit( $user_id, $is_background ) {
$limits = mysite_ai_limits();
$site = mysite_ai_site_usage();
if ( ( $limits['site_monthly_tokens'] > 0 && $site['tokens'] >= $limits['site_monthly_tokens'] )
|| ( $limits['site_monthly_requests'] > 0 && $site['requests'] >= $limits['site_monthly_requests'] ) ) {
return __( 'This site has reached its monthly AI budget. It resets on the first of the month.', 'mysite' );
}
if ( ! $is_background && $limits['user_daily_requests'] > 0 ) {
$today = mysite_ai_today_usage();
$used = isset( $today['users'][ $user_id ]['requests'] ) ? $today['users'][ $user_id ]['requests'] : 0;
if ( $used >= $limits['user_daily_requests'] ) {
return __( 'You have reached your daily AI limit. It resets at midnight.', 'mysite' );
}
}
return '';
}
/**
* Counts every completed request and its tokens, for the site and for the user.
*/
add_action( 'wp_ai_client_after_generate_result', 'mysite_ai_record_usage' );
function mysite_ai_record_usage( AfterGenerateResultEvent $event ) {
$tokens = $event->getResult()->getTokenUsage()->getTotalTokens();
$user_id = get_current_user_id();
$site = mysite_ai_site_usage();
++$site['requests'];
$site['tokens'] += $tokens;
update_option( 'mysite_ai_site_usage', $site, false );
$today = mysite_ai_today_usage();
if ( ! isset( $today['users'][ $user_id ] ) ) {
$today['users'][ $user_id ] = array(
'requests' => 0,
'tokens' => 0,
);
}
++$today['users'][ $user_id ]['requests'];
$today['users'][ $user_id ]['tokens'] += $tokens;
update_option( 'mysite_ai_today_usage', $today, false );
}
One caveat. The counters are read, changed and written back, so two requests landing in the same instant can under-count by one. That is fine for a budget guard. The provider’s dashboard is the bill.
Step 3: show administrators the numbers
A dashboard widget is enough. This one shows the month’s total against the token cap and today’s requests by user, for anyone with manage_options.
/**
* A dashboard widget for administrators: this month's totals and today's users.
*/
add_action( 'wp_dashboard_setup', 'mysite_ai_dashboard_widget' );
function mysite_ai_dashboard_widget() {
if ( current_user_can( 'manage_options' ) ) {
wp_add_dashboard_widget( 'mysite_ai_usage', __( 'AI usage', 'mysite' ), 'mysite_ai_render_usage' );
}
}
function mysite_ai_render_usage() {
$limits = mysite_ai_limits();
$site = mysite_ai_site_usage();
$today = mysite_ai_today_usage();
printf(
'<p>%s</p>',
esc_html(
sprintf(
/* translators: 1: month, 2: requests, 3: tokens used, 4: token cap */
__( '%1$s so far: %2$s requests, %3$s of %4$s tokens.', 'mysite' ),
$site['period'],
number_format_i18n( $site['requests'] ),
number_format_i18n( $site['tokens'] ),
number_format_i18n( $limits['site_monthly_tokens'] )
)
)
);
if ( empty( $today['users'] ) ) {
echo '<p>' . esc_html__( 'No AI requests yet today.', 'mysite' ) . '</p>';
return;
}
uasort(
$today['users'],
function ( $a, $b ) {
return $b['requests'] <=> $a['requests'];
}
);
echo '<table class="widefat"><thead><tr><th>' . esc_html__( 'User, today', 'mysite' ) . '</th><th>' . esc_html__( 'Requests', 'mysite' ) . '</th><th>' . esc_html__( 'Tokens', 'mysite' ) . '</th></tr></thead><tbody>';
foreach ( $today['users'] as $user_id => $usage ) {
$user = get_userdata( $user_id );
$name = $user ? $user->display_name : __( 'Background (no user)', 'mysite' );
printf(
'<tr><td>%s</td><td>%s</td><td>%s</td></tr>',
esc_html( $name ),
esc_html( number_format_i18n( $usage['requests'] ) ),
esc_html( number_format_i18n( $usage['tokens'] ) )
);
}
echo '</tbody></table>';
}
With all four blocks in the file, an Author who tries an AI feature gets a 503 with “AI features are not enabled for your account”, an Editor gets through and appears in the widget, and an Editor who passes the daily cap is refused with the daily message. Those are the cases this code was tested against on WordPress 7.1, from PHP and over the REST API.
Step 4: set a spending limit at the provider too
Belt and braces. The provider’s billing console will let you set a monthly budget with alerts, and usually a hard limit, on the project the key belongs to. Set it to the number you would be annoyed to pay, not the number you expect to pay. Use one key per site, so a runaway site cannot spend another site’s budget, and revoke the key when you remove a plugin that stored it. The filter above stops prompts that your WordPress code asks for; the provider’s cap stops everything else, including a key that has leaked.
What does this not cover?
- Plugins with their own API key field that call a provider with
wp_remote_post(). The AI Client hooks never see those requests; only the HTTP layer does, throughpre_http_request, which is how the AI plugin’s Connector Approvals experiment works. For those plugins the provider cap is the control. - Key checks on the Connectors screen go through the provider’s availability check, not the prompt builder, so they are neither blocked nor counted.
- REST requests. WordPress sets the current user before a callback runs, so the capability check works for cookie and application password authentication. A route open to the public runs as user 0 and is blocked.
- Multisite. Roles and options are per site, so grants and caps are per site. Super admins pass every capability check, so
use_ainever blocks them; the caps still do. - Turning AI off for a whole site.
define( 'WP_AI_SUPPORT', false )in wp-config.php makeswp_supports_ai()return false, and core then does not even set up the AI provider connectors.
The AI plugin’s team is tracking role controls per feature in issue 736 and usage safeguards in issue 345 on GitHub, and Trac ticket 65340 asks core to warn people about costs before they paste a key. Until those land, drop the file above into mu-plugins, pick your caps, set the provider’s cap, and watch the widget for a week before you decide whether the numbers are right.
