Foundations

Planner/Executor Patterns With Abilities

⏱ 16 min

Course 3’s multi-step workflows relied on the connected client’s own model to plan a sequence of tool calls, Claude or ChatGPT read your ability descriptions and decided the order itself, entirely outside your server. That’s the right default. Sometimes, though, you want planning to happen inside WordPress, as a single ability an outer agent calls once, that itself decides which of several other abilities to run and in what order. That’s a planner/executor pattern, and it’s built from two functions you already know: wp_ai_client_prompt() from the PHP AI Client SDK course, and wp_get_ability( $name )->execute() from the Abilities API.

What you'll learn in this lesson
When planning belongs inside an ability, not just in the client
The case for server-side planning: a fixed, known set of internal abilities and a task that doesn't need a human narrating every step.
Calling wp_ai_client_prompt() from inside an execute_callback
Using structured JSON output to get back a plan you can actually act on.
Executing the plan with wp_get_ability()->execute()
Invoking other abilities directly from PHP, not through another MCP round trip.
The real risks of a self-planning ability
A model deciding its own next step is powerful and needs the same guardrails as any other agent action.
Prerequisites

wp_ai_client_prompt() and as_json_response() from the PHP AI Client SDK course, and wp_get_ability( $name )->execute( $input ) from the Abilities API. This lesson assumes you already have the order-ops/get-order-status, order-ops/open-escalation, order-ops/process-return, and order-ops/reship-order abilities from Lesson 1 registered.

Step 1: Why plan inside WordPress at all

An outer client model is still the one deciding to call your planner ability in the first place, planning doesn’t disappear, it moves one level down. The planner pattern is worth it when a task has a known, bounded set of internal abilities that almost always need to run together in some order, and you’d rather encapsulate that decision-making behind one ability than expose four or five raw abilities and hope every client’s model sequences them the same sensible way every time. A “resolve this order issue” planner is a good candidate: given a description of the problem, it decides whether that means checking status, processing a return, reshipping, or opening an escalation for a human, and calls the right one.

Step 2: The planner ability, using wp_ai_client_prompt() to decide

// File: order-ops/class-plan-resolution.php
wp_register_ability( 'order-ops/plan-resolution', array(
	'label'       => 'Plan and execute an order issue resolution',
	'description' => 'Given a plain-language description of a customer\'s order issue and '
		. 'the order ID, decides which internal action is appropriate (check status, open an '
		. 'escalation, process a return, or reship) and executes it. Returns the action taken '
		. 'and its result.',
	'input_schema' => array(
		'type'       => 'object',
		'properties' => array(
			'order_id'    => array( 'type' => 'integer' ),
			'description' => array( 'type' => 'string' ),
		),
		'required' => array( 'order_id', 'description' ),
	),
	'execute_callback'    => 'order_ops_plan_resolution',
	'permission_callback' => 'order_ops_can_resolve',
	'meta' => array( 'mcp' => array( 'public' => true ) ),
) );

function order_ops_plan_resolution( $input ) {
	$schema = array(
		'type'       => 'object',
		'properties' => array(
			'action' => array(
				'type' => 'string',
				'enum' => array( 'check_status', 'open_escalation', 'process_return', 'reship' ),
			),
			'reasoning' => array( 'type' => 'string' ),
		),
		'required' => array( 'action', 'reasoning' ),
	);

	$raw = wp_ai_client_prompt()
		->with_text( 'Order ID: ' . $input['order_id'] . '. Customer issue: ' . $input['description'] )
		->using_system_instruction(
			'You decide which single action resolves a customer order issue. Choose ' .
			'check_status for questions with no clear problem, open_escalation for anything ' .
			'ambiguous or high-value, process_return only for explicit return requests, and ' .
			'reship only for confirmed lost-or-damaged shipments. When unsure, choose ' .
			'open_escalation, never guess toward a destructive action.'
		)
		->as_json_response( $schema )
		->generate_text();

	$plan = json_decode( $raw, true );

	if ( ! is_array( $plan ) || empty( $plan['action'] ) ) {
		return new WP_Error( 'planning_failed', 'Could not determine a resolution action.' );
	}

	return order_ops_execute_plan( $plan['action'], $input['order_id'], $plan['reasoning'] );
}

Step 3: Executing the plan with wp_get_ability()

The planner never calls the underlying logic directly. It looks the ability up by name and executes it exactly the way an external MCP client would, just from inside PHP:

// File: order-ops/class-plan-resolution.php (continued)
function order_ops_execute_plan( $action, $order_id, $reasoning ) {
	$ability_map = array(
		'check_status'    => 'order-ops/get-order-status',
		'open_escalation' => 'order-ops/open-escalation',
		'process_return'  => 'order-ops/process-return',
		'reship'          => 'order-ops/reship-order',
	);

	$ability_name = $ability_map[ $action ] ?? null;
	$ability      = $ability_name ? wp_get_ability( $ability_name ) : null;

	if ( ! $ability ) {
		return new WP_Error( 'unknown_action', 'Planner chose an action with no matching ability.' );
	}

	$result = $ability->execute( array( 'order_id' => $order_id ) );

	return array(
		'action_taken' => $action,
		'reasoning'    => $reasoning,
		'result'       => $result,
	);
}

wp_get_ability() returns null if the name doesn’t exist, which is why the lookup is checked before calling execute(), a planner that hallucinates an ability name should fail loudly, not fatal.

What actually happens on one call to plan-resolution
1
An outer client calls order-ops/plan-resolution once
With an order ID and a plain description of the problem.
2
The ability calls wp_ai_client_prompt() internally
Asking a model to choose one of four known actions, with a schema constraining the answer to that fixed set.
3
The ability calls wp_get_ability(...)->execute() on the chosen action
The same function any PHP code would use to invoke another ability directly, no second MCP round trip.
4
One structured result comes back
Including which action was taken and why, so the outer client can relay a clear explanation.

Step 4: The planner still needs the same guardrails as any agent

A planner ability that can choose process_return or reship is, functionally, an agent making a decision with real consequences. The confirmation-gate pattern from Course 3 still applies here, don’t let the planner call a destructive underlying ability directly. Instead, route process_return and reship through the propose/confirm pattern from Course 3, so the planner’s output is a proposal a human still approves, not an executed action:

// File: order-ops/class-plan-resolution.php (continued)
$ability_map = array(
	'check_status'    => 'order-ops/get-order-status',       // safe to run directly
	'open_escalation' => 'order-ops/open-escalation',         // safe to run directly
	'process_return'  => 'order-ops/propose-process-return',  // proposes only, needs confirm
	'reship'          => 'order-ops/propose-reship-order',    // proposes only, needs confirm
);

Constraining the model’s choice to a fixed enum in the schema, as Step 2 does, is what keeps this pattern safe enough to build on: the model is picking among four known, pre-vetted actions, not writing arbitrary PHP or choosing an ability name freeform.

Test it

# File: terminal
wp-env run cli wp eval '
$result = wp_get_ability( "order-ops/plan-resolution" )->execute( array(
    "order_id"    => 4821,
    "description" => "Customer says the package never arrived, tracking shows delivered.",
) );
var_dump( $result );
'

Confirm the action_taken matches what a human reading that description would expect (likely open_escalation given the ambiguity), and that the schema’s enum constraint actually rejected any attempt to return an action outside the four listed, try a deliberately vague description and confirm the model doesn’t invent a fifth action name.

A planner that calls destructive abilities directly is a bigger blast radius, not a smaller one

It’s tempting to let the planner call order-ops/process-return directly since it’s “just following the model’s own reasoning.” That reasoning runs on every call, with no human in the loop reading it first. Route anything destructive through a propose/confirm pair as shown in Step 4, the planner should decide what to propose, a human should still decide whether it happens.

Recap

A planner ability is an execute_callback that calls wp_ai_client_prompt() with a schema constrained to a fixed set of known actions, then calls wp_get_ability( $name )->execute() on whichever action the model chose. It moves planning one level down, from the outer client’s model into your own PHP, which is useful for a bounded, well-known task, but it is still a model making a decision, so destructive outcomes still need the confirmation pattern from Course 3 sitting between the plan and anything irreversible.

Resources & further reading

← Why Multiple Specialized Agents Beat One Generalist Agent Handoffs Between Specialized Roles →