A scope-restricting system instruction, from the last lesson, gets the assistant to decline questions it shouldn’t answer. It doesn’t, by itself, give the visitor anywhere useful to go next. A visitor who’s been told twice that the widget can’t help with their actual problem is a visitor about to leave frustrated, not one who’s been served well. This lesson builds a real escalation path: detecting when a conversation has gone out of scope or low-confidence, and handing the visitor off to a real contact form or support channel instead of leaving them stuck in a loop with the model.
Lesson 4’s session transcript handling and Lesson 6’s scope-restricting system instruction. A real contact form or support page URL on your site to hand visitors off to.
Step 1: ask the model for a structured signal, not just prose
Parsing free-text replies for phrases like “I’m not sure” is unreliable. A more honest approach is asking the model to return a small structured object alongside its answer, so your PHP code can check a real field instead of guessing from wording:
// File: inc/rest-chat-endpoint.php
$system_instruction =
'You are a helpful assistant for [Site Name], answering visitor questions about ' .
'our products, shipping, and support policies only. Respond with a JSON object ' .
'with exactly two fields: "reply", your answer as plain text, and "needs_human", ' .
'a boolean, true if the question is outside what you can confidently answer from ' .
'this site\'s own information, or if the visitor is asking for something a person ' .
'needs to help with directly, such as an order-specific problem.';
$result = wp_ai_client_prompt( $message )
->using_system_instruction( $system_instruction )
->as_json_response()
->using_max_tokens( 400 )
->generate_text();
as_json_response() makes a well-shaped JSON reply far more likely, it doesn’t
guarantee it. Decode defensively and fall back sensibly if either field is missing:
if ( is_wp_error( $result ) ) {
return new WP_Error( 'ai_call_failed', 'Something went wrong.', array( 'status' => 502 ) );
}
$parsed = json_decode( $result, true );
$reply_text = is_array( $parsed ) && isset( $parsed['reply'] ) ? $parsed['reply'] : $result;
$needs_human = is_array( $parsed ) && ! empty( $parsed['needs_human'] );Step 2: track repeated struggle across a session, not just one turn
A single low-confidence answer isn’t necessarily worth interrupting a conversation for, the model might recover on the next turn with more context. Two or three in the same session is a much stronger signal the visitor needs a real person. The transcript transient from Lesson 4 already tracks the session, extend it with a running count:
// File: inc/rest-chat-endpoint.php
function my_plugin_track_escalation_signal( string $session_id, bool $needs_human ): int {
$key = 'my_plugin_chat_escalate_' . $session_id;
$count = (int) get_transient( $key );
$count = $needs_human ? $count + 1 : 0;
set_transient( $key, $count, 20 * MINUTE_IN_SECONDS );
return $count;
}
// File: inc/rest-chat-endpoint.php
$escalation_count = my_plugin_track_escalation_signal( $session_id, $needs_human );
$should_escalate = $escalation_count >= 2;
my_plugin_append_to_session_transcript( $session_id, 'visitor', $message );
my_plugin_append_to_session_transcript( $session_id, 'assistant', $reply_text );
return rest_ensure_response( array(
'reply' => $reply_text,
'should_escalate' => $should_escalate,
) );
Resetting the counter to zero on any confidently-answered turn matters: this should catch a visitor who’s genuinely stuck across consecutive turns, not penalize someone who asked one hard question in the middle of an otherwise normal conversation.
Step 3: show a real handoff in the widget
// File: assets/js/chat-widget.js
.then( function ( data ) {
appendMessage( 'assistant', data.reply || 'Sorry, something went wrong.' );
if ( data.should_escalate ) {
appendEscalationLink();
}
} );
function appendEscalationLink() {
var note = document.createElement( 'div' );
note.className = 'site-chat-message site-chat-message--assistant site-chat-escalation';
note.innerHTML =
'It looks like this needs a closer look. ' +
'<a href="/contact/" target="_blank" rel="noopener">Contact our support team</a> ' +
'and they\'ll help directly.';
messages.appendChild( note );
messages.scrollTop = messages.scrollHeight;
}
A visitor probing for out-of-scope answers, trying to get the assistant to argue past its system instruction, will naturally rack up low-confidence or declined turns. Routing that pattern to a real human channel after a couple of attempts isn’t just better UX for a genuinely confused visitor, it also caps how long a manipulation attempt gets to keep trying against the model before the conversation is redirected elsewhere.
Step 4: pick a real destination, not a dead end
The link in Step 3 has to go somewhere that actually helps: a contact form, a support email, a live chat handoff if you have one, or a phone number for time-sensitive cases. Whatever the destination is, it should be a real, staffed channel, an escalation link that points to another AI system, or to nothing at all, defeats the entire point of building this path.
Test it: force an escalation and confirm the handoff appears
Ask the widget two or three unrelated or clearly out-of-scope questions in a row, for example requests entirely unrelated to your site’s products or policies. Confirm:
- Each individual reply declines plainly, consistent with Lesson 6’s system instruction
- After the second or third such turn,
should_escalatecomes backtrue - The widget renders the contact link, and clicking it goes to a real, working page
Then ask one clearly answerable, on-topic question and confirm the escalation counter resets, a following unrelated question shouldn’t escalate immediately again.
Recap
Detecting when a public chat widget should hand off to a person means asking the model
for a structured, honest signal, a needs_human boolean alongside its reply, rather than
guessing from wording. Tracking that signal across a session, not just a single turn,
distinguishes a visitor who’s genuinely stuck from one hard question in an otherwise
normal conversation. The payoff is a real link to a real contact channel once that
threshold is hit, turning “the AI can’t help with this” from a dead end into an actual
next step.
Resources & further reading
- PHP AI Client SDK repository, GitHub
- Transients API reference, developer.wordpress.org
- Abilities API reference, developer.wordpress.org