Skip to main content

Creating a Flavor AI Ability

A Flavor AI ability is a read-only capability an AI agent can call through the flavor-mcp server (see Flavor MCP — Architecture). Every ability extends one abstract base class and implements a fixed contract. The base class owns rate limiting, PII masking, and audit logging — you cannot forget or bypass them.

First-party / advanced

This describes how Flavor's own abilities are built and how to add one. It is an advanced, first-party surface (beta), not a public plugin API with stability guarantees. Abilities ship inside the AI Pack Core Component.

Where abilities live

Abilities are plain, un-namespaced PHP classes. In the source repo they live under the flavor-ai component:

flavor-ai/abilities/plugin/*.php ← loaded only when WP eCommerce Core is active
flavor-ai/abilities/theme/*.php ← loaded only when the Flavor Starter theme is active (none ship yet)

When the AI Pack is installed, those directories are placed under flavor-core/includes/ai/abilities/{plugin,theme}/, which is where the loader scans. Each .php file may declare one or more ability classes; the loader discovers them by class inheritance (a get_declared_classes() diff), so there is no file-name → class-name convention to keep in sync.

Which directory you choose is not cosmetic — it decides conditional availability. A file under abilities/plugin/ is only ever discovered on installs where WP eCommerce Core is loaded (its abilities can safely reference WPECommerce\Core\… classes); a file under abilities/theme/ only where the Flavor Starter theme is loaded.

The contract — eight required methods

Every ability extends Flavor_Core_AI_Abstract_Ability and implements all eight abstract methods. They are PHP-enforced (abstract protected): omit one and the class fatals at instantiation. The loader catches that, logs it to core.mcp.register, and skips the ability — but a missing method is a bug to fix, not a soft state.

abstract protected function get_slug(): string; // e.g. 'flavor/products-query'
abstract protected function get_label(): string; // short human label
abstract protected function get_description(): string; // one sentence — the agent reads this
abstract protected function get_input_schema(): array; // JSON Schema (array form)
abstract protected function get_output_schema(): array; // JSON Schema (array form)
abstract protected function get_required_capability(): string; // WP capability the caller must hold
abstract protected function get_pii_fields(): array; // output keys that are PII (may be empty)
abstract protected function run(array $input): array; // the actual logic — raw result

Overridable defaults (you rarely touch these)

MethodDefaultOverride when
get_category(): string'flavor'Almost never — keeps abilities grouped under the registered flavor category.
get_rate_limit(): int30 (per user, per minute)A heavier ability that should allow fewer calls.
get_daily_rate_limit(): int1000 (per user, per day)A coarse volume cap bounding sustained scraping.
get_annotations(): array['readOnlyHint' => true]Phase 1 is read-only — leave as-is.

A minimal ability, end to end

get_pii_fields() returns [] when an ability exposes no personal data — but that is still an explicit, audited declaration (you cannot silently skip thinking about it). Here is the catalog-search ability, a complete no-PII example:

<?php
use WPECommerce\Core\Infrastructure\Repositories\ProductRepository;

defined( 'ABSPATH' ) || exit;

class Flavor_AI_Products_Query_Ability extends Flavor_Core_AI_Abstract_Ability {

protected function get_slug(): string { return 'flavor/products-query'; }
protected function get_label(): string { return 'Query Products'; }
protected function get_description(): string {
return 'Search and filter the store catalog (read-only). Returns matching products with pricing and stock levels.';
}
protected function get_required_capability(): string { return 'manage_options'; }

/** Catalog data — no customer PII. Explicit empty declaration. */
protected function get_pii_fields(): array { return []; }

protected function get_input_schema(): array {
return [
'type' => 'object',
'properties' => [
'search' => [ 'type' => 'string', 'description' => 'Free-text search over title / SKU.' ],
'status' => [ 'type' => 'string', 'enum' => [ 'publish', 'draft', 'pending', 'private' ] ],
'page' => [ 'type' => 'integer', 'minimum' => 1, 'default' => 1 ],
'per_page' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20 ],
],
];
}

protected function get_output_schema(): array {
return [ 'type' => 'object', 'properties' => [ 'products' => [ 'type' => 'array' ], 'count' => [ 'type' => 'integer' ] ] ];
}

protected function run( array $input ): array {
// Whitelist + clamp EVERY input — never pass raw input through.
$args = [
'search' => isset( $input['search'] ) ? (string) $input['search'] : null,
'status' => isset( $input['status'] ) ? (string) $input['status'] : null,
'page' => isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1,
'per_page' => isset( $input['per_page'] ) ? min( 100, max( 1, (int) $input['per_page'] ) ) : 20,
];

$repo = new ProductRepository(); // the SAME repository the REST controllers use
$products = $repo->all( $args );

// Return a CURATED subset — omit internal-only fields (cost, margin, admin meta).
$rows = [];
foreach ( $products as $product ) {
$a = $product->toArray();
$rows[] = [
'id' => $a['id'], 'title' => $a['title'], 'sku' => $a['sku'],
'price' => $a['price'], 'stock_quantity' => $a['stock_quantity'],
'stock_status' => $a['stock_status'],
];
}

return [ 'products' => $rows, 'count' => count( $rows ) ];
}
}

That is the whole ability. You never call wp_register_ability(), never write the permission check, never rate-limit, never log — the loader and the base class do all of it.

What run() must and must not do

run() receives schema-shaped input and returns the raw (un-redacted) result array. It must not rate-limit, redact, or log — the base class does all of that around it. Keep run() to three moves:

  1. Whitelist + clamp every input value (cast types, clamp pagination, validate enums). Never pass raw agent input into a query.
  2. Call the existing service/repository layer — the same one the REST controllers use ("Option A"). If the service doesn't expose what you need, add it to the service, not here. Do not self-dispatch REST, and do not add AI-only methods to the plugin.
  3. Curate the output — drop internal-only fields (cost, margin, admin notes, raw meta, staff-routing) before returning.

The execute() pipeline (you don't write this)

The base class wraps every call in a final template method that always runs, in order:

  1. Rate limit — per user, per minute (default 30) and per user, per day (default 1000). Over budget returns a WP_Error with status 429 and audits rate_limited.
  2. run() — your logic, isolated in a try/catch. A thrown exception is caught, audited as error, and returned as a WP_Error (status 500) — it never escapes unaudited.
  3. PII redaction — keys you declared in get_pii_fields() are masked recursively unless the caller is authorized (below).
  4. Audit — one entry to the core.mcp.execute channel (user id, slug, outcome, latency).

execute() is final: no subclass can override it and skip a step.

Declaring and masking PII

get_pii_fields() returns a flat list of output key names. The base class walks your result recursively and replaces the value of any matching key with [redacted] at every depth — including inside repeated rows and nested blocks — unless the connecting account holds the unmask capability:

const PII_UNMASK_CAP = 'flavor_view_customer_pii';

So the ability declares which keys are personal; the capability decides whether to reveal them. When a privileged caller does receive un-masked data, that event is separately audited to the core.mcp.pii_unmask channel (warning level) so it can be alerted on independently.

Recursion is why you list bare key names, not paths. The single-order ability, for example, masks these across its nested billing/shipping blocks:

protected function get_pii_fields(): array {
return [ 'first_name', 'last_name', 'full_name', 'company', 'email', 'phone',
'address_1', 'address_2', 'city', 'state', 'postcode', 'vat_number',
'formatted', 'customer_note' ];
}
Masking is not a substitute for minimization

Masking hides declared keys from unauthorized callers. It does not replace dropping internal-only fields in run(). Mask what is personal and returned; simply don't return what an agent never needs (cost, margin, raw meta).

Permission and licensing — the final gate

You declare only the required capability. The gate itself (check_permission()) is final and un-bypassable. It checks, in order:

  1. Licenseflavor_core_is_licensed(); a stale or missing license returns false (zero abilities).
  2. Capabilitycurrent_user_can( get_required_capability() ).

You cannot ship an ability with '__return_true' as its permission callback — the contract only lets you name a capability, not weaken the gate. Every shipped Phase-1 ability names manage_options (which also matches the transport-level admin gate on the flavor-mcp server).

A worked example with PII — orders query

<?php
use WPECommerce\Core\Infrastructure\Repositories\OrderRepository;

defined( 'ABSPATH' ) || exit;

class Flavor_AI_Orders_Query_Ability extends Flavor_Core_AI_Abstract_Ability {

protected function get_slug(): string { return 'flavor/orders-query'; }
protected function get_label(): string { return 'Query Orders'; }
protected function get_description(): string {
return 'Search and list orders with filters (read-only). Returns lightweight summaries; '
. 'use order-get for full detail. Customer name/email masked unless the caller holds the PII-unmask capability.';
}
protected function get_required_capability(): string { return 'manage_options'; }

/** Per-row customer identifiers (the only PII a list summary exposes). */
protected function get_pii_fields(): array { return [ 'full_name', 'email' ]; }

protected function get_input_schema(): array {
return [
'type' => 'object',
'properties' => [
'status' => [ 'type' => 'string', 'description' => 'Order status filter.' ],
'date_from'=> [ 'type' => 'string', 'description' => 'Created-after (YYYY-MM-DD).' ],
'date_to' => [ 'type' => 'string', 'description' => 'Created-before (YYYY-MM-DD).' ],
'page' => [ 'type' => 'integer', 'minimum' => 1, 'default' => 1 ],
'per_page' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20 ],
],
];
}

protected function get_output_schema(): array {
return [ 'type' => 'object', 'properties' => [
'orders' => [ 'type' => 'array' ], 'total' => [ 'type' => 'integer' ],
] ];
}

protected function run( array $input ): array {
$params = array_filter( [
'status' => isset( $input['status'] ) ? (string) $input['status'] : null,
'date_from' => isset( $input['date_from'] ) ? (string) $input['date_from'] : null,
'date_to' => isset( $input['date_to'] ) ? (string) $input['date_to'] : null,
], static fn( $v ) => null !== $v && '' !== $v );
$params['page'] = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1;
$params['per_page'] = isset( $input['per_page'] ) ? min( 100, max( 1, (int) $input['per_page'] ) ) : 20;

$repo = new OrderRepository(); // no-arg constructor (uses global $wpdb)
$result = $repo->getAll( $params ); // repo clamps pagination + whitelists orderby internally

$rows = [];
foreach ( ( $result['orders'] ?? [] ) as $order ) {
$a = $order->toArray();
$rows[] = [
'id' => $a['id'], 'order_number' => $a['order_number'], 'status' => $a['status'],
'grand_total' => $a['grand_total'], 'created_at' => $a['created_at'],
'customer' => [ // full_name + email masked by execute()
'full_name' => $a['billing']['full_name'] ?? '',
'email' => $a['billing']['email'] ?? '',
],
];
}
return [ 'orders' => $rows, 'total' => (int) ( $result['total'] ?? count( $rows ) ) ];
}
}

Note how full_name and email are nested inside customer yet still get masked — the base class recurses.

Read-only annotation

Phase-1 abilities advertise the MCP read-only hint (the base-class default):

protected function get_annotations(): array {
return [ 'readOnlyHint' => true ];
}

MCP clients treat such hints as advisory (the spec lets clients treat them as untrusted). The real guarantee is that Phase-1 abilities have no write path at all — not the hint.

Two rules enforced statically (before ship)

PHP's abstract enforcement can't catch these, so a PostToolUse edit hook (ability-contract-check) warns in real time and an audit check gates the build:

  1. Never set meta.mcp.public = true anywhere in the AI sources. Abilities reach agents only via the flavor-mcp allowlist, never the Abilities API default server. The base class hardcodes the flag false.
  2. Declare all eight contract methods in every extends Flavor_Core_AI_Abstract_Ability file — surfaced the moment the file is saved, instead of as a runtime fatal.

Checklist before you ship an ability

  • All eight contract methods implemented.
  • run() whitelists/clamps every input and returns a curated subset via the existing service/repository layer.
  • get_pii_fields() lists every personal-data output key (or is explicitly []).
  • Internal-only fields (cost/margin/admin meta) dropped in run().
  • get_required_capability() names a real capability (Phase-1 abilities use manage_options).
  • No 'public' => true anywhere in the AI sources.
  • Placed in the correct directory (abilities/plugin/ vs abilities/theme/) for its product dependency.
  • Verified end-to-end: tools/list shows the ability, tools/call returns the curated result, and a core.mcp.execute audit entry appears in Flavor → Debug.