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.
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 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
- Heartbeat API, developer.wordpress.org
- Human Escalation Patterns, Course 11
- register_rest_route() function reference, developer.wordpress.org