Voice Commands

Voice Commands That Map to MCP Abilities

⏱ 18 min

This is the lesson the corrected premise in Lesson 1 was building toward: actually letting a spoken sentence make something happen on a WordPress site. “Create a draft post about our new pricing” should become a real ability call, not a canned reply. But “delete the post titled old announcement” is exactly the kind of destructive action Course 3’s confirmation-gate pattern exists for, and voice does not get an exception from that pattern. It gets a voice-shaped version of it: the site asks out loud, and you have to say “yes” out loud back.

What you'll learn in this lesson
Turning a transcript into a specific ability call
A small, honest intent parser, not a general-purpose command interpreter.
Routing safe, non-destructive commands straight through
Creating a draft is a reasonable direct action, no gate needed.
Applying Course 3's propose-then-confirm pattern to voice
A spoken proposal, and a second spoken "yes" as the confirmation turn.
Where this deliberately stays narrow
A fixed, known set of commands, not open-ended natural language control of the whole site.
Prerequisites

Course 3’s Designing Confirmation-Gated (Human-in-the-Loop) Agent Actions is required background, this lesson applies that exact pattern rather than re-explaining it. Also this course’s Lessons 2 and 4 for the transcript in and spoken reply out.

Step 1: Classify the transcript into a known intent

Resist the pull toward a fully open-ended natural-language command interpreter. A small, explicit set of recognized intents is both safer and genuinely more reliable than trying to parse arbitrary spoken English into arbitrary actions:

// File: my-plugin/class-voice-commands.php
function my_plugin_classify_voice_command( string $transcript ): array {
	$prompt = <<<PROMPT
Classify this spoken command into exactly one of these intents:
"create_draft", "delete_post", "unrecognized".

Respond as JSON only, no other text: {"intent": "...", "topic": "...", "post_title": "..."}

Use "topic" only for create_draft, extracting what the draft should be about.
Use "post_title" only for delete_post, extracting the title mentioned.
If the command doesn't clearly match either, use "unrecognized".

Command: "{$transcript}"
PROMPT;

	$response = wp_ai_client_prompt( $prompt )->generate_text();
	$parsed   = json_decode( $response, true );

	return is_array( $parsed ) ? $parsed : array( 'intent' => 'unrecognized' );
}

This is the same wp_ai_client_prompt()->generate_text() call this whole track has used from Course 1 onward, just doing intent classification instead of open-ended generation, structured, constrained JSON output for a narrow, known set of outcomes.

Step 2: Route by intent, safe actions go straight through

// File: my-plugin/class-voice-commands.php (continued)
function my_plugin_handle_voice_command( string $transcript, int $user_id ): array {
	$parsed = my_plugin_classify_voice_command( $transcript );

	switch ( $parsed['intent'] ) {
		case 'create_draft':
			return my_plugin_voice_create_draft( $parsed['topic'] ?? '' );

		case 'delete_post':
			return my_plugin_voice_propose_delete( $parsed['post_title'] ?? '', $user_id );

		default:
			return array(
				'spoken_reply' => "I didn't recognize that as a command I can run.",
			);
	}
}

function my_plugin_voice_create_draft( string $topic ): array {
	if ( ! current_user_can( 'edit_posts' ) ) {
		return array( 'spoken_reply' => "You don't have permission to create drafts." );
	}

	$content  = wp_ai_client_prompt( "Write a short draft blog post about: {$topic}" )->generate_text();
	$post_id  = wp_insert_post( array(
		'post_title'   => $topic,
		'post_content' => $content,
		'post_status'  => 'draft',
	) );

	return array(
		'spoken_reply' => "I've created a draft titled {$topic}. It's saved, not published.",
		'post_id'      => $post_id,
	);
}

Creating a draft is exactly the kind of action Course 3’s earlier lesson already established as safe by design, a draft is reversible and never publicly visible, so it runs directly from a single spoken command with no confirmation turn.

Step 3: Destructive intents get the real confirmation gate

// File: my-plugin/class-voice-commands.php (continued)
function my_plugin_voice_propose_delete( string $title, int $user_id ): array {
	$posts = get_posts( array( 'title' => $title, 'post_status' => 'any', 'numberposts' => 1 ) );

	if ( empty( $posts ) ) {
		return array( 'spoken_reply' => "I couldn't find a post titled {$title}." );
	}

	$post  = $posts[0];
	$token = wp_generate_password( 24, false );

	set_transient(
		'voice_delete_token_' . $token,
		array( 'post_id' => $post->ID, 'user_id' => $user_id ),
		2 * MINUTE_IN_SECONDS
	);

	return array(
		'spoken_reply'       => "This will permanently delete the post titled {$post->post_title}. Say confirm delete to proceed, or say cancel.",
		'confirmation_token' => $token,
	);
}

function my_plugin_voice_confirm_delete( string $transcript, string $pending_token ): array {
	$affirmative = (bool) preg_match( '/\b(confirm|yes|do it)\b/i', $transcript );

	if ( ! $affirmative ) {
		delete_transient( 'voice_delete_token_' . $pending_token );
		return array( 'spoken_reply' => 'Cancelled. Nothing was deleted.' );
	}

	$pending = get_transient( 'voice_delete_token_' . $pending_token );

	if ( ! $pending ) {
		return array( 'spoken_reply' => 'That confirmation has expired. Please say the command again.' );
	}

	delete_transient( 'voice_delete_token_' . $pending_token ); // one-time use

	$result = wp_delete_post( $pending['post_id'], true );

	return array(
		'spoken_reply' => $result ? 'Done. The post has been deleted.' : 'Something went wrong, the post was not deleted.',
	);
}

This is Course 3’s exact propose-then-confirm shape, a token issued at proposal time, bound to the user who requested it, single-use, and expiring quickly. The only thing voice adds is what carries the confirmation: instead of a typed “yes, delete it,” it’s a second recognized utterance containing “confirm,” “yes,” or “do it,” matched against the transcript after the widget speaks the proposal back and starts listening again for exactly that reply.

A short expiry window matters even more for voice

Course 3’s original pattern used a five-minute window for a typed confirmation. Two minutes is tighter here deliberately: a voice interface tends to move faster than a typed conversation, and a stale, forgotten confirmation prompt sitting open in the background for minutes is a bigger real risk when the “confirm” trigger is a common word like “yes” that could appear in unrelated speech picked up by an open microphone.

Step 4: The front-end listen-propose-confirm loop

// File: assets/js/voice-commands.js
var pendingToken = null;

recognition.addEventListener( 'result', function ( event ) {
	var transcript = event.results[ 0 ][ 0 ].transcript;

	var endpoint = pendingToken ? '/confirm-voice-command' : '/voice-command';
	var body     = pendingToken
		? { transcript: transcript, confirmation_token: pendingToken }
		: { transcript: transcript };

	fetch( window.siteVoiceCommands.restUrlBase + endpoint, {
		method: 'POST',
		headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': window.siteVoiceCommands.nonce },
		body: JSON.stringify( body ),
	} )
		.then( function ( r ) { return r.json(); } )
		.then( function ( data ) {
			pendingToken = data.confirmation_token || null;
			speakReply( data.spoken_reply ); // Lesson 5's function
		} );
} );

pendingToken is what decides, on the very next thing you say, whether the server treats it as a new command or as the confirmation for the one still open.

Test it: the full loop, including the cancel path

You: "Delete the post titled Old Announcement."
Widget speaks: "This will permanently delete the post titled Old Announcement.
                Say confirm delete to proceed, or say cancel."
You: "Cancel."
Widget speaks: "Cancelled. Nothing was deleted."

Confirm in wp-admin that the post is untouched. Then run it again and actually say “confirm delete,” and confirm the post is gone. Then, separately, wait past the two-minute window before responding and confirm you get “that confirmation has expired,” not a delayed deletion.

Recap

A transcribed voice command is classified into one of a small, known set of intents with generate_text(), never parsed as open-ended natural language control of the whole site. Safe, reversible actions like creating a draft run directly. Anything destructive gets Course 3’s exact confirmation gate, a single-use, user-bound, short-lived token, with voice’s only real addition being that the proposal and the confirmation are both spoken, and the “yes” that unlocks execution has to be an actual second utterance, not an assumption baked into the first one.

Resources & further reading

← Voice Search Over Your Content Course Recap: A Working, Honest Voice Interface for WordPress →