Adapters
Jetonomy uses a universal adapter pattern for every external integration point. Instead of hard-coding a dependency on a specific search engine, email provider, membership plugin, or AI provider, each integration is represented by a PHP interface. You implement the interface, register your adapter, and Jetonomy uses it everywhere.
All adapters are managed through the static Adapter_Registry class (includes/adapters/class-adapter-registry.php).
The Four Adapter Types
| Interface | Class (namespace Jetonomy\Adapters\) |
What it controls |
|---|---|---|
Search_Adapter |
interface-search-adapter.php |
Full-text search for posts, replies, spaces |
Email_Adapter |
interface-email-adapter.php |
Outbound notification emails |
Membership_Adapter |
interface-membership-adapter.php |
Membership level checks and gating |
AI_Adapter |
interface-ai-adapter.php |
AI chat completions and embeddings (text generation, moderation, semantic features) |
Built-in Adapters (Free)
| Adapter Class | Type | Active When |
|---|---|---|
Fulltext_Search (Jetonomy\Search\) |
Search | Always (MySQL FULLTEXT - built-in) |
WP_Mail_Adapter |
Always (uses wp_mail()) |
|
WP_Roles_Adapter |
Membership | Always (WP role-based membership fallback) |
MemberPress_Adapter |
Membership | MemberPress plugin is active |
PMPro_Adapter |
Membership | Paid Memberships Pro is active |
Ollama_AI_Adapter |
AI | A local Ollama endpoint is configured |
Pro Adapters (Jetonomy Pro)
| Adapter Class | Type | Active When |
|---|---|---|
WooCommerce_Adapter |
Membership | WooCommerce Memberships is active |
RCP_Adapter |
Membership | Restrict Content Pro is active |
LearnDash_Adapter |
Membership | LearnDash is active (4.x and 5.x) |
Tutor_Adapter |
Membership | Tutor LMS is active |
LifterLMS_Adapter |
Membership | LifterLMS is active |
Sensei_Adapter |
Membership | Sensei LMS is active |
MasterStudy_Adapter |
Membership | MasterStudy LMS is active |
OpenAI_AI_Adapter |
AI | The AI extension is enabled with an OpenAI provider configured |
Anthropic_AI_Adapter |
AI | The AI extension is enabled with an Anthropic provider configured |
Custom_AI_Adapter |
AI | The AI extension is enabled with a custom OpenAI-compatible provider configured |
Pro registers membership adapters via Adapter_Registry::register_membership() and AI adapters via Adapter_Registry::register_ai() at plugins_loaded priority 20.
Adapter_Registry API
// Register adapters.
\Jetonomy\Adapters\Adapter_Registry::register_search( 'my-search', $adapter );
\Jetonomy\Adapters\Adapter_Registry::register_email( 'my-mailer', $adapter );
\Jetonomy\Adapters\Adapter_Registry::register_membership( 'my-membership', $adapter );
\Jetonomy\Adapters\Adapter_Registry::register_ai( 'my-ai', $adapter );
// Retrieve the active adapter (first registered adapter where is_active() returns true).
$search = \Jetonomy\Adapters\Adapter_Registry::get_search();
$email = \Jetonomy\Adapters\Adapter_Registry::get_email();
$membership = \Jetonomy\Adapters\Adapter_Registry::get_membership();
$ai = \Jetonomy\Adapters\Adapter_Registry::get_ai();
// Retrieve a specific adapter by ID.
$mp = \Jetonomy\Adapters\Adapter_Registry::get_membership( 'memberpress' );
$openai = \Jetonomy\Adapters\Adapter_Registry::get_ai( 'openai' );
// List all registered membership / AI adapters.
$all = \Jetonomy\Adapters\Adapter_Registry::get_all_membership();
$all_ai = \Jetonomy\Adapters\Adapter_Registry::get_all_ai();
The Registry returns null when no active adapter is found for a type - always null-check before calling methods.
Registration timing: Register your adapters at plugins_loaded. Use priority 9 if you want your adapter to override a built-in default (e.g. replacing built-in search). Use priority 15 for additive adapters that do not need to override defaults (e.g. adding a new membership source):
add_action( 'plugins_loaded', function() {
if ( ! class_exists( '\Jetonomy\Adapters\Adapter_Registry' ) ) {
return; // Jetonomy not active.
}
\Jetonomy\Adapters\Adapter_Registry::register_search(
'meilisearch',
new My_Plugin\Meilisearch_Adapter()
);
}, 15 );
Search Adapter Interface
namespace Jetonomy\Adapters;
interface Search_Adapter {
/** Return true when this adapter is ready to handle queries. */
public function is_active(): bool;
/** Index a document. Called when a post or reply is created or updated. */
public function index( string $object_type, int $object_id, array $data ): void;
/**
* Execute a search query.
*
* @param string $query The search string.
* @param string $type 'post', 'reply', or 'space'.
* @param int|null $space_id Optional space filter.
* @param int $limit Max results (default 20).
* @param int $offset Pagination offset.
* @return array Array of result objects with at least: id, title (posts/spaces) or content (replies).
*/
public function search( string $query, string $type, ?int $space_id, int $limit, int $offset ): array;
/** Remove a document from the index. Called when a post or reply is deleted. */
public function delete( string $object_type, int $object_id ): void;
}
Example: Custom Elasticsearch Adapter
<?php
namespace My_Plugin;
use Jetonomy\Adapters\Search_Adapter;
class Elasticsearch_Adapter implements Search_Adapter {
private \Elasticsearch\Client $client;
public function __construct() {
// Build your Elasticsearch client here.
$this->client = \Elasticsearch\ClientBuilder::create()
->setHosts( [ get_option( 'my_plugin_es_host', 'localhost:9200' ) ] )
->build();
}
public function is_active(): bool {
// Only activate when the Elasticsearch host option is set and the client connects.
$host = get_option( 'my_plugin_es_host' );
return ! empty( $host ) && $this->client->ping();
}
public function index( string $object_type, int $object_id, array $data ): void {
$this->client->index( [
'index' => 'jetonomy_' . $object_type,
'id' => $object_id,
'body' => $data,
] );
}
public function search( string $query, string $type, ?int $space_id, int $limit, int $offset ): array {
$must = [
[ 'multi_match' => [ 'query' => $query, 'fields' => [ 'title^3', 'content' ] ] ],
];
if ( $space_id ) {
$must[] = [ 'term' => [ 'space_id' => $space_id ] ];
}
$params = [
'index' => 'jetonomy_' . $type,
'body' => [
'query' => [ 'bool' => [ 'must' => $must ] ],
'from' => $offset,
'size' => $limit,
],
];
$raw = $this->client->search( $params );
// Map Elasticsearch hits to the flat object array Jetonomy expects.
return array_map(
fn( $hit ) => (object) array_merge( $hit['_source'], [ 'id' => (int) $hit['_id'] ] ),
$raw['hits']['hits'] ?? []
);
}
public function delete( string $object_type, int $object_id ): void {
$this->client->delete( [
'index' => 'jetonomy_' . $object_type,
'id' => $object_id,
] );
}
}
Register it:
add_action( 'plugins_loaded', function() {
if ( ! class_exists( '\Jetonomy\Adapters\Adapter_Registry' ) ) {
return;
}
\Jetonomy\Adapters\Adapter_Registry::register_search(
'elasticsearch',
new My_Plugin\Elasticsearch_Adapter()
);
}, 15 );
Jetonomy will call is_active() on every registered search adapter and use the first one that returns true. Because the built-in Fulltext_Search adapter always returns true, register your custom adapter before the defaults are initialized - or override it by making sure your adapter is registered first.
The built-in defaults are initialized at plugins_loaded priority 10 via Adapter_Registry::init_defaults(). Registering at priority 15 means your adapter is added after the defaults, but since get_search() iterates in insertion order, you need to register at priority 9 if you want your adapter to take precedence:
add_action( 'plugins_loaded', function() {
// Priority 9 - runs before Jetonomy's init_defaults() at priority 10.
\Jetonomy\Adapters\Adapter_Registry::register_search( 'elasticsearch', new My_Plugin\Elasticsearch_Adapter() );
}, 9 );
Email Adapter Interface
namespace Jetonomy\Adapters;
interface Email_Adapter {
public function is_active(): bool;
/**
* Send a single transactional email.
*
* @param string $to Recipient email address.
* @param string $subject Email subject line.
* @param string $html HTML body.
* @param string $plain Plain-text fallback.
* @param string[] $extra_headers Additional mail headers.
* @return bool True on success.
*/
public function send( string $to, string $subject, string $html, string $plain, array $extra_headers = [] ): bool;
/**
* Send a batch of emails.
*
* @param array $messages Array of ['to', 'subject', 'html', 'plain'] arrays.
* @return array Results array indexed by recipient.
*/
public function send_batch( array $messages ): array;
/** Register any hooks needed (e.g. intercepting wp_mail for logging). */
public function register_hooks(): void;
}
Example: Postmark Adapter
class Postmark_Adapter implements \Jetonomy\Adapters\Email_Adapter {
public function is_active(): bool {
return ! empty( get_option( 'my_plugin_postmark_token' ) );
}
public function send( string $to, string $subject, string $html, string $plain, array $extra_headers = [] ): bool {
$token = get_option( 'my_plugin_postmark_token' );
$from = get_option( 'admin_email' );
$response = wp_remote_post( 'https://api.postmarkapp.com/email', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'X-Postmark-Server-Token' => $token,
],
'body' => wp_json_encode( [
'From' => $from,
'To' => $to,
'Subject' => $subject,
'HtmlBody' => $html,
'TextBody' => $plain,
] ),
] );
return ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response );
}
public function send_batch( array $messages ): array {
$results = [];
foreach ( $messages as $msg ) {
$results[ $msg['to'] ] = $this->send( $msg['to'], $msg['subject'], $msg['html'], $msg['plain'] );
}
return $results;
}
public function register_hooks(): void {
// Optional - intercept wp_mail if you want to route ALL site email through Postmark.
}
}
Membership Adapter Interface
namespace Jetonomy\Adapters;
interface Membership_Adapter {
public function is_active(): bool;
/** Return all active membership level IDs for a user. */
public function get_user_levels( int $user_id ): array;
/** Check whether a user has a specific membership level. */
public function user_has_level( int $user_id, string $level_id ): bool;
/** Return all available membership levels as ['id' => ..., 'name' => ...] objects. */
public function get_all_levels(): array;
/** Return the human-readable label for a level ID. */
public function get_level_label( string $level_id ): string;
/** Register any hooks needed for lifecycle events (e.g. activation/deactivation). */
public function register_hooks(): void;
}
The register_hooks() method is where you fire jetonomy_membership_activated and jetonomy_membership_deactivated - see 02-hooks-reference.md.
Example: Custom Membership Adapter
class My_Membership_Adapter implements \Jetonomy\Adapters\Membership_Adapter {
public function is_active(): bool {
return defined( 'MY_MEMBERSHIP_VERSION' );
}
public function get_user_levels( int $user_id ): array {
return (array) get_user_meta( $user_id, 'my_membership_levels', true );
}
public function user_has_level( int $user_id, string $level_id ): bool {
return in_array( $level_id, $this->get_user_levels( $user_id ), true );
}
public function get_all_levels(): array {
return my_membership_get_all_plans(); // Your own function.
}
public function get_level_label( string $level_id ): string {
return my_membership_get_plan_name( $level_id ) ?? $level_id;
}
public function register_hooks(): void {
// Fire Jetonomy's membership hooks so space access is updated automatically.
add_action( 'my_membership_activated', function( int $user_id, string $plan_id ) {
do_action( 'jetonomy_membership_activated', $user_id, $plan_id );
}, 10, 2 );
add_action( 'my_membership_cancelled', function( int $user_id, string $plan_id ) {
do_action( 'jetonomy_membership_deactivated', $user_id, $plan_id );
}, 10, 2 );
}
}
AI Adapter Interface
namespace Jetonomy\Adapters;
interface AI_Adapter {
/** Whether this adapter is configured and ready. */
public function is_active(): bool;
/** Unique provider identifier (e.g. 'openai', 'anthropic', 'ollama'). */
public function get_id(): string;
/** Human-readable provider name. */
public function get_name(): string;
/**
* Send a chat completion request.
*
* @param array $messages Array of ['role' => 'system'|'user'|'assistant', 'content' => string].
* @param array $options Optional: model, temperature, max_tokens, json_mode.
* @return array{content: string, usage: array{prompt_tokens: int, completion_tokens: int, total_tokens: int}, model: string}
* @throws \RuntimeException On API failure.
*/
public function chat( array $messages, array $options = [] ): array;
/**
* Generate embeddings for text.
*
* @param string $text Input text.
* @param array $options Optional: model.
* @return array{embedding: float[], model: string, usage: array{total_tokens: int}}
* @throws \RuntimeException On API failure or if provider does not support embeddings.
*/
public function embed( string $text, array $options = [] ): array;
/** Return supported models as id => display_name. */
public function get_models(): array;
/** Test the connection (validates API key + reachability). Returns ['ok' => bool, 'error'? => string, 'model'? => string]. */
public function test(): array;
}
The built-in Ollama_AI_Adapter (free) talks to a local Ollama endpoint and activates only when one is configured. Jetonomy Pro's AI extension registers OpenAI_AI_Adapter, Anthropic_AI_Adapter, and Custom_AI_Adapter (any OpenAI-compatible endpoint). Adapter_Registry::get_ai() returns the first registered adapter whose is_active() returns true; pass an explicit ID to target a specific provider.
Example: Custom AI Adapter
class My_AI_Adapter implements \Jetonomy\Adapters\AI_Adapter {
public function is_active(): bool {
return ! empty( get_option( 'my_plugin_ai_key' ) );
}
public function get_id(): string {
return 'my-ai';
}
public function get_name(): string {
return 'My AI Provider';
}
public function chat( array $messages, array $options = [] ): array {
$response = wp_remote_post( 'https://api.example.com/v1/chat', [
'headers' => [
'Authorization' => 'Bearer ' . get_option( 'my_plugin_ai_key' ),
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( [
'model' => $options['model'] ?? 'default',
'messages' => $messages,
] ),
] );
if ( is_wp_error( $response ) ) {
throw new \RuntimeException( $response->get_error_message() );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return [
'content' => $body['choices'][0]['message']['content'] ?? '',
'usage' => $body['usage'] ?? [ 'prompt_tokens' => 0, 'completion_tokens' => 0, 'total_tokens' => 0 ],
'model' => $body['model'] ?? ( $options['model'] ?? 'default' ),
];
}
public function embed( string $text, array $options = [] ): array {
throw new \RuntimeException( 'Embeddings not supported.' );
}
public function get_models(): array {
return [ 'default' => 'Default Model' ];
}
public function test(): array {
return $this->is_active()
? [ 'ok' => true ]
: [ 'ok' => false, 'error' => 'API key not configured.' ];
}
}
Connecting Adapters to Jetonomy Events
Adapters do not self-wire - you need to connect them to Jetonomy's lifecycle hooks to trigger indexing, emailing, or broadcasting at the right time.
Search: Index content on create/update
add_action( 'jetonomy_after_create_post', function( int $post_id, int $space_id ) {
$search = \Jetonomy\Adapters\Adapter_Registry::get_search();
if ( ! $search ) return;
$post = \Jetonomy\Models\Post::find( $post_id );
if ( $post ) {
$search->index( 'post', $post_id, [
'title' => $post->title,
'content' => wp_strip_all_tags( $post->content ),
'space_id' => $post->space_id,
'author_id' => $post->author_id,
'created_at' => $post->created_at,
] );
}
}, 10, 2 );
add_action( 'jetonomy_post_deleted', function( int $post_id ) {
$search = \Jetonomy\Adapters\Adapter_Registry::get_search();
$search?->delete( 'post', $post_id );
} );
AI: Summarize a new post on demand
add_action( 'jetonomy_after_create_post', function( int $post_id, int $space_id ) {
$ai = \Jetonomy\Adapters\Adapter_Registry::get_ai();
if ( ! $ai ) return;
$post = \Jetonomy\Models\Post::find( $post_id );
if ( $post ) {
$result = $ai->chat( [
[ 'role' => 'system', 'content' => 'Summarize the following topic in one sentence.' ],
[ 'role' => 'user', 'content' => wp_strip_all_tags( $post->content ) ],
] );
// Persist $result['content'] wherever you need it.
}
}, 10, 2 );
Summary: Registration Cheat Sheet
add_action( 'plugins_loaded', function() {
if ( ! class_exists( '\Jetonomy\Adapters\Adapter_Registry' ) ) {
return;
}
// Search - replace built-in MySQL FULLTEXT.
\Jetonomy\Adapters\Adapter_Registry::register_search(
'meilisearch',
new My_Plugin\Meilisearch_Adapter()
);
// Email - replace wp_mail for notification emails.
\Jetonomy\Adapters\Adapter_Registry::register_email(
'postmark',
new My_Plugin\Postmark_Adapter()
);
// Membership - add a custom membership source.
$adapter = new My_Plugin\My_Membership_Adapter();
$adapter->register_hooks();
\Jetonomy\Adapters\Adapter_Registry::register_membership( 'my-membership', $adapter );
// AI - add a custom AI provider for chat completions / embeddings.
\Jetonomy\Adapters\Adapter_Registry::register_ai(
'my-ai',
new My_Plugin\My_AI_Adapter()
);
}, 9 ); // Priority 9 ensures search adapter runs before built-in defaults at priority 10.
What's Next?
- REST API Reference - All 48+ free endpoints (90+ with Pro) in detail
- Hooks Reference - Connect your adapter to content lifecycle events
- Template Overrides - Customize the community UI