Growth Features

Lead Capture Within the Chat Flow

⏱ 15 min

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.

What you'll learn in this lesson
Asking the model for a purchase-intent signal
The same structured-JSON pattern Course 11 used for needs_human, applied to intent instead.
Showing an inline form without derailing the conversation
The form appears alongside the reply, the chat keeps working either way.
Storing leads in a real, queryable custom post type
wp_insert_post() with meta, not a one-off table just for this.
Not asking twice in the same session
A session-level flag so one form offer per conversation is the ceiling.
Prerequisites

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.

Reusing the same permission_callback matters here

/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

← Adding Voice Input Conversation Analytics Dashboard →