Structured Outputs

Getting Reliable JSON Back From the Model

⏱ 15 min

Course 5 used as_json_response() once, for a two-field product description object. This lesson goes further: a bulk-tagging feature that needs three fields back from a single post, a title suggestion, an array of tags, and a category that has to be one of the categories that actually exist on the site, not one the model invents. That last constraint is exactly where a loosely-specified JSON request falls apart, and exactly where a properly built JSON Schema earns its keep.

What you'll learn in this lesson
as_json_response($schema)
Requesting a MIME type of application/json alongside a JSON Schema in one call.
Why "JSON mode" alone is not enough
The difference between asking for JSON and constraining its shape.
Building a schema from real WordPress data
Generating the category enum from get_categories() instead of hardcoding it.
Parsing defensively
json_decode(), json_last_error(), and checking required keys before trusting anything.
Prerequisites

Course 5’s structured output example and Lesson 1’s system instruction patterns. This lesson assumes a working wp_ai_client_prompt() call already returning text.

Step 1: Why a bare JSON request isn’t enough

Asking for JSON without a schema, in the underlying SDK that’s asOutputMimeType( 'application/json' ) on its own, tells the model to format its answer as JSON, but says nothing about what fields belong in that JSON, what types they should be, or what values are allowed. A model left to guess the shape will still produce valid JSON, just not necessarily the JSON your code expects, an extra field one run, a missing one the next, category spelled categories on a bad day. as_json_response() accepts an optional schema argument specifically to close that gap:

// File: my-plugin/class-bulk-tagger.php
$schema = array(
	'type'       => 'object',
	'properties' => array(
		'title'    => array( 'type' => 'string' ),
		'tags'     => array(
			'type'     => 'array',
			'items'    => array( 'type' => 'string' ),
			'maxItems' => 5,
		),
		'category' => array( 'type' => 'string' ),
	),
	'required'   => array( 'title', 'tags', 'category' ),
);

Step 2: Constraining category to values that actually exist

A free-text category string still lets the model invent a category your site doesn’t have. Building the schema’s enum from the site’s real registered categories closes that gap entirely, the model can only pick from what you give it:

// File: my-plugin/class-bulk-tagger.php
$categories = get_categories( array( 'hide_empty' => false ) );
$category_names = wp_list_pluck( $categories, 'name' );

$schema = array(
	'type'       => 'object',
	'properties' => array(
		'title'    => array( 'type' => 'string' ),
		'tags'     => array(
			'type'     => 'array',
			'items'    => array( 'type' => 'string' ),
			'maxItems' => 5,
		),
		'category' => array(
			'type' => 'string',
			'enum' => $category_names,
		),
	),
	'required'   => array( 'title', 'tags', 'category' ),
);

Building the enum from get_categories() at request time, rather than hardcoding a list in the schema, means the constraint automatically stays correct as categories are added or renamed on the site, no separate list to remember to update.

Step 3: Calling as_json_response() with the schema

// File: my-plugin/class-bulk-tagger.php
$post_content = wp_strip_all_tags( get_post_field( 'post_content', $post_id ) );

$raw = wp_ai_client_prompt()
	->with_text(
		"Post content:\n" . wp_trim_words( $post_content, 400 )
	)
	->using_system_instruction(
		'Suggest a concise title, up to five relevant tags, and the single best-fit ' .
		'category for this WordPress post. Only choose a category from the allowed ' .
		'list, never invent a new one.'
	)
	->as_json_response( $schema )
	->using_temperature( 0.2 )
	->generate_text();

A low temperature suits this task well, bulk tagging benefits from consistent, repeatable categorization far more than from variety.

Step 4: Parsing the result defensively

Requesting a schema is a strong instruction to the model, and on many providers it’s enforced quite tightly at the API level, but it isn’t a guarantee your PHP code should skip validating. Always check the decode succeeded and the fields you need are present before writing anything to the database:

// File: my-plugin/class-bulk-tagger.php
$data = json_decode( $raw, true );

if (
	json_last_error() !== JSON_ERROR_NONE
	|| ! is_array( $data )
	|| empty( $data['title'] )
	|| empty( $data['category'] )
	|| ! in_array( $data['category'], $category_names, true )
) {
	// Lesson 6 covers what to do next: retry with clarification, or fall back safely.
	return null;
}

wp_update_post( array(
	'ID'         => $post_id,
	'post_title' => sanitize_text_field( $data['title'] ),
) );

wp_set_post_terms( $post_id, $data['tags'] ?? array(), 'post_tag' );
wp_set_post_terms( $post_id, array( $data['category'] ), 'category' );

Checking in_array( $data['category'], $category_names, true ) again here, even though the schema already constrained it, costs almost nothing and protects against the rare case where a provider’s schema enforcement is looser than expected.

Forgetting the required array is a common, quiet mistake

A schema with properties but no required array tells the model every field is optional, which invites it to omit tags entirely on a post it considers hard to categorize rather than returning an empty array. Always list every field your code actually depends on in required, not just the ones that feel essential.

Test it: run the bulk-tagging call

wp-env run cli wp eval '
$categories = get_categories( array( "hide_empty" => false ) );
$category_names = wp_list_pluck( $categories, "name" );

$schema = array(
	"type"       => "object",
	"properties" => array(
		"title"    => array( "type" => "string" ),
		"tags"     => array( "type" => "array", "items" => array( "type" => "string" ), "maxItems" => 5 ),
		"category" => array( "type" => "string", "enum" => $category_names ),
	),
	"required"   => array( "title", "tags", "category" ),
);

echo wp_ai_client_prompt()
	->with_text( "Post content:\nA guide to setting up automated backups for a WordPress site using WP-CLI cron jobs." )
	->using_system_instruction( "Suggest a concise title, up to five relevant tags, and the single best-fit category. Only choose from the allowed category list." )
	->as_json_response( $schema )
	->using_temperature( 0.2 )
	->generate_text();
'

Expect output shaped like:

{
  "title": "Automating WordPress Backups With WP-CLI Cron Jobs",
  "tags": ["backups", "wp-cli", "automation", "cron"],
  "category": "Tutorials"
}

with category always one of the names actually returned by get_categories() on your site.

Recap

as_json_response() accepts an optional JSON Schema, and that schema is what turns “the model will probably return something JSON-shaped” into “the model returns exactly the fields your code expects, with values constrained to what’s actually valid on this site.” Build constraints like the category enum from live WordPress data rather than a hardcoded list, mark every field your code depends on as required, and always parse the result defensively, a schema request lowers the odds of bad output, it doesn’t eliminate the need to check for it.

Resources & further reading

← Few-Shot Prompting Patterns Designing Tool-Calling Prompts for Abilities →