Backend Wiring

Simulating a Streaming Experience Without SDK Streaming Support

⏱ 14 min

Chat interfaces people are used to, ChatGPT, Claude.ai, tend to show a reply appearing word by word rather than snapping into view all at once. It’s tempting to assume wp_ai_client_prompt() supports that out of the box. It doesn’t, not yet, and building toward that assumption will leave you debugging a feature that was never there. This lesson is honest about exactly what’s possible today, and gives you two real patterns: a simple one that fakes the visual effect client-side, and a more advanced one for teams that need genuine server-to-client streaming right now.

What you'll learn in this lesson
What the PHP AI Client SDK actually supports today
generate_text() returns one complete response, it does not stream tokens.
Pattern (a): client-side reveal animation
One full generation call, then the reply is revealed progressively in the browser.
Pattern (b): genuine server-sent streaming
Calling the provider's own HTTP API directly and relaying chunks over Server-Sent Events, bypassing the SDK for that call.
Which one to actually pick
Pattern (a) as the default for most sites, pattern (b) only when true streaming is a hard requirement.
Prerequisites

Lesson 4’s working chat endpoint and transcript handling. This lesson only changes how the reply is delivered and displayed, not how it’s generated.

Step 1: what the SDK actually does today

wp_ai_client_prompt() does not stream tokens

As of this writing, the PHP AI Client SDK’s generate_text() call is request-response: it waits for the model provider to finish generating, then returns the complete text in one piece. Token-by-token streaming is tracked as roadmap work for a future SDK release (see the project’s own development roadmap discussion), it is not available in the current SDK. Any code claiming wp_ai_client_prompt() streams a response today is describing a feature that doesn’t exist yet, don’t build against that assumption.

That single fact is why every prior lesson’s endpoint returns one JSON object with the whole reply in it, {"reply": "..."}, not a stream of partial chunks. What you build on top of that fact is a choice between two honest patterns.

The generation itself still happens as one call, the same endpoint from Lesson 4, unchanged. What changes is display: once the full reply arrives, the browser reveals it progressively instead of dumping it in all at once.

// File: assets/js/chat-widget.js
function revealTextProgressively( element, fullText, charsPerTick ) {
	var i = 0;
	element.textContent = '';

	var interval = setInterval( function () {
		i += charsPerTick;
		element.textContent = fullText.slice( 0, i );

		if ( i >= fullText.length ) {
			clearInterval( interval );
		}
	}, 15 );
}

// Inside the existing fetch().then() handler from Lesson 3/4:
.then( function ( data ) {
	var bubble = document.createElement( 'div' );
	bubble.className = 'site-chat-message site-chat-message--assistant';
	messages.appendChild( bubble );
	revealTextProgressively( bubble, data.reply || 'Sorry, something went wrong.', 3 );
} )

This is a legitimate, honest UX pattern, not a trick played on the visitor. The generation already finished server-side before this code runs, the animation is purely cosmetic polish that matches the feel visitors expect from other chat products, without claiming the backend does something it doesn’t.

Why this is the right default for most sites
Zero backend complexity beyond what Lesson 4 already built
No new infrastructure, no provider-specific code.
Works with every AI provider the SDK supports
The reveal effect is entirely independent of which provider generated the text.
The only real cost is a slightly longer perceived wait
The visitor waits for the full reply before anything appears, then sees it animate in.

Step 3: pattern (b), genuine streaming, bypassing the SDK for that one call

Some teams need the reply to actually start appearing before the full generation finishes, a longer response on a slow connection, for instance, where waiting for the complete text before showing anything feels sluggish. Real token-by-token delivery requires calling the AI provider’s own HTTP API directly for that specific request, outside the PHP AI Client SDK, since the SDK doesn’t expose a streaming method to call through.

The shape of it: your REST callback opens a streaming HTTP request to the provider (most providers’ chat completion APIs support a stream: true parameter that returns chunked, incremental output), reads each chunk as it arrives, and relays it to the browser over Server-Sent Events as your own PHP script flushes output incrementally.

// File: inc/rest-chat-stream-endpoint.php
// Conceptual sketch, provider-specific request details vary by vendor.
add_action( 'rest_api_init', function () {
	register_rest_route( 'my-plugin/v1', '/chat-stream', array(
		'methods'             => 'POST',
		'callback'            => 'my_plugin_handle_chat_stream',
		'permission_callback' => 'my_plugin_chat_permission_check',
	) );
} );

function my_plugin_handle_chat_stream( WP_REST_Request $request ) {
	header( 'Content-Type: text/event-stream' );
	header( 'Cache-Control: no-cache' );

	$message = sanitize_textarea_field( $request->get_param( 'message' ) );

	// This call goes directly to the provider's own streaming HTTP endpoint,
	// using your stored API key server-side, not through wp_ai_client_prompt().
	my_plugin_stream_from_provider( $message, function ( $chunk ) {
		echo "data: " . wp_json_encode( array( 'chunk' => $chunk ) ) . "\n\n";
		flush();
	} );

	echo "data: [DONE]\n\n";
	flush();
	exit;
}
// File: assets/js/chat-widget.js
// Conceptual sketch: reading a Server-Sent Events style response incrementally.
fetch( siteChatWidget.streamUrl, {
	method: 'POST',
	headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': siteChatWidget.nonce },
	body: JSON.stringify( { message: text } ),
} ).then( function ( response ) {
	var reader = response.body.getReader();
	var decoder = new TextDecoder();

	function read() {
		reader.read().then( function ( result ) {
			if ( result.done ) {
				return;
			}
			appendChunkToLastMessage( decoder.decode( result.value ) );
			read();
		} );
	}

	read();
} );
This is a real, more advanced approach, and it's provider-specific

Pattern (b) works, but every part of it, the request format, the chunk shape, how the stream ends, is defined by whichever AI provider you’re calling, not by any WordPress API. Switching providers means rewriting the streaming logic. It also means that one specific call bypasses the PHP AI Client SDK’s abstraction entirely, so you lose the SDK’s provider-switching convenience for that request. Only take this on if genuine token-by-token delivery is a hard product requirement today.

Step 4: choosing between them

Which pattern to use
Default to pattern (a) for nearly every site
One SDK call, a client-side reveal, works with any provider the SDK supports.
Reach for pattern (b) only under a real requirement
A support tool where long replies over slow connections genuinely need to start rendering early.
Revisit pattern (b) once the SDK ships native streaming
Tracked on the project's roadmap, at which point it likely replaces this bypass entirely.

Test it: confirm pattern (a) behaves correctly

Send a longer message that produces a multi-sentence reply. Confirm the text appears progressively rather than all at once, and confirm the network tab still shows exactly one request to /wp-json/my-plugin/v1/chat that completes before any animation starts, proving the “streaming” is purely a client-side reveal, not a second network mechanism.

Recap

The PHP AI Client SDK generates a complete response in one call today, it does not stream tokens, and that’s expected to change only in a future SDK release. Pattern (a), generating the full reply once and revealing it progressively in the browser, is an honest, simple, provider-agnostic default that matches the UX visitors expect. Pattern (b), calling a provider’s own streaming HTTP API directly and relaying chunks over Server-Sent Events, gets genuine streaming today at the cost of bypassing the SDK for that call and coupling your code to one provider’s API shape.

Resources & further reading

← Handling Sessions and Conversation Context Guardrails for Anonymous, Public-Facing AI →