Documentation

AI Sales & Support Chatbot

An AI chatbot for WordPress that answers from your content — indexed on your server, answered by the model you choose, cited back to the source.
WordPress 6.5+
PHP 8.2+
License GPLv2 or later
Multisite supported

Requirements

A standard WordPress install. Nothing is contacted until you configure a provider and an API key.

WordPress
6.5 or newer — tested up to 7.0.2
PHP
8.2 or newer
Database
MySQL 5.7+ / MariaDB 10.3+. MariaDB 11.7+ optionally enables the native VECTOR backend
Multisite
Supported — activation, data removal and uninstall run network-wide
License
GPLv2 or later
Languages
English (US/GB), German, Spanish, French, Italian, Dutch, Polish

There 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.

Option
Default
madcb_embedding_api_url
https://api.openai.com/v1/embeddings
madcb_embedding_model
text-embedding-3-small — free text, any provider’s model name works
madcb_embedding_dimensions
1536 — accepted range 8–8192

3 · 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.

wp-config.phpPHP
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.

Step
What happens
POST /madcb/v1/chat/init
Creates a job row with the message and post_id, fires a non-blocking loopback request plus a WP-Cron safety net, returns 202 with a job_id
GET /madcb/v1/chat/poll/{id}
Polled roughly every 400 ms. Reports pending → writing (streaming chunks) → done
Background worker
Claims the job atomically — a duplicate loopback or cron run exits here — then runs the pipeline below

The pipeline

Stage
Detail
Session + PII masking
History is loaded and the contact interceptor masks email addresses and phone numbers in the message
Context orchestration
Page meta always; hybrid search only in always mode
Prompt assembly
A frozen, cacheable system prefix — instructions, response contract, knowledge rules, tool manifest, persona — then the visitor turn
Answer loop
The model either returns an answer envelope or a tool_request. Tool results come back as conversation turns and the loop re-runs
Persist
Parsed answer, suggestions, links and usage are written to history and the job row

Nothing 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

Check
Result
Document hash unchanged
Stamps indexed_at and stops — nothing is chunked or embedded
Document changed
Re-chunked; only chunks whose own content hash changed go to the embeddings API
Chunks no longer produced
Deleted — but only after the embedding call succeeds, so a failed remote call leaves the previous version fully searchable

The 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:

Improve what gets indexedPHP
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

Trigger
Effect
Post leaves publish
Chunks are purged immediately — draft, pending, private, future or trash
Document no longer resolvable
Chunks are purged rather than left stale — password-protected, unpublished, post type de-selected
Retrieval time
A visibility filter drops any chunk whose backing post is no longer publicly readable, in both backends and both keyword paths
Daily sweep
madcb_daily_index_sweep re-queues posts modified since they were indexed, or that have no chunks — catching drift no save hook would ever see

Retrieval

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

Mode
Behaviour
agentic (default)
PHP resolves page meta only. The model calls search_knowledge_base when it judges it needs site content
always
Hybrid search runs before every message and its results ride the user turn as a <knowledge> block

The 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.

Force a mode in codePHP
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.

Backend
Where vectors live
When to pick it
Vektor (default)
HNSW binary files in uploads/mad-chatbot/vektor/, guarded by .htaccess
Anywhere. Zero infrastructure, works offline, backs up with a normal WordPress dump
MariaDB
A native VECTOR(n) column with an HNSW index, inline in madcb_chunks
MariaDB ≥ 11.7 where you would rather ANN ran server-side

Selection 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 envelopeJSON
{"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

Name
Purpose
search_knowledge_base
Hybrid vector + full-text search over indexed site content. In agentic mode this is how the model reaches site knowledge at all
query_posts
Search posts by keyword and optional post type
get_post
Retrieve a single post by ID
get_page_content
Full rendered content of a single page
list_post_types
List all public post types
get_taxonomy_terms
List terms in a taxonomy
get_posts_by_term
Posts in a taxonomy term

Each 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

Parameters
query (required), limit (1–10, default 5), source_type (optional)
Token budget
Per turn, not per call — five rounds would otherwise inject five budgets. Appends “Showing 3 of 10 matches” when it truncates
Cache
Five-minute transient on the query. A repeat skips embedding entirely, but still draws on the per-turn pool
Empty results
Not an error. The tool says nothing matched this phrasing, states that this does not mean the topic is absent, and invites a re-search
Cards
Deduped per source URL, so several chunks of one post collapse to one card

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.

Register a toolPHP
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.

Both card typesPHP
// 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.

Resolve an id an anonymous visitor may actually readPHP
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;
}
Before shipping a tool
Arguments
Cast and range-checked — absint, min/max, sanitize_text_field
Identifiers
Resolved through a gate that checks type, status, password and viewability
Returned fields
An explicit allowlist. No get_data(), no raw get_post_meta()
Size
Bounded — capped row count, trimmed text. Less surface for injected instructions
Scope
Read-only. A tool that creates orders or applies coupons should not exist on an anonymous chat surface
Tested with
A draft, a private, a password-protected, a wrong-type and a nonexistent id — all should be indistinguishable from “no such thing”

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.

The two interfacesPHP
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;
}
Register it, then queue a documentPHP
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.

Option
Values
Template
default, compact, modern (glass), embed, or your own
Placement
Floating, embedded in a page, or full-page
Colour
Six brand colour fields, plus presets that meet WCAG AA contrast
Layout
Position and panel size, plus custom CSS
Content
Welcome message, suggested follow-up questions, floating teaser bubble
Accessibility
Respects reduced-transparency and high-contrast system settings

Overriding 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.

Extend rather than replaceHTML
{# 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:

ID
Required
Notes
#madcb-root
yes
Root container; receives data-theme
#madcb-panel
yes
Must start with hidden in its class list
#madcb-toggle / #madcb-close
yes
Open and close the panel
#madcb-clear
yes
Clears chat history
#madcb-input / #madcb-send
yes
Text field and send button
#madcb-messages
yes
Message bubble container
#madcb-loading
recommended
Spinner inside the send button, starts hidden
#madcb-bubble-floating
optional
Floating callout bubble
#madcb-overlay
optional
Consent overlay
Style or swap without touching the admin fieldsPHP
add_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.

Reroute or annotate the notificationPHP
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

Telemetry
None. No analytics, no phoning home, no licence check
Your index
Never leaves your server — only the passages needed for the current question are sent
Contact details
Masked before the message reaches the provider
Consent
Optional overlay requiring agreement before the first message
Limits
Per-IP rate limiting, plus retention windows for conversations and logs
Uninstall
Removes every table, option, conversation and index file it created — across a multisite network

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

Tighten it for anonymous visitorsPHP
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

Mechanism
Effect
Background jobs
Replies and indexing both run out of band, so nothing blocks page rendering
Conditional retrieval
Search runs only when the question needs it — around 2,490 ms saved on every message that does not
Prompt caching
The stable prefix is kept byte-identical between turns so supported providers can cache it
Query cache
A five-minute transient on repeated searches, plus a persistent query→embedding cache pruned daily
Incremental indexing
Only changed chunks are re-embedded; an unchanged document costs one hash comparison

Reading the log

Every answer is broken down by stage — retrieval, embedding, API call, persistence — with the mode and search count on the row:

Three turns
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.

Registration
madcb_register_providers
Add an AI backend. Six-method interface, autoloaded only when selected. Not needed for OpenAI-compatible endpoints — use the Custom provider
madcb_register_tools
Register a tool. Must return the registry
madcb_register_context_providers
Register a searchable source
madcb_context_resolve_document
Resolve a document for a custom source during background indexing
Prompt and context
madcb_system_prompt
Append to the frozen system prompt. Keep it byte-identical between turns
madcb_template_context
Variables handed to a prompt Twig template
madcb_context_chunks
Inject or filter retrieved chunks (always mode only)
madcb_retrieval_mode
Force agentic or always
madcb_welcome_message
Override the opening message
madcb_max_message_chars
Cap visitor message length (default 8000)
Content, tools, frontend
madcb_post_content
Transform post text before indexing — the single most useful filter for retrieval quality
madcb_detected_builder · madcb_page_html · madcb_page_text
Three stages of page-builder extraction
madcb_tool_can_read_post
Visibility gate for content-reading tools
madcb_allowed_image_hosts
Hosts whose images may render in cards
madcb_widget_template · _vars · _custom_css
Force a template, inject Twig variables, append CSS
madcb_ratelimit_* · madcb_chat_client_ip
Rate limiting and IP resolution
madcb_prepare_email_args
Recipient, subject, body and headers of the lead email

Three you will reach for first

System prompt, welcome, visibilityPHP
// 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.

Reindex one postPHP
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.