Skip to chapter content

Chapter 8 5 min read

Ask for JSON, Not Prose

Structured output via JSON schema

Every example so far has returned a string. A haiku, a tagline, an explanation, all of it text meant to be read and dropped onto a page as-is. That’s fine for content, but it falls apart the moment you need the AI’s answer to become data, five plugin recommendations you can loop over, a set of form field values, anything your code needs to act on rather than just display.

Ask a model “list 5 blog post ideas” without any structure and you’ll get a paragraph, or a numbered list with inconsistent formatting from one call to the next, sometimes bold, sometimes not, sometimes with extra commentary before or after. Parsing that reliably with PHP is a losing game. There’s a better option: tell the model exactly what shape you want the answer in, and it hands back something you can decode like any other API response.

Describing the shape you want

If you’ve ever written an args schema for a REST API endpoint, some of this will look familiar. Here’s the vocabulary, since everything from this point in the book that touches structured data reuses the same handful of keywords:

  • type describes what shape a value is: 'string', 'number', 'boolean', 'object' (a set of named fields), or 'array' (a list of entries).
  • properties lists the named fields inside an object, each with its own type.
  • items describes what a single entry inside an array looks like, usually an object with its own properties.
  • required is a plain list of which field names must actually show up.
  • additionalProperties, set to false, tells the model not to invent extra fields you didn’t ask for.

That’s the whole vocabulary. One of these keywords comes with a real catch worth understanding before you write your first schema, whether the root can be an array, covered next. The other, whether additionalProperties is required, only matters as a provider-specific detail, covered in the warning further down instead of its own section.

The one rule that shapes everything else: the root has to be an object

Here’s the part that isn’t obvious until you hit it: the root of a JSON schema has to be 'object', not 'array', even when what you actually want back is a list.

OpenAI enforces this with a hard 400 Bad Request, confirmed by testing while writing this chapter.

Gemini’s own documentation states the same requirement.

Anthropic gets here a different way. Its structured output is built on forced tool use, and a tool’s input schema is inherently an object, a named set of arguments. So the same shape applies, just not as an error you’d ever hit.

This isn’t one provider’s quirk to work around. It’s close to a universal rule for this feature.

The fix is a small, consistent pattern: wrap the array inside a named property of an object.

$schema = array(
'type' => 'object',
'properties' => array(
'ideas' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'title' => array( 'type' => 'string' ),
'angle' => array( 'type' => 'string' ),
),
'required' => array( 'title', 'angle' ),
'additionalProperties' => false,
),
),
),
'required' => array( 'ideas' ),
'additionalProperties' => false,
);

Read it from the outside in. The root is an object, which satisfies the rule above, with one property, ideas. That’s where the actual array you want lives. Everything inside ideas is the same shape as before: an array of object entries, each with properties, required, and additionalProperties: false.

One thing this changes: the response you get back will be a JSON object like {"ideas": [...]}, not a bare array. Decoding needs one extra step to match, pulling the list out of the ideas key instead of treating the whole decoded value as the list itself. That shows up in the code below.

Pass the schema to as_json_response(), chained onto the builder the same way everything else has been:

$json = wp_ai_client_prompt( 'Suggest 5 blog post ideas...' )
    ->as_json_response( $schema )
    ->generate_text();

Worth noticing: this still calls generate_text(), the same method as every prior chapter. as_json_response() doesn’t change what you call, it changes what comes back. Instead of loose prose, you get a JSON-encoded string that matches the schema you described.

Seeing it work

Create chapter-08-json-response.php inside includes:

<?php
/**
* Chapter 8: Ask for JSON, Not Prose
* Usage: add [ai_course_ch8] to any page or post to see the output.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
function ai_course_ch8_json_response() {
$schema = array(
'type' => 'object',
'properties' => array(
'ideas' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'title' => array( 'type' => 'string' ),
'angle' => array( 'type' => 'string' ),
),
'required' => array( 'title', 'angle' ),
'additionalProperties' => false,
),
),
),
'required' => array( 'ideas' ),
'additionalProperties' => false,
);
$json = wp_ai_client_prompt( 'Suggest 5 blog post ideas for a WordPress plugin developer\'s blog. For each, give a title and a one-sentence angle describing what makes it worth reading.' )
->as_json_response( $schema )
->generate_text();
if ( is_wp_error( $json ) ) {
return 'Could not generate ideas right now: ' . esc_html( $json->get_error_message() );
}
$data = json_decode( $json, true );
if ( ! is_array( $data ) || empty( $data['ideas'] ) || ! is_array( $data['ideas'] ) ) {
return 'The AI did not return valid JSON that time, try reloading the page.';
}
$output = '<ol>';
foreach ( $data['ideas'] as $idea ) {
$output .= '<li><strong>' . esc_html( $idea['title'] ) . '</strong>: ' . esc_html( $idea['angle'] ) . '</li>';
}
$output .= '</ol>';
return $output;
}
add_shortcode( 'ai_course_ch8', 'ai_course_ch8_json_response' );

Add [ai_course_ch8] to a page and load it. You get an actual numbered list, five distinct ideas, each with a title and a one-line angle, built entirely from a real PHP array the AI handed back. Nothing was string-parsed or guessed at, json_decode() did all the work, same as it would for any other JSON API response.

Warning

Warning: if you’re on OpenAI, two schema requirements are strictly enforced here. One is the root-object rule from above. The other hasn’t come up yet: every object in the schema also needs additionalProperties: false.

Skip the root-object rule, and you get “schema must be a JSON Schema of ‘type: object’, got ‘type: array’.”

Skip additionalProperties: false and you get “‘additionalProperties’ is required to be supplied and to be false.”

The schema above already satisfies both.

The one extra check this needs

Notice the empty( $data['ideas'] ) check right after decoding. is_wp_error() only tells you the network call itself succeeded, it says nothing about whether the response actually matched your schema. In practice, models following a JSON schema are reliable, but not infallible, and a malformed response would make json_decode() return null, return an array without the ideas key at all, or occasionally an empty ideas list. empty() catches all three, a plain isset() check would let an empty list slip through as if it were valid. A missing or null ideas value throws a PHP warning the moment foreach tries to loop over it; an empty list doesn’t warn, it just silently produces an empty <ol></ol> with nothing in it. Either way, this check catches it before that happens, the same way is_wp_error() catches a failed call. Two different failure points, two different checks, both cheap to write.

Where this is going

Right now the ideas from this example just get printed to the page and vanish on the next reload. That’s fine for a demo, but the actual point of structured data is doing something with it afterward, saving it, storing it in post meta, feeding it into a form. That’s Chapter 9, next.

Try it yourself

Design your own schema for something you’d actually use. A few starting points: FAQ entries (question and answer), SEO metadata (title and meta_description), or product attributes (name, price_range, use_case). Wrap whichever one you pick in a root object the same way this chapter’s example does, plug it into as_json_response(), and confirm the decoded result actually matches the shape you asked for.

This book is created with Chapterwright