The standard pitch for an AI support bot goes like this: connect your data sources, we will index them, and your bot will answer from them.
Read that again as an engineer. Connect your data sources means build an export. We will index them means your customer records now live in a vendor's system too. And "answer from them" means answer from whatever the last sync managed to copy.
For documentation, that trade is fine — articles change weekly and an index is a reasonable shape for them. For anything live, it is a bad trade. Prices change. Stock changes. Order status changes while the customer is typing. An index is a photograph of a thing that moves.
The other option
There is a much older idea available here, and it does not involve moving your data anywhere: give the model a function and let it call it.
That is what tool calling has been since it shipped. What is unusual is where the function runs. In most implementations it runs on the vendor's servers, which means you are back to giving them credentials, whitelisting their IPs, and building an integration.
We run it in the visitor's browser.
KookeeChat.init({
apiKey: 'YOUR_API_KEY',
tools: [{
name: 'search_orders',
description: "Look up the signed-in customer's orders.",
parameters: { type: 'object', properties: { status: { type: 'string' } } },
handler: ({ status }) =>
fetch('/api/my/orders?status=' + status, { credentials: 'include' })
.then((r) => r.json()),
}],
});
Look at what that handler is. It is a fetch to your API, with credentials: 'include', running in your page, as the visitor who is already logged in.
Three consequences follow, and they are the entire point:
You built no integration. No API key exchanged, no IP allowlist, no webhook, no OAuth app. The endpoint you are calling is one your frontend already calls.
We never keep your data. The tool's name, description and parameter schema reach Kookee — enough for the model to know the tool exists and what to pass it. The handler runs in the browser; its result passes through Kookee to the model for that answer and is not stored. Your order table is not in our database, because we never asked for it.
Auth is already solved. The hardest question in "AI that can see customer data" is which customer. Here it is not a question at all. The request carries the visitor's own session cookie, so it returns their orders and nobody else's — enforced by the same authorization code that protects the rest of your app.
What it looks like to the visitor
Visitor: where's my last order?
Checking your orders…
Assistant: Your order #4821 shipped on Aug 21 and is due Tuesday. Tracking: DHL 0034…
The pause in the middle is the interesting part. The turn genuinely stops: the model asks for a tool, the answer halts, the browser runs your function, the result goes back, and the answer resumes. To the visitor it is one continuous reply with a status line.
How we ended up building it this way
Our first design echoed the conversation history back to the browser between the two halves and let the client send it forward again. Stateless, no server memory, elegant on a whiteboard.
Two reviewers killed it, for reasons worth repeating:
It leaked. Our own help-article search runs server-side and returns full article text — including chatbot_only articles, which are kept out of the public help center. Echoing the turn's messages through the browser would have put them in the network tab of anyone who opened devtools.
It was forgeable. If the client holds the conversation state, the client can edit it. Drop a few entries and the round counter resets — an unbounded loop of model calls on someone else's budget.
So the paused turn lives on the server, in Redis, behind a single-use token with a short expiry. The browser gets only what it needs: which functions to call, with which arguments, and an opaque token to resume with. It cannot see the server's tool results and cannot rewrite the turn.
That token check has its own subtlety we got wrong first: the original code deleted the token before verifying which project it belonged to. Nobody could guess a 24-byte random token, but a wrong-project request would still destroy a legitimate in-flight turn. Read, verify, then delete.
Guardrails, and where we stop
Some things we enforce:
Tool results are wrapped as untrusted data, never instructions.
Citation markers are stripped from them, so a tool result cannot fabricate a source.
Handlers time out at 15 seconds; results are capped at 4000 characters.
A tool that throws returns its error to the model rather than collapsing the answer.
And one thing we deliberately do not do: there is no automatic confirmation before a tool runs. If you register something with side effects — cancel an order, send an email — it executes when the model decides to call it.
We thought about adding a confirm dialog for every tool and decided against it. A confirmation on a read-only product search is friction with no upside, and we cannot tell which is which from a JSON schema. You can. So: register read tools freely, and put a window.confirm inside any handler that writes.
When you should not use this
If the answer lives in your documentation, write an article. Tools are for data that changes faster than you can publish — prices, stock, order status, account state. Anything stable is cheaper, faster and more reliable as a help article, and it gets cited with a link the visitor can open.
The rule of thumb we use: if you would have to redeploy to change the answer, it is a tool. If you would edit a page, it is an article.
Available now in @kookee/react 1.5.0 and @kookee/sdk 1.5.0, and in the script tag with no install at all. Ten tools per widget, which is more than any support assistant we have seen actually needs.