Every reply so far has been generated from a single message, with no memory of anything said earlier in the conversation. Ask a follow-up like “what about the second one?” and the model has no idea what “the second one” refers to. An authenticated admin assistant would normally lean on the current user’s ID to key some server-side state, but there is no user object here, just a browser tab. This lesson builds conversation memory anyway, using a session ID the client generates itself and a WordPress transient to hold the short-lived transcript that goes with it.
Lesson 3’s REST endpoint and widget fetch call. Basic familiarity with transients
(set_transient() / get_transient()) helps but isn’t required, this lesson explains
the pattern from scratch.
Step 1: generate a session ID on the client
The browser is the only thing that can identify “this particular visitor’s current chat session,” so it generates the ID itself, once, and keeps reusing it for the life of that browser tab:
// File: assets/js/chat-widget.js
function getOrCreateSessionId() {
var key = 'site_chat_session_id';
var existing = window.sessionStorage.getItem( key );
if ( existing ) {
return existing;
}
var id = crypto.randomUUID();
window.sessionStorage.setItem( key, id );
return id;
}
sessionStorage, not localStorage, is the right choice here: it clears automatically
when the tab closes, which matches how long a conversation should reasonably persist.
This ID is not a credential and proves nothing about identity, it’s purely a way to
group a run of messages together server-side.
Step 2: store the transcript in a transient, keyed by session ID
The server needs somewhere to hold “what’s been said so far in this session” between one REST request and the next, since each request is otherwise stateless. A transient, keyed by the session ID and given a short expiry, is exactly that:
// File: inc/rest-chat-endpoint.php
function my_plugin_get_session_transcript( string $session_id ): array {
$transcript = get_transient( 'my_plugin_chat_' . $session_id );
return is_array( $transcript ) ? $transcript : array();
}
function my_plugin_append_to_session_transcript( string $session_id, string $role, string $text ): array {
$transcript = my_plugin_get_session_transcript( $session_id );
$transcript[] = array( 'role' => $role, 'text' => $text );
// Keep only the most recent 10 turns, bounding both storage and prompt size.
if ( count( $transcript ) > 10 ) {
$transcript = array_slice( $transcript, -10 );
}
set_transient( 'my_plugin_chat_' . $session_id, $transcript, 20 * MINUTE_IN_SECONDS );
return $transcript;
}
A 20-minute expiry means an abandoned conversation cleans itself up automatically, nothing here needs a cron job or manual cleanup. If a visitor comes back after 20 minutes of inactivity, they simply start a fresh context, which is a reasonable default for most support and FAQ-style widgets.
Anyone can construct any string and claim it’s their session ID, since the ID is entirely client-generated and never checked against a real identity. Never store anything in a session transient that would matter if a different visitor could read it. This is memory for conversational convenience, not a secure per-user store, that distinction matters more once Lesson 6 adds rate limiting, which uses IP address specifically because a session ID is trivial to rotate.
Step 3: build history into the prompt
With a transcript on hand, the callback assembles it into context for the model instead of treating each message as the entire conversation:
// File: inc/rest-chat-endpoint.php
function my_plugin_handle_chat_request( WP_REST_Request $request ) {
$message = sanitize_textarea_field( $request->get_param( 'message' ) );
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
if ( '' === trim( $message ) ) {
return new WP_Error( 'empty_message', 'Message cannot be empty.', array( 'status' => 400 ) );
}
$transcript = my_plugin_get_session_transcript( $session_id );
$history_text = '';
foreach ( $transcript as $turn ) {
$history_text .= ucfirst( $turn['role'] ) . ': ' . $turn['text'] . "\n";
}
$result = wp_ai_client_prompt()
->using_system_instruction(
'You are a helpful assistant answering visitor questions on this website. ' .
'Keep answers short and plain.'
)
->with_text( $history_text ? "Conversation so far:\n{$history_text}" : '' )
->with_text( "Visitor: {$message}" )
->using_max_tokens( 400 )
->generate_text();
if ( is_wp_error( $result ) ) {
return new WP_Error( 'ai_call_failed', 'Something went wrong generating a reply.', array( 'status' => 502 ) );
}
my_plugin_append_to_session_transcript( $session_id, 'visitor', $message );
my_plugin_append_to_session_transcript( $session_id, 'assistant', $result );
return rest_ensure_response( array( 'reply' => $result ) );
}
Capping the transcript at 10 turns in Step 2 isn’t arbitrary, every prior turn included
in $history_text adds to the tokens sent on every subsequent call. An unbounded
transcript would make a long conversation quietly more expensive and slower with every
message, which is exactly the kind of cost this course’s rate limiting and guardrail
lessons are also concerned with.
Step 4: send the real session ID from the widget
Finally, swap the placeholder from Lesson 3 for the real function:
// File: assets/js/chat-widget.js
body: JSON.stringify( {
message: text,
session_id: getOrCreateSessionId(),
} ),
Test it: confirm a follow-up question actually has context
Open the widget and send two messages in sequence:
You: "Do you sell blue t-shirts?"
You: "What sizes does it come in?"
The second reply should sensibly refer back to the t-shirt, not ask “which product are you referring to.” Then confirm the transient exists directly:
wp-env run cli wp eval 'var_dump( get_transient( "my_plugin_chat_" . "<the session id from your browser console>" ) );'
You should see an array of the last several turns, capped at 10.
Recap
Without a WordPress user to attach state to, a client-generated session ID and a
transient keyed by that ID stand in for a real session store. The ID is created once per
browser tab with crypto.randomUUID() and kept in sessionStorage, the server appends
each turn to a capped, short-lived transient, and the callback folds that history into
the prompt so follow-up questions actually have context. None of this is a security
mechanism, it’s a memory mechanism, keeping it bounded keeps both cost and prompt size
under control as conversations get longer.
Resources & further reading
- Transients API reference, developer.wordpress.org
- PHP AI Client SDK repository, GitHub
- MDN: Window.sessionStorage, MDN