Human Handover

Building the Live Chat Takeover

⏱ 19 min

Course 11’s escalation lesson hands a stuck visitor off to a contact form, a real and useful pattern, but a one-way door. The visitor leaves the chat entirely and starts over somewhere else. A genuine takeover is different: a support agent joins the exact same conversation the visitor is already in, mid-session, and the visitor keeps typing into the same widget, never noticing they’re now talking to a person instead of a model. This lesson builds that, using WordPress’s own Heartbeat API on the admin side and simple REST polling on the front end.

What you'll learn in this lesson
A control flag on the session
ai or human, stored right alongside Course 11's transcript, deciding who answers next.
Tracking active sessions for the admin view
A lightweight index of live session IDs an agent can actually see.
Using the Heartbeat API in wp-admin
heartbeat_received and heartbeat-tick refreshing the live session list without a custom polling loop.
An admin reply endpoint that flips control to human
The moment an agent sends a message, the AI stops answering that session.
Short-polling on the widget so visitors see the agent's replies
The front end's honest, simple counterpart to Heartbeat.
Prerequisites

Course 11’s session transcript transient and REST endpoint. A logged-in WordPress user with manage_options (or a dedicated capability) to act as the support agent.

Step 1: add a control flag to the session state

Course 11’s transcript transient held an array of turns. This lesson stores it inside a slightly larger structure that also tracks who’s currently answering:

// File: inc/rest-chat-endpoint.php
function my_plugin_get_session_state( string $session_id ): array {
	$state = get_transient( 'my_plugin_chat_' . $session_id );
	if ( ! is_array( $state ) ) {
		$state = array( 'control' => 'ai', 'transcript' => array() );
	}
	return $state;
}

function my_plugin_save_session_state( string $session_id, array $state ) {
	set_transient( 'my_plugin_chat_' . $session_id, $state, 20 * MINUTE_IN_SECONDS );
}

control starts as 'ai' for every new session. The chat callback checks it before ever calling wp_ai_client_prompt(): if a human already has control, the visitor’s message gets appended to the transcript and the function returns without generating a reply at all, since a person is now responsible for answering.

// File: inc/rest-chat-endpoint.php
$state = my_plugin_get_session_state( $session_id );
$state['transcript'][] = array( 'role' => 'visitor', 'text' => $message );

if ( 'human' === $state['control'] ) {
	my_plugin_save_session_state( $session_id, $state );
	return rest_ensure_response( array( 'reply' => null, 'awaiting_human' => true ) );
}
// ...otherwise, generate a reply as before and append it to $state['transcript'].

Step 2: track which sessions are actually active

An agent needs a list of live conversations to choose from. Enumerating transient keys directly isn’t reliable across all object cache backends, so keep a small explicit index instead, updated on every incoming message:

// File: inc/rest-chat-endpoint.php
function my_plugin_mark_session_active( string $session_id ) {
	$active = get_transient( 'my_plugin_active_sessions' );
	$active = is_array( $active ) ? $active : array();

	$active[ $session_id ] = time();

	// Drop anything untouched for 20 minutes, matching the session transient's own expiry.
	$active = array_filter( $active, function ( $ts ) {
		return $ts > time() - 20 * MINUTE_IN_SECONDS;
	} );

	set_transient( 'my_plugin_active_sessions', $active, 20 * MINUTE_IN_SECONDS );
}

Call this once per incoming visitor message, right alongside my_plugin_save_session_state().

Step 3: refresh the admin session list with the Heartbeat API

WordPress already loads Heartbeat in wp-admin, the same mechanism behind post-editing autosave and “this post is being edited by” locks, so the admin session list can piggyback on it instead of writing a custom polling loop:

// File: inc/admin-chat-panel.php
add_filter( 'heartbeat_received', function ( array $response, array $data ) {
	if ( empty( $data['my_plugin_watch_sessions'] ) ) {
		return $response;
	}

	$active = get_transient( 'my_plugin_active_sessions' );
	$response['my_plugin_active_sessions'] = is_array( $active ) ? array_keys( $active ) : array();

	return $response;
}, 10, 2 );
// File: assets/js/admin-chat-panel.js
jQuery( document ).on( 'heartbeat-send', function ( event, data ) {
	data.my_plugin_watch_sessions = true;
} );

jQuery( document ).on( 'heartbeat-tick', function ( event, data ) {
	if ( data.my_plugin_active_sessions ) {
		renderSessionList( data.my_plugin_active_sessions );
	}
} );

Heartbeat’s default tick runs every 15 to 60 seconds depending on screen activity, close enough for “which conversations are currently live,” a support agent picking which session to join. It is not fast enough, on its own, for the message-by-message exchange inside a session an agent has already joined, that’s Step 5’s job.

Heartbeat isn't loaded on the front end by default

Heartbeat ships enqueued automatically in wp-admin, which is exactly why Step 3 uses it there for free. The public chat widget runs on the front end, where Heartbeat isn’t loaded unless a plugin explicitly enqueues it and adjusts the interval with the heartbeat_settings filter, extra weight for a single feature. Step 5 uses plain REST polling on the widget side instead, the same tool Course 11 already built the endpoint with.

Step 4: an admin reply that flips control to human

// File: inc/admin-chat-panel.php
add_action( 'rest_api_init', function () {
	register_rest_route( 'my-plugin/v1', '/admin/reply', array(
		'methods'             => 'POST',
		'callback'            => 'my_plugin_handle_admin_reply',
		'permission_callback' => function () {
			return current_user_can( 'manage_options' );
		},
	) );
} );

function my_plugin_handle_admin_reply( WP_REST_Request $request ) {
	$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
	$reply      = sanitize_textarea_field( $request->get_param( 'reply' ) );

	$state             = my_plugin_get_session_state( $session_id );
	$state['control']  = 'human';
	$state['transcript'][] = array( 'role' => 'agent', 'text' => $reply );

	my_plugin_save_session_state( $session_id, $state );

	return rest_ensure_response( array( 'ok' => true ) );
}

Note the permission_callback here checks a real, logged-in WordPress user’s capability, current_user_can( 'manage_options' ), the opposite of the anonymous nonce check the public /chat route uses. This endpoint is admin-only by design, no anonymous visitor should ever be able to flip a session’s control flag.

Step 5: the widget short-polls while waiting for a human

Once the widget receives awaiting_human: true, it starts polling the existing /chat route on a short interval instead of waiting for the visitor to send another message, since the next new content is the agent’s reply, not anything the visitor typed:

// File: assets/js/chat-widget.js
function pollForHumanReply( sessionId ) {
	var poll = setInterval( function () {
		fetch( siteChatWidget.restUrl + '/status?session_id=' + sessionId, {
			headers: { 'X-WP-Nonce': siteChatWidget.nonce },
		} )
			.then( function ( r ) { return r.json(); } )
			.then( function ( data ) {
				if ( data.new_messages && data.new_messages.length ) {
					data.new_messages.forEach( function ( m ) {
						appendMessage( m.role === 'agent' ? 'assistant' : m.role, m.text );
					} );
					clearInterval( poll );
				}
			} );
	}, 4000 );
}

A four-second interval is frequent enough to feel responsive without hammering the server, the same trade-off Heartbeat’s own 15-to-60-second default makes for the lower-urgency admin session list.

Test it: take over a live session and confirm the widget sees it

Open the widget as a visitor and send a message. In a separate logged-in admin session, confirm the session appears in the Heartbeat-refreshed list within about a minute, then call /admin/reply for that session ID. Back in the visitor’s browser, within the next poll cycle, confirm the agent’s reply appears in the widget and that any further messages the visitor sends stop triggering wp_ai_client_prompt(), checked by watching your site’s AI provider logs for a gap in calls during the human-controlled portion of the conversation.

Recap

A control flag stored alongside the session transcript, 'ai' or 'human', is the entire mechanism deciding who answers next. An active-sessions transient gives an agent something to choose from, refreshed for free via the Heartbeat API already running in wp-admin. A capability-checked admin reply endpoint flips the flag and appends the agent’s message, and the widget’s own short REST polling, the honest front-end counterpart to Heartbeat, is what lets a visitor see that reply without sending another message first.

Resources & further reading

← Answering With Real URLs Multilingual Support for the Chatbot →