Permissions

Building Role-Based Access Control on Top of permission_callback

⏱ 14 min

The last lesson established that an agent’s access is whatever its underlying WordPress account can do, and that reaching for a built-in role like Editor tends to over-grant. This lesson builds the fix: a dedicated role, or a dedicated set of custom capabilities, created specifically for one agent connection, then checked from permission_callback the same way any other capability would be. This is genuine role-based access control, it’s just built on WordPress’s own role system rather than some separate adapter-native mechanism, because no such separate mechanism exists.

What you'll learn in this lesson
Registering a dedicated custom role
add_role() with a precise, minimal capability list.
Adding a custom capability instead of reusing a built-in one
For access patterns that do not map cleanly onto any existing capability.
Provisioning one account per agent connection
Never share a single agent account across unrelated workflows.
Checking custom capabilities from permission_callback
The exact same current_user_can() pattern, applied to a capability you defined.
Prerequisites

Lesson 3 (permission inheritance), and admin access to create new users and roles on your development site.

Step 1: Register a dedicated role

add_role() creates a new named role with exactly the capabilities you list, nothing inherited from Editor or any other built-in role by default:

// File: my-plugin.php
add_action( 'init', function () {
	if ( ! get_role( 'ai_content_agent' ) ) {
		add_role(
			'ai_content_agent',
			__( 'AI Content Agent', 'my-plugin' ),
			array(
				'read'         => true,
				'edit_posts'   => true,
				'upload_files' => true,
			)
		);
	}
} );

This role can read content, edit posts, and upload files, and nothing else. It cannot publish, cannot delete, cannot manage users or options, cannot install plugins. Compare that to Editor, which bundles roughly twenty capabilities this hypothetical content agent will never use.

Register once, guard against duplication

add_role() runs on every request if left unguarded, which is harmless but wasteful. The get_role() check above is the standard pattern, and matters more once you have several agent-specific roles across a few plugins.

Step 2: Add a custom capability for anything that does not map cleanly

Sometimes an agent’s intended access doesn’t correspond to any existing WordPress capability. Rather than overloading an existing one (which affects humans with that capability too), define your own:

// File: my-plugin.php
add_action( 'admin_init', function () {
	$role = get_role( 'ai_content_agent' );
	if ( $role && ! $role->has_cap( 'my_plugin_summarize_comments' ) ) {
		$role->add_cap( 'my_plugin_summarize_comments' );
	}
} );

Custom capabilities are just strings. WordPress does not validate them against a fixed list, current_user_can( 'my_plugin_summarize_comments' ) works exactly like checking any built-in capability, as long as some role or user actually has it granted.

Step 3: Provision one account per agent connection

Create a real WordPress user for each distinct agent workflow, assigned the role you just built, and generate its Application Password from that account, never from an admin login:

wp-env run cli wp user create ai-content-agent ai-content-agent@example.test \
  --role=ai_content_agent --user_pass="$(openssl rand -base64 24)"

wp-env run cli wp user application-password create ai-content-agent \
  "Claude Desktop - Production - 2026-07"
One account per distinct workflow, not one account for all agents
A read-only reporting agent and a content-drafting agent should never share a login.
A strong, random password on the account itself
The account password is a separate credential from the Application Password, keep it unused and unguessable.
Real, identifiable usernames and emails
ai-content-agent, not agent1, so a future audit of the users list is actually legible.

Step 4: Check the custom role or capability from permission_callback

Once the role and account exist, your ability’s permission_callback checks it exactly the way it would check any built-in capability:

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/summarize-recent-comments',
		array(
			'label'               => __( 'Summarize recent comments', 'my-plugin' ),
			'description'         => __( 'Returns a summary of comments from the last 24 hours.', 'my-plugin' ),
			'permission_callback' => function () {
				return current_user_can( 'my_plugin_summarize_comments' );
			},
			'execute_callback'    => function () {
				// summarization logic
			},
			'meta' => array(
				'annotations' => array( 'readonly' => true ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

Anyone, human or agent, without that specific capability gets denied. Anyone with it, including your dedicated agent account, gets through. There is nothing MCP-specific about this line of code, which is exactly the point: it’s the same permission infrastructure as the rest of WordPress, applied deliberately.

Test it: confirm the role is actually scoped correctly

wp-env run cli wp eval '
$user = get_user_by( "login", "ai-content-agent" );
var_dump( user_can( $user, "edit_posts" ) );       // expect true
var_dump( user_can( $user, "delete_users" ) );     // expect false
var_dump( user_can( $user, "my_plugin_summarize_comments" ) ); // expect false, until Step 2 is applied
'

Confirm the agent account can do exactly what its role grants, and nothing else.

Forgetting to remove the role or capability when a workflow ends

Roles and custom capabilities do not clean themselves up. If an agent workflow is retired, remove its dedicated account, its role (remove_role()), and any custom capabilities added to it. A stale, forgotten agent role with live credentials still attached is exactly the kind of thing an audit should catch, and exactly the kind of thing that is easy to forget once the project that created it has moved on.

Recap

Real role-based access control for AI agents in WordPress means registering a dedicated, minimally-scoped role with add_role(), optionally adding custom capabilities for access patterns that do not map onto existing ones, provisioning a distinct account per agent workflow, and checking all of it through the same current_user_can() pattern every other ability uses. The next lesson goes one level deeper: permission checks that depend on more than just the role, like object ownership and content status.

Resources & further reading

← Permission Inheritance: How WordPress Roles Map to Agent Access Building Permission-Gated Tools →