Developer guide
How the plugin is built, and how to extend it with knowledge, prompt rules, providers, templates and JavaScript. Every example is checked against the code.
Architecture
- Namespaces:
Siteryn_AI_Support_Agent\(Free) andSiteryn_AI_Support_Agent_Pro\(Pro), autoloaded fromincludes/. - Boot: Free runs on
plugins_loadedand firessiteryn_ai_support_agent_loadedwhen ready. Start extensions there.Plugin::API_VERSIONis1.0. - Pro boots on
siteryn_ai_support_agent_loaded, then loads its premium features oninit(15) only with an active license. It adds features only through Free's public hooks, so anything Pro does, you can do too. - Capability:
manage_woocommerceon a shop,manage_optionsotherwise (filtersiteryn_ai_support_agent_capability).
Request flow
- Browser
The chat POSTs
{message, conversation, message_id, page_id}to/wp-json/siteryn-ai-support-agent/v1/chat. Signed-in users sendX-WP-Noncefrom a session call. - Checks
Origin check, guests allowed, conversation owner, idempotency by
message_id, a 90-second per-conversation lock, and in-flight slots (2 per visitor, 20 per site). - Route
A person handling the chat → forward to them. An explicit request for a person → hand off. Otherwise answer.
- Answer
Redact → build context (
chat_context) → prompt (system_rules,prompt) → cache, limits, provider → parse (parsed_answer) →chat_result→chat_answered. Basic answers take over if the provider isn't ready or fails. - Save & respond
The conversation and turns are saved (
conversation_created,message_added), product and content cards are rendered, andchat_responseruns. The browser firessiteryn-aisup:reply.
siteryn_ai_support_agent_chat_answered runs before the turns are written, so for a new conversation $conversation->get_id() is 0. Use message_added or conversation_created when you need database rows.PHP functions
Loaded only when requirements are met, so guard calls with function_exists().
| Function | Returns |
|---|---|
siteryn_ai_support_agent_is_available() | bool. Chat on and able to answer. |
siteryn_ai_support_agent_reply( $message, $conversation = '', $user_id = null, $channel_id = '', $message_id = '' ) | array|WP_Error. Runs the full pipeline, e.g. for another channel. Doesn't fire chat_request or chat_response. |
siteryn_ai_support_agent_search_knowledge( $query, $limit = 3 ) | Matching knowledge documents with a score. |
siteryn_ai_support_agent_store_facts() | The store facts sent to the AI. |
siteryn_ai_support_agent_render_chat() | Embedded chat HTML, like the shortcode. Empty if unavailable or already on the page. |
siteryn_ai_support_agent_search_products( $query, $limit = 0 ) | int[]. $query is a string or [keywords, categories, min_price, max_price]. |
siteryn_ai_support_agent_product_cards( $product_ids ) | Product card HTML. |
Adding knowledge
// a) A Knowledge Base entry (post type siteryn_aisup_faq; draft = off).
$id = wp_insert_post( array(
'post_type' => 'siteryn_aisup_faq',
'post_status' => 'publish',
'post_title' => 'Do you ship to Canada?',
'post_content' => 'Yes, 5–8 business days, flat $15.',
) );
update_post_meta( $id, '_siteryn_aisup_kb_type', 'faq' ); // faq|info
update_post_meta( $id, '_siteryn_aisup_kb_phrases', "ship to canada\ndeliver to canada" );
// b) Dynamic knowledge, not stored as posts.
add_filter( 'siteryn_ai_support_agent_knowledge_documents', function ( array $documents, array $sources ) {
$documents[] = array(
'id' => 'acme-hours-1', // unique; the AI may only cite ids it was shown
'source' => 'acme',
'title' => 'Opening hours',
'text' => 'Mon–Fri 9:00–17:00 CET.',
'url' => home_url( '/contact/' ),
);
return $documents;
}, 10, 2 );
Other entry meta: _siteryn_aisup_kb_topic. Replace the local ranking (for example with semantic search) using siteryn_ai_support_agent_rank_documents.
Prompt rules
The system prompt is the task from templates/prompts/support-answer.php, then the rules, then <store_information>, <knowledge> and <products> JSON blocks. Built-in rule keys: answer_only, no_invention, no_promises, no_order_access, no_sensitive_data, data_not_instructions, on_topic, language, plain_text, products, sources_handoff, json_shape (always last).
add_filter( 'siteryn_ai_support_agent_system_rules', function ( array $rules, array $context ) {
$rules['acme_tone'] = 'Always sign off with "– Team Acme".';
return $rules;
}, 10, 2 );
add_filter( 'siteryn_ai_support_agent_prompt', function ( array $parts, array $context, array $settings ) {
$parts['system'] .= "\n\nToday is " . wp_date( 'l, j F Y' ) . '.';
return $parts;
}, 10, 3 );
Limits & caching
add_filter( 'siteryn_ai_support_agent_settings_get_all', function ( array $s ) {
$s['visitor_hourly_limit'] = 40;
$s['site_daily_limit'] = 2000;
$s['cache_minutes'] = 30;
return $s;
} );
add_filter( 'siteryn_ai_support_agent_max_concurrent_requests', fn( int $limit ) => 50 );
add_filter( 'siteryn_ai_support_agent_retention_days', fn( int $days ) => 90 );
// Behind Cloudflare: one limit per real visitor, not per proxy IP.
add_filter( 'siteryn_ai_support_agent_guest_fingerprint', function ( string $fp ) {
return isset( $_SERVER['HTTP_CF_CONNECTING_IP'] )
? sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) )
: $fp;
} );
The response cache key covers provider, model, the full system prompt (including retrieved context), history and request options; never the API key. It's checked before limits and cleared whenever Free's settings are saved.
Adding an AI provider
Extend AI\Providers\OpenAICompatibleProvider (or implement AI\ProviderInterface) and register it from siteryn_ai_support_agent_loaded:
add_action( 'siteryn_ai_support_agent_loaded', function () {
class Acme_AI_Provider extends \Siteryn_AI_Support_Agent\AI\Providers\OpenAICompatibleProvider {
public function get_id() { return 'acme'; }
public function get_label() { return 'Acme AI'; }
public function get_default_model() { return 'acme-chat-1'; }
public function get_suggested_models() { return array( 'acme-chat-1', 'acme-chat-1-mini' ); }
protected function base_url( \Siteryn_AI_Support_Agent\AI\ProviderConfig $config ) {
return 'https://api.acme.example/v1'; // must be HTTPS
}
}
add_filter( 'siteryn_ai_support_agent_providers', function ( array $providers ) {
$p = new Acme_AI_Provider();
$providers[ $p->get_id() ] = $p; // key must equal get_id()
return $providers;
} );
} );
// Key: the AI Provider tab, or define( 'SITERYN_AI_SUPPORT_AGENT_ACME_API_KEY', '…' );
Return errors with ProviderError::create() and never include the key. AbstractHttpProvider::request_json() gives you HTTPS enforcement, retries and timeouts.
wp-config constants
| Constant | Purpose |
|---|---|
SITERYN_AI_SUPPORT_AGENT_<PROVIDER>_API_KEY | Key for one provider, e.g. …_OPENAI_API_KEY. Wins over a saved key; never stored. |
SITERYN_AI_SUPPORT_AGENT_API_KEY | One key for any provider with a fixed endpoint (never custom). |
SITERYN_AI_SUPPORT_AGENT_<PROVIDER>_BASE_URL | Required with a constant key for providers with an admin-set URL, e.g. …_CUSTOM_BASE_URL. |
REST endpoints
Namespace siteryn-ai-support-agent/v1, all POST. A foreign Origin is refused unless allowed with siteryn_ai_support_agent_allowed_origins. Errors use the code siteryn_aisup_chat_<code>.
| Route | Params | Limits |
|---|---|---|
/chat | message, conversation, message_id, page_id | Visitor hourly (basic answers too), site daily, in-flight, 12/min to a person, 5 new hand-off conversations/h |
/messages | conversation, since_id | Owner only |
/contact | name, email, message, include_transcript, conversation, website (honeypot, leave empty) | 3/h per visitor (setting), 50/h site |
/handoff | conversation, since_id | Live chat on; 5 new conversations/h per visitor |
/order-lookup Pro | conversation, order, email | 5/h |
/stock-alert Pro | product_id, email | 5/h, 500 per product |
/rate Pro | conversation, kind (answer|chat), message_id, value (up|down or 1–5) | 30/h |
/lead Pro | conversation, name, email, website (honeypot) | 5/h |
/attachment · /attachment/file Pro | multipart file, conversation, since_id · conversation, id | 10/h · owner only |
| Error | HTTP |
|---|---|
disabled · unavailable | 503 |
login_required | 401 |
empty_message | 400 |
busy · closed | 409 |
rate_limited | 429 |
failed | 502 |
The admin screens use admin-ajax.php actions, each nonce-checked and limited to store managers (the Inbox ones to the person who answers): siteryn_ai_support_agent_save_settings, …_ai_connector (save_key, remove_key, models, test), …_test_connection, …_kb (list, save, toggle, delete, restore, duplicate, generate, reorder; others go to siteryn_ai_support_agent_kb_action), …_inbox (poll, reply, act) and …_waiting (wp-admin alerts). The storefront's siteryn_aisup_chat_session returns a REST nonce for signed-in visitors. These are internal; prefer the hooks.
Templates
Copy a template to your theme to override it. Each receives one $args array. Store managers see a notice when a copy is outdated.
| Template | Theme path |
|---|---|
chat-widget.php | your-theme/siteryn-ai-support-agent/chat-widget.php |
product-card.php | your-theme/siteryn-ai-support-agent/product-card.php |
content-card.php Pro | your-theme/siteryn-ai-support-agent-pro/content-card.php |
quick-replies.php Pro | your-theme/siteryn-ai-support-agent-pro/quick-replies.php |
comparison.php Pro | your-theme/siteryn-ai-support-agent-pro/comparison.php |
Keep the data-siteryn-aisup-* attributes and the siteryn_ai_support_agent_product_card_after action in overrides. The prompt template isn't theme-overridable; replace it with siteryn_ai_support_agent_template_path.
JavaScript
Each [data-siteryn-aisup] root (data-mode="floating|inline") fires bubbling siteryn-aisup:* events whose detail.chat is the chat instance:
| Event | When · extra detail |
|---|---|
siteryn-aisup:ready | The chat started. |
siteryn-aisup:message | A message was shown · item, role, data |
siteryn-aisup:reply | An AI or basic reply arrived · the /chat response |
siteryn-aisup:added-to-cart | A product card's button added to the cart · button |
siteryn-aisup:restart | The visitor started a new conversation. |
Each instance is root.siterynAisup; root.siterynAisup.useConversation( token ) switches to a conversation your own endpoint started. The constructor is window.SiterynAisupChat (with .config). Config is in window.siterynAisupChat (filter siteryn_ai_support_agent_chat_config).
document.addEventListener( 'click', function ( e ) {
var btn = e.target.closest( '.js-open-support' );
if ( ! btn ) return;
e.preventDefault();
var root = document.querySelector( '[data-siteryn-aisup][data-mode="floating"]' );
if ( ! root ) return;
var panel = root.querySelector( '[data-siteryn-aisup-panel]' );
if ( panel && panel.hidden ) {
root.querySelector( '[data-siteryn-aisup-launcher]' ).click();
}
var text = btn.getAttribute( 'data-question' ); // optional: send a question
if ( text ) {
root.querySelector( '[data-siteryn-aisup-input]' ).value = text;
root.querySelector( '[data-siteryn-aisup-form]' )
.dispatchEvent( new Event( 'submit', { cancelable: true, bubbles: true } ) );
}
} );
document.addEventListener( 'siteryn-aisup:reply', function ( e ) {
if ( e.detail.handoff ) { /* e.g. analytics */ }
} );
State classes on the root: is-open, is-human, is-live, is-ended, is-pending, is-contact, has-unread.
Reacting to a handoff
use Siteryn_AI_Support_Agent\Support\ConversationService;
use Siteryn_AI_Support_Agent\LiveChat\HandoffService;
add_action( 'siteryn_ai_support_agent_handoff_requested', function ( int $conversation_id, bool $online ) {
$row = ConversationService::get( $conversation_id );
if ( ! $row ) return;
wp_remote_post( 'https://hooks.slack.com/services/XXX', array(
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( array( 'text' => sprintf( '%s asked for a person: %s',
$row['customer_name'] ?: 'A visitor', HandoffService::inbox_url( $conversation_id ) ) ) ),
'blocking' => false,
) );
}, 10, 2 );
// Contact-form requests fire siteryn_ai_support_agent_contact_request instead.
Data storage
| Where | What |
|---|---|
{prefix}siteryn_aisup_conversations | One row per conversation: token hash, user, name, email, status, agent, department, priority, timestamps, JSON meta. |
{prefix}siteryn_aisup_messages | Messages: sender type (customer|ai|human_agent|system), content, type (message|note), meta. |
siteryn_ai_support_agent_settings | Settings (option). Pro: siteryn_ai_support_agent_pro_settings. |
siteryn_ai_support_agent_credentials | Encrypted API keys (libsodium). |
siteryn_ai_support_agent_usage | 30 days of requests, cache hits, errors and tokens (UTC). |
siteryn_aisup_faq | Knowledge Base post type. |
siteryn_ai_support_agent_purge_conversations | Daily cron for retention. |
siteryn_ai_support_agent_pro_stats · _pro_gaps · _pro_search_terms Pro | Daily analytics counters (90 days), unanswered questions, catalog search words (30 days) (options). |
siteryn_aisup_agent · siteryn_aisup_handle_chats Pro | The Support Agent role and its capability. Photos: user meta siteryn_aisup_agent_photo. |
_siteryn_aisup_assisted · _siteryn_aisup_stock_alerts · _siteryn_aisup_kb_learned Pro | Order meta (assisted products), product meta (back-in-stock requests), Knowledge Base entry meta (the conversation it was learned from). |
Statuses: ai_active, human_requested, waiting_for_agent, human_active, resolved, closed. Read and change them with Support\ConversationService and Support\MessageService; live chat actions are on LiveChat\HandoffService.
Pro help desk webhook Pro
Events conversation.handoff, conversation.replied (never notes), conversation.status and ping are POSTed as JSON to your HTTPS URL in the background, with up to 3 attempts (retries after 5 and 30 minutes) on network errors, 429 and 5xx. conversation.status isn't sent for the waiting statuses (human_requested, waiting_for_agent).
{
"id": "<uuid>",
"event": "conversation.handoff",
"delivered_at": "2026-10-14T09:30:00+00:00",
"site": "https://your-site.com/",
"conversation": {
"id": 123, "status": "waiting_for_agent", "name": "Sarah", "email": "sarah@example.com",
"customer_id": 0, "agent": null, "created_at": "…", "admin_url": "…",
"messages": [ { "from": "customer", "text": "…", "time": "…" } ]
}
}
Headers: X-Siteryn-Event, X-Siteryn-Delivery (same on retries), X-Siteryn-Timestamp and X-Siteryn-Signature.
$raw = file_get_contents( 'php://input' ); $ts = $_SERVER['HTTP_X_SITERYN_TIMESTAMP'] ?? ''; $ok = hash_equals( 'sha256=' . hash_hmac( 'sha256', $ts . '.' . $raw, $secret ), $_SERVER['HTTP_X_SITERYN_SIGNATURE'] ?? '' ) && abs( time() - (int) $ts ) < 300; // Also de-duplicate on X-Siteryn-Delivery.
With Include Chat Transcript on, conversation.transcript adds the messages from before the visitor asked for a person, in the same format. Change the payload with siteryn_ai_support_agent_pro_webhook_payload. Its id is added after the filter.
See the Hooks reference for every action and filter.