Everything built so far answers questions well. None of it does anything for the business running the chatbot beyond that. A visitor asking detailed product questions, comparing options, asking about bulk pricing, is showing real purchase intent, and a support chatbot that lets that moment pass without ever asking for a way to follow up is leaving a genuinely capturable lead on the table. This lesson adds that: a structured intent signal from the model, and an inline form the widget shows at exactly the right moment.
Course 11’s as_json_response() pattern from the escalation lesson, and Course 11’s
session transcript transient.
Step 1: extend the structured reply with an intent signal
Course 11’s escalation lesson already asks the model for a needs_human boolean
alongside its reply. A purchase_intent boolean is the same pattern, one more field in
the same JSON object, not a second model call:
// File: inc/rest-chat-endpoint.php
$system_instruction =
'You are a helpful assistant for [Site Name]. Respond with a JSON object with three ' .
'fields: "reply", your answer as plain text, "needs_human", a boolean as before, ' .
'and "purchase_intent", a boolean, true only if the visitor is actively comparing, ' .
'pricing, or asking questions consistent with being close to a purchase decision, ' .
'not for a casual or purely informational question.';
$result = wp_ai_client_prompt( $message )
->using_system_instruction( $system_instruction )
->as_json_response()
->using_max_tokens( 400 )
->generate_text();
Step 2: offer the form once per session, not once per message
A visitor who shows purchase intent on turn two shouldn’t see the same form again on turn five, that reads as nagging rather than helpful. A session-level flag caps it at once:
// File: inc/rest-chat-endpoint.php
function my_plugin_should_offer_lead_form( string $session_id, bool $purchase_intent ): bool {
if ( ! $purchase_intent ) {
return false;
}
$key = 'my_plugin_lead_offered_' . $session_id;
if ( get_transient( $key ) ) {
return false;
}
set_transient( $key, true, 20 * MINUTE_IN_SECONDS );
return true;
}
// File: inc/rest-chat-endpoint.php
$parsed = json_decode( $result, true );
$reply_text = is_array( $parsed ) && isset( $parsed['reply'] ) ? $parsed['reply'] : $result;
$purchase_intent = is_array( $parsed ) && ! empty( $parsed['purchase_intent'] );
$offer_lead_form = my_plugin_should_offer_lead_form( $session_id, $purchase_intent );
return rest_ensure_response( array(
'reply' => $reply_text,
'offer_lead_form' => $offer_lead_form,
) );
Step 3: render the inline form without interrupting the reply
The form appears as its own element after the reply, the conversation keeps working identically whether the visitor fills it in, ignores it, or dismisses it:
// File: assets/js/chat-widget.js
.then( function ( data ) {
appendMessage( 'assistant', data.reply || 'Sorry, something went wrong.' );
if ( data.offer_lead_form ) {
appendLeadForm();
}
} );
function appendLeadForm() {
var form = document.createElement( 'form' );
form.className = 'site-chat-lead-form';
form.innerHTML =
'<p>Want a team member to follow up with details?</p>' +
'<input type="email" name="email" placeholder="you@example.com" required>' +
'<button type="submit">Send me details</button>';
form.addEventListener( 'submit', function ( e ) {
e.preventDefault();
submitLead( form.email.value, getOrCreateSessionId() );
form.replaceWith( document.createTextNode( 'Thanks, we\'ll be in touch.' ) );
} );
messages.appendChild( form );
messages.scrollTop = messages.scrollHeight;
}
Step 4: store the lead in a real, queryable post type
A dedicated custom post type, registered privately with no public front-end archive, gives leads a real wp-admin list, meta fields, and search, without building a one-off table just to hold rows a site owner will actually want to browse:
// File: my-plugin/class-lead-capture.php
add_action( 'init', function () {
register_post_type( 'chat_lead', array(
'label' => 'Chat Leads',
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'supports' => array( 'title' ),
) );
} );
add_action( 'rest_api_init', function () {
register_rest_route( 'my-plugin/v1', '/lead', array(
'methods' => 'POST',
'callback' => 'my_plugin_handle_lead_submission',
'permission_callback' => 'my_plugin_chat_permission_check', // Reused from Course 11.
) );
} );
function my_plugin_handle_lead_submission( WP_REST_Request $request ) {
$email = sanitize_email( $request->get_param( 'email' ) );
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
if ( ! is_email( $email ) ) {
return new WP_Error( 'invalid_email', 'A valid email is required.', array( 'status' => 400 ) );
}
$state = my_plugin_get_session_state( $session_id );
$transcript = wp_list_pluck( $state['transcript'], 'text' );
$lead_id = wp_insert_post( array(
'post_type' => 'chat_lead',
'post_title' => $email . ' (' . gmdate( 'Y-m-d H:i' ) . ')',
'post_status' => 'private',
) );
update_post_meta( $lead_id, 'email', $email );
update_post_meta( $lead_id, 'session_id', $session_id );
update_post_meta( $lead_id, 'conversation_snippet', implode( "\n", array_slice( $transcript, -6 ) ) );
return rest_ensure_response( array( 'ok' => true ) );
}
The last six turns of the transcript, saved as conversation_snippet, give whoever
follows up on the lead real context, what the visitor was actually asking about, without
storing the entire conversation history a second time.
/lead reuses my_plugin_chat_permission_check(), the nonce check from Course 11,
rather than being left open. Without it, this endpoint would accept an email submission
from any script anywhere on the internet, not just your own widget, a spam vector with no
upside.
Test it: trigger purchase intent and confirm a lead gets stored
Ask the widget something like “what’s the price difference between the standard and pro plans,” a reasonable purchase-intent question, and confirm the inline form appears. Submit it, then check:
wp-env run cli wp eval '
$leads = get_posts( array( "post_type" => "chat_lead", "posts_per_page" => 5 ) );
foreach ( $leads as $lead ) {
echo get_post_meta( $lead->ID, "email", true ) . "\n";
}
'
You should see the submitted email, and in wp-admin under Chat Leads, the stored conversation snippet next to it.
Recap
Detecting purchase intent is the same structured-JSON pattern Course 11 already used for
needs_human, one more boolean in the same model call. A session-level flag keeps the
form offer to once per conversation, and a dedicated chat_lead custom post type gives a
site owner a real, browsable list in wp-admin, complete with the conversation context
that made the lead worth following up on, rather than a bare email address with no
context behind it.
Resources & further reading
- Human Escalation Patterns, Course 11
- register_post_type() function reference, developer.wordpress.org
- PHP AI Client SDK repository, GitHub