Coordination

Agent Handoffs Between Specialized Roles

⏱ 15 min

Lesson 1 split the support and fulfillment roles into two separate MCP servers on purpose, different audiences, different trust levels, different tool lists. That split creates a new problem: the support agent’s order-ops/open-escalation call knows things the fulfillment agent needs, what the customer actually said, which order, why it looks unusual, but the two agents don’t share a conversation, a process, or even necessarily a request. This lesson is about the handoff mechanism that connects them: post meta, written by one agent’s ability and read by another’s.

What you'll learn in this lesson
Why two separate servers need an explicit handoff store
No shared memory exists between a customer-facing MCP connection and an internal one.
Using post meta as a structured handoff record
A durable, queryable place to put context that outlives a single conversation.
Writing a clean handoff from the support agent
Capturing exactly what the fulfillment agent will need, no more, no less.
Reading and claiming a handoff from the fulfillment agent
Avoiding two staff members, or two agent calls, working the same handoff at once.
Prerequisites

The two-server split and the four order-ops abilities from Lesson 1. This lesson doesn’t require the planner from Lesson 2, though it composes cleanly with it, a planner could just as easily be the thing writing the handoff record.

Step 1: Why “just pass it in the conversation” doesn’t work here

If the support agent and the fulfillment agent were the same conversation with the same model, handoff would be trivial, the context is just still in the transcript. They aren’t. The support server and the fulfillment server are reached by different clients, likely at different times, possibly by different people entirely, a customer talking to a chat widget, then a staff member later opening their own internal tool. Nothing links those two interactions except whatever your own code deliberately records. Post meta, attached to the order post both agents already reference by ID, is that link.

Step 2: Writing the handoff from the support side

order-ops/open-escalation already existed as an ability in Lesson 1’s tool list. Here’s what it actually does: it doesn’t just log a note, it writes a structured handoff record the fulfillment agent can act on later.

// File: order-ops/class-open-escalation.php
wp_register_ability( 'order-ops/open-escalation', array(
	'label'       => 'Open an escalation for internal fulfillment review',
	'description' => 'Records a customer-reported issue on an order for internal fulfillment '
		. 'staff to review and act on. Does not itself process a return or reshipment.',
	'input_schema' => array(
		'type'       => 'object',
		'properties' => array(
			'order_id' => array( 'type' => 'integer' ),
			'summary'  => array( 'type' => 'string' ),
		),
		'required' => array( 'order_id', 'summary' ),
	),
	'execute_callback'    => 'order_ops_open_escalation',
	'permission_callback' => '__return_true',
	'meta' => array( 'mcp' => array( 'public' => true ) ),
) );

function order_ops_open_escalation( $input ) {
	$order = get_post( $input['order_id'] );
	if ( ! $order ) {
		return new WP_Error( 'not_found', 'No order with that ID exists.' );
	}

	$handoff = array(
		'state'        => 'pending',       // pending -> claimed -> resolved
		'summary'      => sanitize_textarea_field( $input['summary'] ),
		'from_agent'   => 'support',
		'claimed_by'   => null,
		'opened_at'    => current_time( 'mysql', true ),
	);

	update_post_meta( $order->ID, '_order_ops_handoff', $handoff );

	return array(
		'order_id'        => $order->ID,
		'escalation_state' => 'pending',
	);
}

Storing the handoff as a single serialized array under one meta key, rather than several loose keys, keeps the state transitions (pending, claimed, resolved) atomic from your own code’s point of view, one update_post_meta() call always replaces the whole record.

Step 3: Reading and claiming the handoff from the fulfillment side

The fulfillment agent needs two things: a way to see what’s pending, and a way to claim one without a second staff member (or a second agent call) picking up the same escalation at the same time.

// File: order-ops/class-fulfillment-handoffs.php
wp_register_ability( 'order-ops/list-pending-escalations', array(
	'label'       => 'List escalations awaiting fulfillment review',
	'description' => 'Returns orders with a pending handoff from the support agent, '
		. 'including the customer-reported summary, for fulfillment staff to triage.',
	'input_schema'        => array( 'type' => 'object', 'properties' => new stdClass() ),
	'execute_callback'    => 'order_ops_list_pending_escalations',
	'permission_callback' => 'order_ops_can_resolve',
	'meta' => array( 'mcp' => array( 'public' => true ) ),
) );

wp_register_ability( 'order-ops/claim-escalation', array(
	'label'       => 'Claim a pending escalation for handling',
	'description' => 'Marks a pending escalation as claimed by the current user, so no one '
		. 'else starts working the same order issue at the same time.',
	'input_schema' => array(
		'type'       => 'object',
		'properties' => array( 'order_id' => array( 'type' => 'integer' ) ),
		'required'   => array( 'order_id' ),
	),
	'execute_callback'    => 'order_ops_claim_escalation',
	'permission_callback' => 'order_ops_can_resolve',
	'meta' => array( 'mcp' => array( 'public' => true ) ),
) );

function order_ops_claim_escalation( $input ) {
	$handoff = get_post_meta( $input['order_id'], '_order_ops_handoff', true );

	if ( empty( $handoff ) || 'pending' !== $handoff['state'] ) {
		return new WP_Error(
			'not_claimable',
			'This escalation is not pending, it may already be claimed or resolved.'
		);
	}

	$handoff['state']      = 'claimed';
	$handoff['claimed_by'] = get_current_user_id();

	update_post_meta( $input['order_id'], '_order_ops_handoff', $handoff );

	return array( 'order_id' => $input['order_id'], 'escalation_state' => 'claimed' );
}

Checking 'pending' !== $handoff['state'] before claiming is what actually prevents a race: whichever call reads pending and writes claimed first wins, and the second call, running even a moment later, reads claimed and fails cleanly instead of silently double-processing the same escalation.

The full handoff lifecycle
1
Support agent opens an escalation
order-ops/open-escalation writes a pending handoff record keyed to the order post.
2
Fulfillment agent lists pending work
order-ops/list-pending-escalations reads every order with a pending handoff.
3
Fulfillment agent claims one
order-ops/claim-escalation flips the state to claimed, guarding against a duplicate claim.
4
Fulfillment agent resolves it
Using order-ops/process-return or order-ops/reship-order from Lesson 1, then updates the handoff state to resolved.

Test it

# File: terminal
wp-env run cli wp eval '
wp_get_ability( "order-ops/open-escalation" )->execute( array(
    "order_id" => 4821,
    "summary"  => "Customer says package shows delivered but never arrived.",
) );

var_dump( wp_get_ability( "order-ops/list-pending-escalations" )->execute( array() ) );

wp_get_ability( "order-ops/claim-escalation" )->execute( array( "order_id" => 4821 ) );

// A second claim attempt on the same order should now fail:
var_dump( wp_get_ability( "order-ops/claim-escalation" )->execute( array( "order_id" => 4821 ) ) );
'

The first claim should succeed and return escalation_state: claimed. The second, identical call should return the not_claimable WP_Error, confirming the state check is actually doing its job rather than just being decorative.

Post meta has no built-in locking, your state check is the lock

update_post_meta() doesn’t prevent a race between two near-simultaneous reads of the same key, two calls to claim-escalation a few milliseconds apart can both read pending before either writes claimed. On most sites, with human-driven or moderately-paced agent traffic, this is rare enough to be an acceptable risk. If your fulfillment agent runs at genuinely high concurrency, reach for wp_cache_add() with a short-lived lock key, or a dedicated custom table with a unique constraint, instead of trusting a plain meta read-then-write to be atomic.

Recap

Two agents on two separate MCP servers share no memory of each other’s conversations, so handing context between them requires a deliberate, durable store. Post meta, keyed to the same order post both roles already reference, works well for this: one agent writes a structured record with an explicit state field, the other reads it, claims it by flipping that state, and resolves it using abilities already scoped to its own server. The state field itself, not just the presence of the record, is what prevents two workers from processing the same handoff twice.

Resources & further reading

← Planner/Executor Patterns With Abilities Task Queues for Long-Running Agent Jobs →