Requirements
A standard WordPress install. Nothing is contacted until you configure a provider and an API key.
VECTOR backendThere is no mad.Design account, licence key or paid tier. You bring your own API key and pay your provider directly.
Setup
Four steps, in order. Until the first one is done the plugin makes no outbound request of any kind.
1 · Choose a chat provider
OpenAI (ChatGPT), Anthropic (Claude), Google (Gemini), or any OpenAI-compatible endpoint — OpenRouter, Azure OpenAI, vLLM, Ollama, LM Studio. For an OpenAI-compatible endpoint pick the Custom provider and enter the URL; no code is needed.
2 · Configure embeddings
Embeddings are configured independently of the chat provider — own URL, key, model and dimension count. Defaults to OpenAI; any OpenAI-compatible service works, including one you host yourself.
madcb_embedding_api_urlhttps://api.openai.com/v1/embeddingsmadcb_embedding_modeltext-embedding-3-small — free text, any provider’s model name worksmadcb_embedding_dimensions1536 — accepted range 8–81923 · Select the post types to index
Content is chunked, embedded and stored on your server. Nothing is indexed until you click Re-index all context documents once — the madcb_index_initialized gate holds the queue closed until then.
4 · Place the widget
Floating, embedded in a page, or full-page for a dedicated support page.
define( 'MADCB_EMBEDDING_API_KEY', 'sk-…' );Both keys can live in wp-config.php instead of the database. The constant takes precedence over the stored option.
Changing the embeddings URL, model or dimensions wipes the index and clears madcb_index_initialized, because vectors of different widths and models are not comparable. Plan a re-index.
How a request runs
Chat is asynchronous. The browser posts a message, gets a job id back immediately, and polls until the job is done — so a slow provider never holds a PHP worker or blocks page rendering.
POST /madcb/v1/chat/initpost_id, fires a non-blocking loopback request plus a WP-Cron safety net, returns 202 with a job_idGET /madcb/v1/chat/poll/{id}pending → writing (streaming chunks) → doneThe pipeline
always modetool_request. Tool results come back as conversation turns and the loop re-runsNothing volatile goes in the system message. It sits ahead of the entire history, so anything per-request there makes every following turn uncacheable.
Indexing
Indexing runs in the background, off a queue. Content is chunked at roughly 400 tokens with 10 % overlap, on heading and paragraph boundaries.
Only what changed is re-embedded
indexed_at and stops — nothing is chunked or embeddedThe document hash covers the content plus chunk size, overlap and the embeddings model, endpoint and dimensions. Metadata is deliberately excluded, so re-saving a post without touching its body costs nothing.
Contextual retrieval
At index time each chunk gets a one-sentence blurb situating it in its parent document, which measurably improves recall on short or ambiguous passages. The blurb is embedded and full-text indexed, but the stored content stays original — the answering model never reads it. Any failure is fail-open: no blurb, indexing continues.
Page builders
Detection order is Elementor, Bricks, Beaver, Brizy, SiteOrigin, Thrive, Divi, WPBakery. Posts are indexed with their rendered content rather than raw shortcodes. Override the extraction where a builder stores text somewhere unusual:
add_filter( 'madcb_post_content', function ( string $content, WP_Post $post ) {
if ( $post->post_type === 'product' ) {
$content .= "\nSKU: " . get_post_meta( $post->ID, '_sku', true );
}
return $content;
}, 10, 2 );Three narrower hooks sit alongside it: madcb_detected_builder (force or disable a builder path), madcb_page_html (strip nav, footers or a cookie banner from the rendered markup) and madcb_page_text (the final collapsed plain text).
Staying honest
publishmadcb_daily_index_sweep re-queues posts modified since they were indexed, or that have no chunks — catching drift no save hook would ever seeRetrieval
Search is hybrid: a semantic vector pass and a keyword full-text pass, fused with Reciprocal Rank Fusion (K = 60) so a page is found whether the visitor uses your vocabulary or their own. The fused list is then trimmed to a token budget, default 2000.
The keyword pass quotes every term as a phrase, requiring only the longest one. That neutralises -, +, * and @ inside a term — an email address would otherwise be parsed as boolean operators and invert the search. Hosts without a FULLTEXT index fall back to LIKE with hit-count scoring.
Two modes
agentic (default)search_knowledge_base when it judges it needs site contentalways<knowledge> blockThe default changed because retrieval cost around 2,490 ms of a 7,527 ms turn in production — an embedding call plus two searches — paid on every message, including “hallo” and “danke”. A knowledge question now costs one extra round-trip; everything else saves the full 2,490 ms. Quality improves too, because the model writes a focused search phrase instead of the raw message being embedded verbatim.
add_filter( 'madcb_retrieval_mode', fn() => 'always' );If tool calling is off, or search_knowledge_base is disabled, agentic downgrades to always automatically. Without that the bot would have no site knowledge and no way to ask for any.
Page context
Any page can carry its own note in _madcb_page_context post meta. It is read fresh per request, never indexed, and always prepended — so it runs in both modes and costs no embedding call. The page identity travels with each message rather than with the session, so navigating mid-conversation swaps the page context while keeping the full history.
History turns are not labelled with the page that was active when they were generated. Ask about page A, then page B, and A’s answers stay in history while only B’s meta is in the prompt — the model can conflate them.
Vector store
The store sits behind a seven-method interface with a factory that picks the backend at runtime. Two implementations ship.
uploads/mad-chatbot/vektor/, guarded by .htaccessVECTOR(n) column with an HNSW index, inline in madcb_chunksSelection is a dropdown in Settings → Knowledge. If MariaDB is chosen but unsupported — or the constructor throws for any reason — it logs a warning and falls back to Vektor rather than failing the request. Switching backends wipes the newly selected one and forces a re-index.
Maintenance
Vektor tombstones on delete and upserts as delete-then-insert, so its files grow monotonically with edit volume. Compaction vacuums them and rebuilds the graph in place; it is opt-in, scheduled off/weekly/monthly, independent of the daily crons. MariaDB needs none — InnoDB reclaims pages itself.
While Vektor compaction runs, search returns empty immediately instead of blocking on the file lock. Retrieval degrades to keyword-only for the duration rather than stalling requests.
A failed embeddings call degrades retrieval to keyword-only rather than erroring the turn. If answers suddenly stop citing indexed content, check the log table — nothing else will announce it.
Tool calling
There is no separate dispatch model. The answering model decides inside its normal reply whether it needs a tool, by emitting an envelope instead of an answer:
{"tool_request": {"tool": "<slug>", "args": { … }, "note": "<why you need it>"}}The dispatcher validates arguments, invokes the callback, and appends the result as a user turn — never as a system-prompt edit, so the cacheable prefix is never disturbed. A reply without tool_request is the final answer. Rounds are capped by Settings → Tools → maximum tool calls per turn (default 3, range 1–5); on the last round the model is asked once for a final answer.
Built-in tools
search_knowledge_baseagentic mode this is how the model reaches site knowledge at allquery_postsget_postget_page_contentlist_post_typesget_taxonomy_termsget_posts_by_termEach can be switched off individually. A disabled tool is stripped from the manifest and re-checked at execute time, so it cannot be invoked even if the model names it from a cached prompt.
Inside search_knowledge_base
query (required), limit (1–10, default 5), source_type (optional)Indexed prose has its <knowledge> and <page_context> fences stripped, so author-controlled text cannot close a fence and impersonate an instruction.
Writing a tool
Register through madcb_register_tools. The callback receives the validated arguments and returns a result object; text_summary is what the model reads.
add_filter( 'madcb_register_tools', function ( MAD_Chatbot_Tool_Registry $registry ) {
$registry->register( new MAD_Chatbot_Tool_Definition(
name: 'get_hours', // [a-z0-9_] only, unique
label: 'Get Opening Hours',
description: 'Opening hours for one location. Pass the location slug.',
parameters: [
[ 'name' => 'location', 'type' => 'string', 'required' => true,
'description' => 'Location slug' ],
],
callback: function ( array $args ): MAD_Chatbot_Tool_Result {
$hours = my_plugin_get_hours( $args['location'] );
return new MAD_Chatbot_Tool_Result(
tool_name: 'get_hours',
args: $args,
data: $hours,
text_summary: '[Tool: get_hours] ' . $hours['text'],
links: []
);
}
) );
return $registry; // returning anything else drops every filter-registered tool
} );The description is the entire specification the model gets — say what the tool returns and when not to call it. It also lives in the cacheable prefix, so editing it invalidates prompt caching for every open session.
Result cards
The links array renders below the assistant message. Two card types, mixable, persisted in the session history and restored when the panel reopens.
// Small clickable badge — good for many lightweight references.
$links[] = MAD_Chatbot_Tool_Card::link( 'Pricing', 'https://example.com/pricing' );
// Rich card — rendered as a horizontally scrollable row.
$links[] = MAD_Chatbot_Tool_Card::card(
title: get_the_title( $post ),
subtitle: wp_trim_words( get_the_excerpt( $post ), 15, '…' ),
image: get_the_post_thumbnail_url( $post->ID, 'medium' ) ?: '',
url: get_permalink( $post ),
label: 'Read more',
);Card images only render from allowlisted hosts — your own site by default. Add others with madcb_allowed_image_hosts, or they are dropped silently.
Gate everything the model sends you
A tool callback is not an admin screen. The arguments were written by a language model, the visitor is anonymous, and whatever you return is paraphrased straight back to them. The dispatcher checks that the tool exists, is enabled and that required parameters are present — then hands you the arguments verbatim.
function my_gate_post( $raw_id, array $post_types ): ?WP_Post {
if ( ! is_scalar( $raw_id ) ) return null;
$post = get_post( absint( $raw_id ) );
return $post
&& in_array( $post->post_type, $post_types, true )
&& $post->post_status === 'publish'
&& $post->post_password === ''
? $post : null;
}absint, min/max, sanitize_text_fieldget_data(), no raw get_post_meta()Author-supplied text arrives in the model’s context as data. The plugin instructs the model to ignore directives inside retrieved passages, but that is defence in depth, not a guarantee.
Custom context sources
A context provider makes a source beyond posts and pages searchable — a CRM, an external docs site, a custom table. Implement the base interface to be searched live; add the indexable interface to be embedded into the vector index as well.
interface MAD_Chatbot_Context_Provider_Interface {
public function get_slug(): string;
public function get_label(): string;
public function is_enabled(): bool;
public function get_chunks( string $query, array $options = [] ): array;
}
interface MAD_Chatbot_Indexable_Provider_Interface extends MAD_Chatbot_Context_Provider_Interface {
public function get_all_documents(): array; // MAD_Chatbot_Document[]
public function get_document( string $id ): ?MAD_Chatbot_Document;
}add_filter( 'madcb_register_context_providers', function ( array $providers ): array {
$providers['crm'] = My_CRM_Context_Provider::class;
return $providers;
} );
( new MAD_Chatbot_Indexing_Queue() )->enqueue( 'crm', '42' );get_all_documents() must return your provider’s complete, current set. A re-index prunes any previously indexed id under your slug that the call does not return — a forgotten page is content that gets deleted from the index. Pruning is skipped entirely if the call throws or returns nothing, so a timed-out fetch leaves the index intact instead of wiping it.
For source types with no indexable provider, madcb_context_resolve_document is the fallback during background indexing. Returning null for a source that was indexed purges its chunks — that is how deletions propagate.
To inject live, per-visitor data that cannot be pre-indexed — an order status, a logged-in user’s plan — append to madcb_context_chunks instead. It fires immediately before the model call, in always mode only, and injected chunks bypass the token budget, so keep them short.
Appearance
The widget is meant to look like part of your site rather than a bolted-on box.
default, compact, modern (glass), embed, or your ownOverriding a template
Templates are Twig. A file in your theme wins over the plugin’s built-in of the same name; a file with a new name appears in the template dropdown by itself.
{# your-theme/mad-chatbot/widget/default.twig #}
{% extends '@plugin/default.twig' %}
{% block panel_header %}
<div class="my-custom-header">…</div>
{% endblock %}Blocks: floating_bubble, toggle_button, overlay, panel_header, messages_area, input_area. Prompt templates live alongside them under mad-chatbot/prompts/ and are overridable the same way.
IDs the JavaScript binds to
A custom template must keep these, or the widget stops working:
#madcb-rootdata-theme#madcb-panelhidden in its class list#madcb-toggle / #madcb-close#madcb-clear#madcb-input / #madcb-send#madcb-messages#madcb-loading#madcb-bubble-floating#madcb-overlayadd_filter( 'madcb_widget_template', fn( $t ) => is_product() ? 'compact' : $t );
add_filter( 'madcb_widget_custom_css', fn( $css ) =>
$css . '#madcb-send { border-radius: 999px; }' );Leads and conversations
The bot collects an email address or phone number in conversation, stores every conversation in WordPress where you can read it in the admin, and emails your team the full conversation as soon as a contact detail appears.
add_filter( 'madcb_prepare_email_args', function ( array $args, array $data ) {
$args['headers'][] = 'Bcc: crm@example.com';
$args['subject'] = 'Lead: ' . $data['user_email'];
return $args;
}, 10, 2 );$data carries post_id, user_email, history (last 50 turns) and admin_link. Dropping to, subject or body cancels the send.
Contact details are masked before the message reaches the AI provider. The originals stay in your own database, so you can still follow up.
Privacy and data
Messages are processed by a third-party AI provider, so you must still disclose that in your privacy policy. No plugin can do that part for you.
The one cookie
When keep chat open across navigations is enabled, a single cookie madcb_open records whether the panel was open — value 1, path /, 30 days, SameSite=Lax, HTTPS only, set only when the visitor opens the chat. No identifier, no personal data. It is read server-side so the panel renders in the right state with no flash of the wrong one.
Suggested wording under “functional / strictly necessary”: “madcb_open — remembers whether the chat panel was open during your visit. Set only when you open the chat. No personal data. Expires after 30 days.”
On a full-page-cached site the cached HTML will not reflect the cookie unless the cache key varies on madcb_open. The panel still opens — the JavaScript reconciles on init — just with a brief pop-in.
Rate limiting
add_filter( 'madcb_ratelimit_enabled', fn( $on ) => ! is_user_logged_in() && $on );
add_filter( 'madcb_ratelimit_max', fn() => 10 );
add_filter( 'madcb_ratelimit_window', fn() => 60 );The IP resolver checks REMOTE_ADDR and four forwarded-IP headers. Behind a proxy using a different header, override it with madcb_chat_client_ip — an IP you cannot trust makes the limit bypassable.
Performance and cost
Reading the log
Every answer is broken down by stage — retrieval, embedding, API call, persistence — with the mode and search count on the row:
openai · gpt-5.6-luna · agentic, no KB search · 1 API round-trip · cache 93.3%
openai · gpt-5.6-luna · 1 tool call · agentic, 2 KB searches (cached)
openai · gpt-5.6-luna · 5/13 chunks · always-on retrieval(cached) is the important word. Without it a fast turn is ambiguous between “correctly skipped retrieval” and “searched but hit the transient” — opposite signals.
Filters and actions
An overview, not a specification — the complete reference with signatures and examples ships with the plugin. All of these fire on plugins_loaded or later, so a small mu-plugin or your theme’s functions.php is early enough.
madcb_register_providersmadcb_register_toolsmadcb_register_context_providersmadcb_context_resolve_documentmadcb_system_promptmadcb_template_contextmadcb_context_chunksalways mode only)madcb_retrieval_modeagentic or alwaysmadcb_welcome_messagemadcb_max_message_charsmadcb_post_contentmadcb_detected_builder · madcb_page_html · madcb_page_textmadcb_tool_can_read_postmadcb_allowed_image_hostsmadcb_widget_template · _vars · _custom_cssmadcb_ratelimit_* · madcb_chat_client_ipmadcb_prepare_email_argsThree you will reach for first
// Persona and policy — this is the cached prefix, so keep it constant.
add_filter( 'madcb_system_prompt', fn( $p ) => $p . "\n\nAlways answer in formal German (Sie)." );
// Per-visitor greeting. Shown, never stored — the model is told it already said it.
add_filter( 'madcb_welcome_message', fn( $m ) =>
is_user_logged_in() ? 'Welcome back! How can I help?' : $m );
// Keep a post type out of reach of every content-reading tool.
add_filter( 'madcb_tool_can_read_post', fn( bool $ok, WP_Post $post ) =>
$post->post_type === 'internal_note' ? false : $ok, 10, 2 );Actions
Mostly internal, but useful for scripted maintenance: madcb_index_document indexes one source now, madcb_daily_prune, madcb_daily_index_sweep, madcb_daily_log_cleanup and madcb_index_compact run their maintenance immediately.
do_action( 'madcb_index_document', 'post', '123' );Do not call madcb_process_job directly — it verifies a hash of the job id and will not run without a valid secret.