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
Section titled “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)
Section titled “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)
Section titled “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
Section titled “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();There is no realtime adapter. Only membership, search, email, and AI adapters exist - four interfaces, four
register_*()methods onAdapter_Registry, and noregister_realtime(). Live updates (new replies, notification counts) are delivered by REST polling against the updates endpoint, not by a pluggable realtime backend. Older docs listed “real-time” among the adapter interfaces - that was never implemented. If you need push-based delivery, open an issue; do not implement the other adapter interfaces expecting a realtime seam to exist.
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
Section titled “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
Section titled “Example: Custom Elasticsearch Adapter”<?phpnamespace 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
Section titled “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
Section titled “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
Section titled “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.
Grouping and describing your levels (1.9.1)
Section titled “Grouping and describing your levels (1.9.1)”get_all_levels() may return two optional keys per level. Both are ignored if you omit them, so existing adapters keep working unchanged:
| Key | Type | Effect |
|---|---|---|
kind |
string | Groups the level under a heading in the Access Rules picker. Levels sharing a kind are listed together, which stops a long flat list of unrelated products, tiers and courses. |
note |
string | A short line shown under the level name, for when the name alone does not identify it. |
public function get_all_levels(): array { return array( array( 'id' => 'gold', 'name' => 'Gold', 'kind' => 'Subscriptions', 'note' => 'Renews monthly' ), array( 'id' => 'wb-101', 'name' => 'Workshop 101', 'kind' => 'Courses' ), );}A membership rule tops out at Member whatever access level the owner picks, so your adapter cannot be used to appoint moderators. See Access level.
Example: Custom Membership Adapter
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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?
Section titled “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

