Client Patterns

Building a Typed JS Client for Your Abilities

⏱ 13 min

The previous lesson’s fetch-and-Basic-Auth code works, but repeating it for every ability, with the URL, method, and response shape hand-typed each time, is exactly the kind of duplication that drifts out of sync with WordPress the moment an ability’s schema changes. This lesson wraps that boilerplate in a small typed client, and generates its TypeScript types directly from the same input_schema and output_schema JSON Schema definitions you already wrote in PHP, so the client and the ability can’t quietly disagree about shape.

What you'll learn in this lesson
Why a thin client wrapper is worth building
One place for auth, base URL, and error handling instead of N copies.
Exporting your ability schemas as plain JSON
A small WP-CLI or REST route that dumps input_schema/output_schema for your abilities.
Generating TypeScript types from that JSON Schema
Using json-schema-to-typescript so the client's types come from the ability, not a hand guess.
A typed call() method your app actually uses
One generic function, specialized per ability by its generated types.
Prerequisites

The Next.js server-side fetch pattern from Lesson 3, and at least one ability with a real input_schema and output_schema (Course 1’s ability anatomy lesson).

Step 1: Export your ability schemas as plain JSON

Your abilities’ schemas already exist in PHP. Rather than retyping them by hand in TypeScript, dump them somewhere your build process can read, a simple WP-CLI command run at build time works well for this:

// File: my-plugin.php
add_action( 'cli_init', function () {
	WP_CLI::add_command( 'my-plugin export-schemas', function () {
		$abilities = wp_get_abilities();
		$out       = array();

		foreach ( $abilities as $ability ) {
			$out[ $ability->get_name() ] = array(
				'input_schema'  => $ability->get_input_schema(),
				'output_schema' => $ability->get_output_schema(),
			);
		}

		WP_CLI::line( wp_json_encode( $out, JSON_PRETTY_PRINT ) );
	} );
} );
wp-env run cli wp my-plugin export-schemas > schemas/abilities.json

Now schemas/abilities.json contains real JSON Schema, exactly what your PHP registration declared, not a manually re-typed guess sitting in a separate repository.

Step 2: Generate TypeScript types from that JSON Schema

json-schema-to-typescript is a widely used, general-purpose npm package for exactly this: turning a JSON Schema document into .d.ts type definitions. It has no WordPress-specific knowledge, and needs none, input_schema and output_schema are already standard JSON Schema.

npm install --save-dev json-schema-to-typescript
// File: scripts/generate-types.ts
import { compile } from 'json-schema-to-typescript';
import { writeFileSync, readFileSync } from 'node:fs';

const schemas = JSON.parse(readFileSync('schemas/abilities.json', 'utf-8'));

async function run() {
  let output = '';
  for (const [name, { input_schema, output_schema }] of Object.entries(schemas)) {
    const typeName = name.replace(/[^a-zA-Z0-9]/g, '_');
    output += await compile(input_schema as any, `${typeName}_Input`);
    output += await compile(output_schema as any, `${typeName}_Output`);
  }
  writeFileSync('src/generated/abilities.d.ts', output);
}

run();

Run this as a build step whenever schemas/abilities.json changes, and the generated .d.ts file stays a direct reflection of what WordPress actually registered.

This is the same discipline as an OpenAPI-generated client

If you’ve generated a typed client from an OpenAPI spec before, this is the same idea: the schema is the single source of truth, and the types are a build artifact, not something a developer maintains by hand in two places at once.

Step 3: A small, generic typed client

With generated types in place, wrap the actual fetch logic once:

// File: src/lib/abilities-client.ts
type CallOptions = {
  baseUrl: string;
  username: string;
  appPassword: string;
};

export function createAbilitiesClient(opts: CallOptions) {
  const auth = Buffer.from(`${opts.username}:${opts.appPassword}`).toString('base64');

  return {
    async call<TInput, TOutput>(abilityName: string, input: TInput): Promise<TOutput> {
      const res = await fetch(`${opts.baseUrl}/wp-json/mcp/v1/${abilityName}`, {
        method: 'POST',
        headers: {
          Authorization: `Basic ${auth}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(input),
        cache: 'no-store',
      });

      if (!res.ok) {
        throw new Error(`Ability ${abilityName} failed with status ${res.status}`);
      }

      return res.json() as Promise<TOutput>;
    },
  };
}

Step 4: Calling an ability with real types, not any

Import the generated types alongside the client, and every call is now checked against the ability’s actual schema:

// File: app/api/latest-post-title/route.ts
import { createAbilitiesClient } from '@/lib/abilities-client';
import type {
  my_plugin_get_latest_post_title_Input,
  my_plugin_get_latest_post_title_Output,
} from '@/generated/abilities';
import { NextResponse } from 'next/server';

const client = createAbilitiesClient({
  baseUrl: process.env.WP_BASE_URL!,
  username: process.env.WP_APP_USERNAME!,
  appPassword: process.env.WP_APP_PASSWORD!,
});

export async function GET() {
  const result = await client.call<
    my_plugin_get_latest_post_title_Input,
    my_plugin_get_latest_post_title_Output
  >('my-plugin/get-latest-post-title', {});

  return NextResponse.json(result);
}

If the PHP output_schema later adds a required field, regenerating types surfaces a real TypeScript compile error at every call site expecting the old shape, instead of a silent runtime mismatch discovered by a user.

Test it: change a schema and watch the type break on purpose

As a deliberate check, add a new required property to an ability’s output_schema in PHP, re-run the export and generation steps, and try to build your Next.js project without updating any call sites:

wp-env run cli wp my-plugin export-schemas > schemas/abilities.json
npx tsx scripts/generate-types.ts
npm run build

You should see a TypeScript error at the call site accessing the old shape. That failure, at build time rather than in production, is the entire point of generating types from the real schema instead of hand-writing them.

Regenerating types is a step you have to remember to run

Nothing forces you to re-run schema export and type generation when an ability changes. Wire it into your CI pipeline or a pre-build script so a schema change on the WordPress side can’t silently drift out of sync with a client that was only ever generated once and never refreshed.

Recap

A thin, generic call() function centralizes auth, the base URL, and error handling in one place, and generating its input and output types from your abilities’ input_schema and output_schema JSON Schema keeps the client honest about what WordPress actually expects and returns, without hand-maintaining a parallel type definition. The next lesson takes this same shape of call, minus a Node.js server in the middle, to a mobile app talking to WordPress directly from the device.

Resources & further reading

← Calling Abilities From a Next.js App Calling WordPress AI Abilities From a Mobile App →