Skip to main content
  • an autoresponder for every text message your commands, triggers and flows did not handle, with a per-chat history;
  • ctx.tools.ai, a client you call from your own handlers, flows and conversations;
  • optional tool calling, long-term memory and RAG and streaming.
It works with Anthropic, OpenAI, Gemini, Ollama (local or cloud) and nine OpenAI-compatible services, on every channel.
Separate packageUnlike slashCommandsPlugin (bundled with the core), @botmesh/ai is its own package, so a bot without AI carries no AI dependencies. The provider SDKs are optional and loaded only when used.

Quickstart (autoresponder)

Install the plugin and the SDK of your provider (here Anthropic, the default):
This registers:
  • A catch-all autoresponder (bot.onMessage) that answers any text message no command, trigger or conversation consumed. It keeps a per-chat history window (20 messages by default, context.maxMessages) in the bot’s store.
  • A /reset command that clears the chat’s AI history and cancels its active conversation (turn it off with resetCommand: false).
Your own handlers always run first: the autoresponder only sees what is left.

WhatsApp AI policy (since 2026-01-15)

General-purpose AI assistants are not allowed on the WhatsApp Business PlatformSince January 15, 2026, Meta’s terms for the WhatsApp Business Platform (the AI Providers section) prohibit general-purpose AI assistants: open-ended, ChatGPT-style chatbots whose main purpose is the AI itself. Using AI in service of the business (customer support, sales, bookings, order tracking) is still allowed.
What that means for aiPlugin:
  • Write a persona scoped to your business (what you sell, opening hours, policies) that politely declines anything unrelated, as in the quickstart.
  • Offer a way to reach a human (handoff) and don’t market the bot as an “ask me anything” assistant.
  • The rule comes from the official Cloud API terms (@botmesh/meta). Baileys is an unofficial WhatsApp Web client and is also subject to WhatsApp’s terms.
This is practical guidance, not legal advice: check the current terms before going live.

Choose a provider

Every provider supports tool calling (on Ollama and the presets, when the model does) and streaming.
Default models are not covered by semverProviders retire models on their own schedule, so the default models follow the current ones and can change in a minor release. Set model explicitly when the choice matters to you (cost, latency, output style).

Anthropic (default)

baseUrl points the SDK at a gateway or proxy. For high-volume chat a smaller model (claude-sonnet-5-5, claude-haiku-5-5) is cheaper and faster.

OpenAI

baseUrl targets any OpenAI-compatible server (vLLM, LM Studio, a gateway) or Azure OpenAI. The output-token cap is sent as max_completion_tokens to OpenAI itself and to Azure OpenAI (api.openai.com, *.openai.azure.com, *.cognitiveservices.azure.com, or *.services.ai.azure.com under /openai), and as max_tokens to any other baseUrl, which is what those servers expect. maxTokensField overrides the choice, for example for a proxy in front of OpenAI:

Gemini

Install @google/genai. The older @google/generative-ai SDK is deprecated by Google; it is still used when @google/genai is missing, with a one-time DeprecationWarning. baseUrl becomes the SDK’s httpOptions.baseUrl.
For Vertex AI, or any client option aiPlugin does not expose, build the provider yourself and pass it as instance:

Ollama (local or cloud)

Ollama runs open models on your own machine. No SDK and no API key: the plugin talks to its HTTP API with fetch.
  • Tool calling needs a model trained for it (Llama 3.1+, Qwen 3, Mistral and the others tagged tools in the Ollama library).
  • No output cap by default: maxTokens is sent as options.num_predict only when you set it, so thinking models are not cut off mid-reasoning.
  • Cloud models through your local Ollama: sign in once (ollama signin) and use a cloud model name (the ones ending in -cloud, such as gpt-oss:120b-cloud) with the same config. Ollama forwards the request to its cloud; your bot still talks to localhost.
  • Ollama’s cloud API directly (no local server), or an Ollama behind a proxy with auth: set apiKey and it is sent as Authorization: Bearer <key> (never logged, never in an error message):

Presets

DeepSeek, Qwen, GLM, Kimi, Groq, OpenRouter, Mistral, Grok and Together are OpenAI-compatible: the plugin reaches them through the openai SDK with their own base URL, so openai is the only package to install. Each preset has a default model and reads its key from its usual environment variable:
apiKey, model and baseUrl override the preset’s values.

Supported SDK versions

The SDKs are optional peer dependencies; install only the one your provider needs. The tests drive each SDK at both ends of its range against a local fake of the HTTP API: plain replies, tool calls with their results, and streaming.

Use the AI from your code: ctx.tools.ai

The plugin also does bot.provide('ai', client), so ctx.tools.ai is available in any handler, flow or conversation. Turn the autoresponder off (autoReply: false) if that is all you want:
ctx.tools.ai is an AIClient: Typing (TypeScript). Importing @botmesh/ai augments the core’s DefaultTools, so ctx.tools.ai (and bot.injectTools.ai outside handlers) is typed as AIClient with no casts. The type does not check that the plugin is installed: without bot.plug(aiPlugin(...)), or after unplugging it, ctx.tools.ai is undefined at runtime.

Thinking (Anthropic)

Claude models think before answering. @botmesh/ai picks the right request for the model you configure (thinking: 'auto', the default):
  • 'adaptive' always sends adaptive thinking; 'off' never sends the parameter (models that always think, such as Claude Opus 5.5, keep doing so: lower effort instead).
  • { budgetTokens } (a positive integer; anything else throws a RangeError when the config is built) enables budgeted thinking (budget_tokens, raised to the minimum of 1024) on the models without adaptive thinking (Haiku 4.5, Sonnet 4.5, Opus 4.5 and older). Adaptive models get adaptive thinking instead: Opus / Sonnet 4.6 still accept budgets but deprecate them, and Opus 4.7+, Sonnet 5+, Haiku 5+, Fable and Mythos reject them. When maxTokens would leave no room for the answer it becomes budgetTokens + maxTokens.
  • If the API still rejects the request with a 400 that names thinking (or effort), the call is retried once without that parameter and the model is remembered, so the next calls skip it. anthropicModelSupport(model) exposes the rules.

Tools, memory, RAG and streaming

Each has its own guide; this is what they look like in the plugin config. Tool calling: the model calls your functions (search the catalog, place an order), with argument validation, timeouts, a human confirmation for confirm: true tools and audit events.
Memory and RAG: memory remembers facts about each user across conversations (stored in ctx.user); rag answers from your documents with numbered citations, through any retriever, such as createRag() from @botmesh/rag.
Streaming: stream: true sends the autoreply while it is generated: native drafts on Telegram, live edits on Discord, sentence chunks with “typing…” on WhatsApp. Turns with tools are not streamed.

Formatting per channel

Models write Markdown (**bold**, # heading, [link](url), - item), but most chat apps do not render it. The autoresponder converts each reply (also when streaming) to the format of the chat, ctx.textFormat: the provider’s per-chat textFormatFor(chatId) when it serves several channels, else its capabilities.textFormat. Code spans and fenced blocks are never touched, and neither are snake_case words or 2 * 3. The stored history keeps the model’s original text. format: 'whatsapp' (or any format) forces one; format: false sends the text as is. formatMarkdown(text, format) and formatMarkdownStream(source, format) are exported for your own handlers. With 'html' only http(s), mailto: and tel: URLs become <a href> links: anything else (a javascript: link a prompt injection could produce) stays as text.

Voice notes

The autoresponder answers text messages only. With the @botmesh/voice plugin, voice notes are transcribed before routing (use autoTranscribe: { asText: true } so the AI answers them) and autoReplyWithVoice can send the answer back as audio.

Language of the built-in replies

The texts the plugin writes for you (the error and refusal replies, the tool confirmation prompt and its Yes/No buttons, the sources line) follow the chat’s language, ctx.lang. English and Spanish are built in; English is the default. A Spanish-only bot sets lang: 'es' on the Bot:
Override any text with bot.locale() under the ai. keys (ai.error, ai.refusal, ai.tools.confirm…, listed in AI_MESSAGES), or set errorMessage, refusalMessage and maxStepsMessage on the plugin. See i18n.

/reset and slashCommandsPlugin

aiPlugin registers its own /reset: it clears the chat’s AI context and cancels the active conversation. Command registration is “last plug() wins”, so if you also use slashCommandsPlugin (which ships its own /reset to cancel conversations), install aiPlugin after it so its /reset takes precedence:

Cost and latency

Every autoreply turn is one paid model call (more with tools). To keep the bill and the wait down:
  • Answer the obvious without AI. Commands, bot.when() triggers and flows run before the autoresponder; FAQs, menus and greetings handled there never reach the model.
  • Pick the model per job. A small model is usually enough for support chat; give the background memory extraction a cheaper one (memory: { autoExtract: { model: '...' } }).
  • Cap the output. maxTokens limits each answer (it includes the thinking tokens of reasoning models); on Anthropic, effort: 'low' thinks less.
  • Send less history. Each turn resends the chat window: lower context.maxMessages for short exchanges. With RAG, fewer and smaller passages (topK, chunkSize) mean a shorter prompt.
  • Bound the tool loop. Each step is one model call: keep maxSteps low and give tools precise descriptions so the model calls the right one first.
  • Stay quiet in groups. groupMode: 'mentions' on the Bot makes the bot answer group messages only when mentioned.
  • Feel faster. stream: true shows the answer as it is written; humanizeTyping (on by default) shows “typing…” meanwhile.
  • Protect your quota. Rate limiting caps how many messages a chat can push through, and timeoutMs bounds each call.
  • Measure. With tracing, every model call is a span with its model and token usage.

Errors and troubleshooting

aiPlugin never crashes the process over a misconfigured provider (SDK not installed, unknown provider, missing API key): install is synchronous and resolves the provider lazily. At startup a warmup logs the problem as an error, and each message then gets the errorMessage reply instead of an answer. A configuration that can never work fails fast instead: aiPlugin() throws a RangeError / TypeError naming the option for a timeout that is not a finite number of ms (a NaN from Number(process.env.X) would fire after 1 ms), a maxTokens that is not a positive integer, a negative or NaN context.maxMessages, invalid memory limits, an unparsable toolConfirm.timeout or a tool without handler. bot.stop() aborts every model call still in flight: the autoreply turns, background memory extraction and the calls your own handlers make through ctx.tools.ai, which reject with an AbortError. A slow provider never keeps the process alive. A call that exceeds timeoutMs rejects with AITimeoutError.

Full aiPlugin(config) reference

The default texts are the English ones; a chat in Spanish gets the Spanish built-ins (see Language of the built-in replies).

Testing

MockAIProvider (from @botmesh/ai/testing) implements AIProvider with fixed or scripted replies and no network calls. Plug it into the real plugin with instance:
Without a bot, AIClient takes the provider and a ContextManager over any ConvStore (the bot’s store is one):
reply can also be a function of the request that returns { toolCalls } to script tool calls (see Testing in the tool calling guide), and mock.calls records every request the client sent. The package has three entries: A custom AIProvider that streams should honour GenerateInput.onStreamEnd: call it once, after the last chunk, only when the stream ends normally (not on an error, an abort or an early break), with an AIStreamEndInfo (stopReason, usage when known). Check signal.aborted before calling it: a client that ignores the signal would otherwise end “normally” after an abort.

Limitations

  • The history is a fixed window of messages (context.maxMessages); older turns are dropped, not summarized.
  • One persona per plugin: a bot that needs several picks them in its own handlers with ctx.tools.ai.generate({ system, messages }).
  • The autoresponder answers text only (see Voice notes).

Next steps