tools, the model behind @botmesh/ai can call functions of
your bot (search the catalog, look up an order, create a booking) through each
provider’s native tool / function calling. You declare every tool once; the
plugin maps it to Anthropic, OpenAI, Gemini or Ollama, runs a multi-step loop
and feeds the results back to the model until it writes the final answer.
Declaring tools
tools is an AITool; the key is the name the model sees.
defineTool<TArgs>(tool) is an identity helper: it only infers the argument
type for the handler, so your editor autocompletes args.
Schemas
- JSON Schema: sent to the model as is and validated with a built-in
subset (
type,required,properties,enum,const,items,additionalProperties: false,minimum/maximumand their exclusive forms,minLength/maxLength,minItems/maxItems). safeParsevalidators (zod and similar): the arguments are validated withsafeParse()and the handler receives the parseddata. The model-facing schema comes from the validator’stoJSONSchema()when it has one (zod v4); otherwise passjsonSchemayourself.
enum for closed sets such as product keys: in a live test a model
passed "chocolate cake" instead of the menu key chocolate_cake, and only the
enum stops that before the user is asked.
The handler context
The second argument ofhandler is an AIToolContext:
The tool loop
Every turn runs a loop: call the model with the tool definitions; while it asks for tools, validate the arguments, ask for confirmation when needed, run each handler under its timeout and send the results back.maxSteps(default5) caps the model calls per turn. Tool calls requested in the last allowed step are not run (the model would never see their results): the turn repliesmaxStepsMessageand emitsloop.max_steps.- Several calls in the same step run one after another, in order.
- Errors go to the model, never to the user: a handler that throws, invalid arguments, an unknown tool, a timeout or a declined confirmation become failed tool results, so the model can recover or explain.
- Timeouts:
toolTimeoutMs(default15000,0disables) applies to every tool unless the tool sets its owntimeoutMs. On expiry the handler’ssignalis aborted. Each model call keeps the plugin’stimeoutMs. - The stored chat history stays plain user/assistant text: the intermediate tool turns are not persisted.
Human confirmation
A tool withconfirm: true only runs after a person approves it.
In the autoresponder the plugin asks the user itself. The question
(confirmMessage, or a readable default, see below) is asked inside a runtime
conversation, so the handler returns and the chat’s FIFO queue is released:
the user’s answer reaches the conversation and the loop resumes. It never
deadlocks the chat.
The answer is classified with classifyConfirmation():
- A clear yes (
yes,ok,sure,yes please,sí…) runs the tool. - A clear no (
no,no thanks,cancel,cancelar…) declines it and the model is told not to retry it. - Anything else, including a yes that adds changes (
yes but make it 5), does not run the tool and is not discarded: the model gets{ "executed": false, "reason": "user_replied", "userReply": "…" }, so it can call the tool again with the corrected arguments (the user confirms again) or answer the question.
DEFAULT_YES) are
si, s, yes, y, ok, okay, confirmo, confirmar, dale, claro,
de acuerdo, acepto, 1, vale, listo, perfecto, adelante, hazlo,
correcto, esta bien, sure, yeah and yep; the “no” words
(DEFAULT_NO) are no, n, nop, nope, nel, cancelar, cancela,
cancelo, cancel, negativo, mejor no, para nada, tampoco,
no gracias, 2 and 0. Change them with toolConfirm.yes / toolConfirm.no;
both lists are frozen, so extend a copy (yes: [...DEFAULT_YES, 'arre']).
- No double confirmation: when the turn has a confirmer, the system prompt
gets a localized note (
ai.tools.confirmNote) listing theconfirm: truetools and telling the model not to ask “shall I place it?” itself but to call the tool as soon as the user expresses the intent.toolConfirm.systemNotereplaces it with your own text or turns it off (false). toolConfirm.timeout(default'2m') bounds the wait. A timeout or/resetcancels the turn silently;/resetalso cancels it after the approval, so the stale turn neither replies nor writes into the history it just cleared.- Turns of the same chat are serialized: a new message waits for a detached turn to finish. A confirmation never replaces a conversation the user started meanwhile; the turn is cancelled instead and does not reply on top of that flow.
Yes/No buttons
On channels that render native buttons (capabilities.buttons: Telegram
inline keyboards, WhatsApp Cloud API reply buttons, Discord, Messenger and
Instagram quick replies…) the question goes out as a buttons message with
localized labels (Yes / No, Sí / No in Spanish; keys
ai.tools.button.yes / ai.tools.button.no) and the same summary, without the
typed-answer hint (ai.tools.confirmButtons: About to: {tool}{args}\nDo you confirm?).
- A press resolves the confirmation exactly like typing yes or no, and the
interaction is acknowledged (Telegram
answerCallbackQuery, Discord’s 3-second ack). Typed answers keep working alongside the buttons, including a reply with changes that goes back to the model. - Once answered (or timed out / cancelled), the buttons are removed by editing
the message into text with the choice (
→ Yes) where the provider can edit and renders native buttons (Telegram, Discord). - A press on a confirmation that is no longer pending (answered, superseded by
a newer one, timed out,
/reset) is acknowledged and ignored, before any routing: each confirmation has its own button ids, so an old press never answers a newer confirmation nor a later question of a conversation. - Channels that render native buttons but cannot edit messages (WhatsApp
Cloud API, Messenger, Instagram, Gupshup, TikTok) leave the buttons on screen.
There, a press on a confirmation that
is no longer pending gets a short localized reply (
ai.tools.confirmExpired: “That confirmation is no longer active.”);toolConfirm.expiredNotice: falsedrops it silently. A chat handed off to a human never gets this notice (the press is only acknowledged). Optionally,toolConfirm.answeredNotice: truealso repliesai.tools.confirmAnswered(“Answer received: Yes.”) when a press answers; it is off by default because those channels already show the user’s pick as their own message. Neither notice is sent where the buttons are edited away or where the confirmation is a numbered text menu (Baileys, Evolution, WPPConnect, Twilio, email, GoHighLevel): there a later answer is just text. - Channels without native buttons (WhatsApp via Baileys, Evolution, WPPConnect,
Twilio, SMS, email, GoHighLevel…) keep the typed question with the
(yes/no)hint; a later answer there is plain text, with no expired-confirmation notice. toolConfirm.buttons: falsealways asks for a typed answer. A tool’sconfirmMessageis used as is in both modes. Abot.locale()override ofai.tools.confirmis reused for buttons when you don’t translateai.tools.confirmButtonstoo.
ai.tools.confirm message with the tool
title and one short line per argument; a single list argument shows one line
per item, and items with a quantity read as quantity × product:
ctx.lang is es (for
example in a bot with lang: 'es') gets the Spanish one (Voy a: … ¿Confirmas? (sí/no)). A chat in a language without built-in
texts (say fr) gets the bot’s default locale when that is es or en, else
English. Override it per language with
bot.locale('en', { ai: { tools: { confirm: 'Run {tool}?{args}' } } })
({tool} is the title, {args} a newline plus the • lines, or empty), or
set confirmMessage on the tool.
Readable values and totals
Catalog keys likecheese_bread are good for the model and bad for the user.
Two tool options improve the default prompt without writing it yourself:
labelsmaps argument values to human labels, per argument key (also inside list items). Values without a label are shown as-is; the model and the handler keep receiving the raw keys.confirmLines(args)returns the lines to show instead of the arguments, inside the same localized wrapper. Use it to show what your code computed (prices, the total), so the user never confirms a number the model made up.confirmMessagestill wins over both.
argLines(args, ctx, labels) and summarizeValue(value, ctx, depth, labels)
are exported if you build your own prompt.
Inside your own conversation pass confirmWith(c). It never blocks the
queue either, and on a channel with native buttons it asks with the same Yes/No
buttons as the autoreply (same semantics: typed answers keep working, the
press is acknowledged, the buttons are edited away where possible, and a later
press is acknowledged and ignored instead of answering your next c.ask()).
Elsewhere, or with buttons: false, it asks a typed yes/no with c.ask(). The
buttons go to the chat of the ctx you pass to replyInChat (or the ctx
option).
confirmWith(c, { yes, no, timeout, buttons, ctx, answeredNotice }) classifies
the answer like the autoresponder: 'approved', 'denied' or { reply }. A
conversation control error (timeout, cancel, replaced conversation) propagates
out of the loop as usual, so the runtime handles it.
Without a confirmer (for example a bare ctx.tools.ai.ask()), confirm: true tools are denied and the model is told human confirmation was not
available.
You can also write your own ToolConfirmer: an async function that receives
{ tool, callId, args, description, title, prompt, previous? } and resolves
'approved', 'denied', { reply: string } (the user’s free-text answer,
handed to the model) or 'cancelled'. 'cancelled' aborts the whole turn:
the loop throws AIToolCancelledError (with the tool name).
Repeated side effects
Models sometimes call a side-effecting tool again on their own (for example to “fix” an order total after the user said “nothing else”). The client remembers the successful runs ofconfirm: true and dedupe tools per chat (in memory,
for 30 minutes; /reset and ctx.tools.ai.clearToolHistory(chatKey) forget
them) and uses them three ways:
- Confirmation notice: when a
confirm: truetool already ran in the chat with the same arguments, the prompt starts with a localized line (ai.tools.confirmRepeat):Heads-up: I already did «Place order» a moment ago (result: …). Confirming will do it again.The confirmer also receives it asprevious. A call with different arguments (changing 3 → 2 units) is a new action: no notice, noprevious. The result summary leaves out fields too nested to read (lists of objects). - System prompt: the next turns list the actions already run
(
ai.tools.doneNote), asking the model not to repeat them unless the user asks explicitly. dedupe(opt-in per tool): a call with the same validated arguments as a successful run within the window is not run and not confirmed; the model gets{ "executed": false, "reason": "duplicate", "previous": { "result", "secondsAgo" } }.dedupe: 'args'uses a 10-minute window;dedupe: { windowMs: 60_000 }sets your own. It also works for tools withoutconfirm.
error (for example
{ error: 'No order P-123' }) did nothing, so it is not remembered: no repeat
notice, no “already done” line and no dedupe block.
toolConfirm.systemNote: false also turns off the system prompt list.
Best practices for side-effecting tools
These rules come from live tests with real models on WhatsApp. Without them a model will miscalculate an order total in free text, show catalog keys to the customer, or claim it “replaced” an order when it had no tool to do so.- Never let the model compute money. Prices, subtotals and totals are
computed in your code: offer a quote tool (no
confirm, no side effects) for “how much is…?”, compute the total again in the handler that stores the order, and show it in the confirmation withconfirmLines(orconfirmMessage). Return the computed total in the tool result so the model can repeat it instead of calculating it. - Validate catalog values with
enum. Product keys, sizes, branches…: arguments are validated before the confirmation, so an invented product goes back to the model as an error and the user never confirms an impossible order. Pair the keys withlabels(or names inconfirmLines) so the user sees “Cheese bread”, notcheese_bread. - Give every create its update, cancel and list tools. If the model can
only create, it will “fix” an order by creating another one, or say it
changed something it could not. A list tool (
my_orders) gives it the ids to update or cancel. - Use
dedupeon create tools (dedupe: 'args'), so an identical order is not placed twice in a row when the model calls the tool again on its own. - Return an explicit
{ error }for unknown ids ({ error: 'No active order P-123. Check my_orders.' }), never a silentokornull. The model reads it and tells the user; the run is not remembered as done. - Keep
confirm: trueon everything with side effects (create, update, cancel, pay, book). Read-only tools (menu, quote, list) do not need it. - Rely on the system note, do not fight it. With a confirmer, the system
prompt tells the model which tools the system confirms, not to ask “shall I
place it?” itself, not to mention the confirmation process and never to
claim it did, changed, replaced or cancelled anything a tool result did not
confirm (and to say so plainly when it has no tool for a request). Keep it
on (
toolConfirm.systemNote) unless you replace it with an equivalent text.
create-botmesh ai-full template ships the same
tools, with the orders kept in ctx.user.
For a complete, runnable version in English, see the
bakery AI assistant example (examples/bakery-ai). It runs the same
bot on WhatsApp through Baileys (QR) or Twilio (webhook + @botmesh/server) and
includes typed env config and tests with MockAIProvider.
Audit events
Every step of the loop emits anAIToolEvent, both to onToolEvent and on
the bot as 'ai.tool':
Each event carries
at (ms epoch), step (1-based), and when relevant tool,
callId, chatKey, args, durationMs and error.
Providers
Streaming calls (
stream(), streamReplyInChat()) never send tools.
Using tools from your code
Withtools configured, ctx.tools.ai.ask() and ctx.tools.ai.replyInChat()
run the loop automatically. Both accept { ctx, confirm, signal, chatKey }.
For full control, runTools() returns the loop result:
runToolLoop() is the same loop without an AIClient: you pass the tools, a
generate function and the messages.
Testing
MockAIProvider can script tool calls: return { toolCalls } from reply to
make the “model” call a tool, and plain text to finish. Build an AIClient
with your tools and assert on the result:
mock.calls keeps every request the client sent (with its messages and
tools), so you can also assert what the model saw.
Next steps
- AI plugin — providers and the full
aiPluginreference - AI memory & RAG — per-user facts and answers from your documents
- Conversation flows — the runtime that hosts confirmations
- Bakery AI assistant example — these practices in a runnable WhatsApp bot (Baileys or Twilio)