Safety & Handoff

Guardrails for Anonymous, Public-Facing AI

⏱ 16 min

Lesson 1 warned that a public REST endpoint is reachable by anyone, or anything, with your site’s URL. Everything built so far works, but nothing stops a visitor from pasting in a 50,000-character message, asking the widget to write malware, or a script hitting the endpoint thousands of times a minute to run up your provider bill. This lesson adds the three guardrails a public-facing AI surface needs that Course 3’s authenticated admin tools didn’t: input length limits, a scope-restricting system instruction, and rate limiting that doesn’t depend on a logged-in user.

What you'll learn in this lesson
Enforcing an input length limit, client and server
Never trust the client-side limit alone, the server checks too.
Restricting scope with a system instruction
Telling the model plainly what it should and should not answer.
Rate limiting by IP address and session
The same transient counter pattern used for authenticated agents, adapted for anonymous visitors.
Why session-based limiting alone isn't enough
A session ID is trivial to rotate, IP-based limiting is the real backstop.
Prerequisites

Lesson 4’s endpoint and transient-based transcript handling. Familiarity with the transient rate-limiting pattern helps if you’ve seen it before, this lesson builds it from scratch either way.

Step 1: enforce an input length limit, on both ends

A client-side limit alone is a suggestion, anyone can bypass your JavaScript entirely and call the endpoint directly, so the server has to enforce the real limit:

// File: assets/js/chat-widget.js
input.setAttribute( 'maxlength', '500' );
// File: inc/rest-chat-endpoint.php
function my_plugin_handle_chat_request( WP_REST_Request $request ) {
	$message = sanitize_textarea_field( $request->get_param( 'message' ) );

	if ( '' === trim( $message ) ) {
		return new WP_Error( 'empty_message', 'Message cannot be empty.', array( 'status' => 400 ) );
	}

	if ( mb_strlen( $message ) > 500 ) {
		return new WP_Error(
			'message_too_long',
			'Please keep messages under 500 characters.',
			array( 'status' => 400 )
		);
	}

	// ...rest of the callback from Lesson 4
}

A length cap does two jobs at once: it keeps a single message from ballooning token cost, and it makes certain classes of abuse, pasting in enormous blocks of text hoping something sticks, immediately fail cheaply, before a model call ever happens.

Step 2: restrict scope with a system instruction

A system instruction is the practical way to keep a public assistant answering the questions it’s actually there for, and declining the rest plainly rather than improvising:

// 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. If a question is unrelated to ' .
	'this site, say plainly that you can only help with questions about this site, and ' .
	'suggest the visitor use the contact form for anything else. Never provide medical, ' .
	'legal, or financial advice. Never generate code, scripts, or content unrelated to ' .
	'this site\'s own products and policies.';

$result = wp_ai_client_prompt( $message )
	->using_system_instruction( $system_instruction )
	->using_max_tokens( 400 )
	->generate_text();
A system instruction is guidance, not an unbreakable wall

A well-written system instruction meaningfully narrows what a model will attempt, but a sufficiently determined visitor can still try to argue, rephrase, or manipulate their way around it. Treat it as the first, load-bearing layer of scope control, not the only one, pairing it with rate limiting and the escalation patterns in the next lesson is what makes the whole system resilient rather than relying on wording alone.

Step 3: rate limit by IP address, not just session

The transient-based counter pattern from authenticated agent rate limiting works the same way here, bucketed by IP address instead of a WordPress user ID, since there is no user ID to bucket by:

// File: inc/rate-limit.php
function my_plugin_check_and_increment_rate_limit( string $bucket_key, int $limit, int $window_seconds ): bool {
	$window_key     = floor( time() / $window_seconds );
	$transient_key  = "my_plugin_rl_{$bucket_key}_{$window_key}";
	$count          = (int) get_transient( $transient_key );

	if ( $count >= $limit ) {
		return false;
	}

	set_transient( $transient_key, $count + 1, $window_seconds );
	return true;
}

function my_plugin_get_visitor_ip(): string {
	return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( $_SERVER['REMOTE_ADDR'] ) : 'unknown';
}
// File: inc/rest-chat-endpoint.php
function my_plugin_chat_permission_check( WP_REST_Request $request ) {
	$nonce = $request->get_header( 'X-WP-Nonce' );

	if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
		return new WP_Error( 'invalid_nonce', 'Missing or invalid request token.', array( 'status' => 403 ) );
	}

	$ip_allowed = my_plugin_check_and_increment_rate_limit(
		'ip_' . my_plugin_get_visitor_ip(),
		20,  // 20 messages
		60   // per 60-second window
	);

	if ( ! $ip_allowed ) {
		return new WP_Error(
			'rate_limited',
			'Too many messages sent too quickly. Please wait a moment and try again.',
			array( 'status' => 429 )
		);
	}

	return true;
}
Why IP, not just session ID, carries the real limit

The session ID from Lesson 4 is entirely client-generated, a script hammering your endpoint can simply mint a fresh crypto.randomUUID() on every request and sail past any session-based limit. An IP address is harder to rotate on every single request, which is exactly why it’s the backstop here. It’s still not perfect, shared corporate NATs and VPNs mean multiple real visitors can share one IP, and a determined attacker can rotate IPs too, but it closes the trivial bypass a session-only limit leaves wide open.

Step 4: layer a stricter daily ceiling too

A short per-minute window stops a burst, a longer daily ceiling stops slow, sustained abuse from ever costing more than you’ve budgeted for:

// File: inc/rest-chat-endpoint.php
$ip = my_plugin_get_visitor_ip();

$burst_ok = my_plugin_check_and_increment_rate_limit( 'ip_' . $ip, 20, 60 );
$daily_ok = my_plugin_check_and_increment_rate_limit( 'ip_daily_' . $ip, 300, DAY_IN_SECONDS );

if ( ! $burst_ok || ! $daily_ok ) {
	return new WP_Error( 'rate_limited', 'Message limit reached, please try again later.', array( 'status' => 429 ) );
}

Test it: confirm each guardrail actually trips

# Length limit: send a message well over 500 characters and confirm a 400 response.
# Scope: ask the widget an unrelated question ("write me a Python script") and confirm
# it declines rather than complying.
# Rate limit: send 25 messages in quick succession from the same browser and confirm
# the 21st onward returns a 429 with the rate_limited error code.

Confirm all three independently, a passing rate-limit test doesn’t tell you the length check works, and vice versa.

Recap

A public chat endpoint needs guardrails an authenticated admin tool never had to worry about: a hard, server-enforced input length limit, a system instruction that plainly states what the assistant should and shouldn’t answer, and rate limiting bucketed by IP address, since a client-generated session ID can be minted fresh on every request and carries no real cost to rotate. None of these individually is bulletproof, together they turn an open, anonymous endpoint into one with real, practical limits on abuse and cost.

Resources & further reading

← Simulating a Streaming Experience Without SDK Streaming Support Human Escalation Patterns →