Overview
Website tools let the agent in the Sarufi web widget call JavaScript functions on your page. The function runs in the visitor's browser, so the agent can search your site, or answer "what is my balance?" through your own API, without you opening a new API to Sarufi.
There are three ways to use them. Start with the simplest one that fits.
| You want | Path | Definitions come from | Backend or token needed |
|---|---|---|---|
| Public features anyone can use: search, store hours, stock | Public site tools | A tool set in your workspace, linked to the bot | No |
| Features for a signed-in user: balance, orders, payments | Account actions | A tool set, with "Needs signed-in user" on | Yes: a host token per user |
| Definitions kept next to your code, shipped with your release | Declare tools in page code | ChatWidget.tool() on the page | Yes: a host token that also signs the tools |
In every path the page only runs tools. What the model is told about a tool comes from you, never from unsigned page code.
Read the safety rules before going live
A tool call is a request your page makes on the visitor's behalf, with arguments chosen by a model. Safety lists what your server must still check.
Public site tools
The whole setup for a public site search, with no backend and no token.
1. Create a tool set and connect it. In the dashboard, open your bot's Integrations, then Website tools, Create tool set. Add a tool:
- name
search_site; - what it does: "Search this website's pages and products.";
- one text input,
query, required; - status while running: "Searching the site" (optional);
- leave "Needs signed-in user" off.
Save. A tool set made from a bot's page is connected to that bot automatically.
2. Register the handler after the widget loads. ChatWidget is defined by the embed snippet of your web integration, so your code must run after that script has loaded:
<script src="https://your-deployment.example.com/widget.js" data-widget-key="YOUR_CHATBOT_ID" async></script> <script> window.addEventListener("load", () => { ChatWidget.registerTools({ search_site: async ({ query }) => { const hits = await fetch(`/search.json?q=${encodeURIComponent(query)}`).then((r) => r.json()); return hits.slice(0, 5).map((h) => ({ title: h.title, url: h.url })); }, }); }); </script><script src="https://your-deployment.example.com/widget.js" data-widget-key="YOUR_CHATBOT_ID" async></script> <script> window.addEventListener("load", () => { ChatWidget.registerTools({ search_site: async ({ query }) => { const hits = await fetch(`/search.json?q=${encodeURIComponent(query)}`).then((r) => r.json()); return hits.slice(0, 5).map((h) => ({ title: h.title, url: h.url })); }, }); }); </script>
That is all. The widget checks the args against the tool's schema before your function runs, and catches anything it throws (see What the widget guarantees).
Test on your own site. The dashboard playground does not run website tools, because they live in your page. Open your site, ask "search for blue shoes", and run ChatWidget.debug(true) in the console if nothing happens.
Tool sets and links
Website tools are defined in tool sets at the workspace level and linked to chatbots, the same way knowledge bases and MCP servers are. A bot offers only the tools of its linked tool sets. The dashboard does all of this; the API is here for automation.
GET /api/dev/v1/workspaces/{workspace_id}/host-tools GET /api/dev/v1/workspaces/{workspace_id}/host-tools/{set_id} PUT /api/dev/v1/workspaces/{workspace_id}/host-tools/{set_id} PUT /api/dev/v1/chatbots/{chatbot_id}/host-tools/links Authorization: Bearer <api-key>GET /api/dev/v1/workspaces/{workspace_id}/host-tools GET /api/dev/v1/workspaces/{workspace_id}/host-tools/{set_id} PUT /api/dev/v1/workspaces/{workspace_id}/host-tools/{set_id} PUT /api/dev/v1/chatbots/{chatbot_id}/host-tools/links Authorization: Bearer <api-key>
PUT on a tool set creates or replaces it:
{ "name": "Wallet", "tools": [ { "name": "get_balance", "description": "The signed-in user's current wallet balance.", "parameters": { "type": "object", "properties": {} }, "timeout_ms": 15000, "requires_verified_user": true, "status": "Checking your balance" }, { "name": "list_transactions", "description": "The user's most recent transactions, newest first.", "parameters": { "type": "object", "properties": { "limit": { "type": "integer", "minimum": 1, "maximum": 50 }, "direction": { "enum": ["in", "out"] } } }, "timeout_ms": 15000, "requires_verified_user": true } ] }{ "name": "Wallet", "tools": [ { "name": "get_balance", "description": "The signed-in user's current wallet balance.", "parameters": { "type": "object", "properties": {} }, "timeout_ms": 15000, "requires_verified_user": true, "status": "Checking your balance" }, { "name": "list_transactions", "description": "The user's most recent transactions, newest first.", "parameters": { "type": "object", "properties": { "limit": { "type": "integer", "minimum": 1, "maximum": 50 }, "direction": { "enum": ["in", "out"] } } }, "timeout_ms": 15000, "requires_verified_user": true } ] }
GET on a tool set returns { "set_id", "name", "tools", "linked_chatbots", "updated_at" }, and GET on the collection lists them. Link tool sets to a bot by sending the full list:
{ "set_ids": ["wallet-k3x9a1", "shop-0p2m7q"] }{ "set_ids": ["wallet-k3x9a1", "shop-0p2m7q"] }
Rules and limits
| Tool set id | ^[a-z0-9][a-z0-9-]{2,63}$, for example wallet-k3x9a1 |
| Tool set name | at most 80 characters |
| Tool name | ^[a-z][a-z0-9_]{1,47}$, unique across all of a bot's linked tool sets |
| Description | at most 1024 characters |
parameters | an object JSON Schema of at most 8 KB, whole numbers only |
timeout_ms | default 30000, from 1000 to 300000 |
requires_verified_user | true: offered only with a valid host token. false: offered to every visitor |
concurrency | "parallel" (default) or "serial"; use serial for anything that changes data |
status | optional, at most 60 characters of plain text the visitor sees while it runs, such as "Checking your balance" |
| Per tool set | at most 24 tools |
| Per bot | at most 8 linked tool sets, and 24 tools across them |
A builtin or MCP tool with the same name always wins. Linking or saving a tool set whose names clash with another tool set linked to the same bot is refused:
{ "code": "tool_clash", "detail": "search_site is in both Shop and Docs", "tools": ["search_site"] }{ "code": "tool_clash", "detail": "search_site is in both Shop and Docs", "tools": ["search_site"] }
Account actions in 6 steps
For tools that act for a signed-in user, such as a wallet balance, Sarufi needs to know who the user is. Your server says so with a short-lived host token.
- Turn on "Needs signed-in user" for each personal tool in its tool set. Without a valid token those tools are never offered.
- Add your site to the web integration's allowed domains. Host tokens are refused for a bot with no allowed domains.
- Find your chatbot id and webhook secret in the bot's Settings: the chatbot id on General, the secret on Webhook. Keep the secret on your server only.
- Add a
/chat-tokenroute to your server that requires your own sign-in and returns a token for the current user (below). - On the page, fetch the token and pass it to the widget, and refresh it before it expires.
- Register the handlers, calling your own API with the user's session cookie.
The token helper
The token proves which user and which site. This minimal helper is all you need when your tools are defined in tool sets. Tools declared in page code need one more claim; see Signing host tokens, which also has a Python version.
// Node, server side only import { createHmac } from "node:crypto"; function canonical(v) { if (v === null || typeof v !== "object") return JSON.stringify(v); if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`; return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`; } export function hostToken({ chatbotId, userId, origin, ttlSeconds = 900, secret }) { const now = Math.floor(Date.now() / 1000); const body = canonical({ chatbot_id: chatbotId, user_id: userId, origin, exp: now + ttlSeconds }); const key = createHmac("sha256", secret).update("host-token").digest(); const v1 = createHmac("sha256", key).update(`${now}.${body}`).digest("hex"); return `${Buffer.from(body).toString("base64url")}.t=${now},v1=${v1}`; } // Express: only for a signed-in user (requireSession is your own middleware) app.get("/chat-token", requireSession, (req, res) => { res.set("Cache-Control", "no-store").json({ token: hostToken({ chatbotId: process.env.CHATBOT_ID, userId: String(req.user.id), origin: "https://app.example.com", secret: process.env.CHATBOT_WEBHOOK_SECRET, }), }); });// Node, server side only import { createHmac } from "node:crypto"; function canonical(v) { if (v === null || typeof v !== "object") return JSON.stringify(v); if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`; return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`; } export function hostToken({ chatbotId, userId, origin, ttlSeconds = 900, secret }) { const now = Math.floor(Date.now() / 1000); const body = canonical({ chatbot_id: chatbotId, user_id: userId, origin, exp: now + ttlSeconds }); const key = createHmac("sha256", secret).update("host-token").digest(); const v1 = createHmac("sha256", key).update(`${now}.${body}`).digest("hex"); return `${Buffer.from(body).toString("base64url")}.t=${now},v1=${v1}`; } // Express: only for a signed-in user (requireSession is your own middleware) app.get("/chat-token", requireSession, (req, res) => { res.set("Cache-Control", "no-store").json({ token: hostToken({ chatbotId: process.env.CHATBOT_ID, userId: String(req.user.id), origin: "https://app.example.com", secret: process.env.CHATBOT_WEBHOOK_SECRET, }), }); });
On the page
async function refreshChatToken() { const r = await fetch("/chat-token", { credentials: "include" }); if (!r.ok) return; // 401: signed out, the chat goes on with public tools only const { token } = await r.json(); ChatWidget.identify({ token }); } refreshChatToken(); setInterval(refreshChatToken, 10 * 60 * 1000); // inside the 15-minute expiry ChatWidget.onTokenExpired(refreshChatToken); // also called if the session reports expiry document.querySelector("#logout")?.addEventListener("click", () => ChatWidget.signOut());async function refreshChatToken() { const r = await fetch("/chat-token", { credentials: "include" }); if (!r.ok) return; // 401: signed out, the chat goes on with public tools only const { token } = await r.json(); ChatWidget.identify({ token }); } refreshChatToken(); setInterval(refreshChatToken, 10 * 60 * 1000); // inside the 15-minute expiry ChatWidget.onTokenExpired(refreshChatToken); // also called if the session reports expiry document.querySelector("#logout")?.addEventListener("click", () => ChatWidget.signOut());
identify({ token }) hands a new token to the open chat without dropping it. Signing in mid-conversation keeps the chat: a visitor who asks a question and then signs in carries on where they were. Signing out, or a different user signing in, starts a fresh conversation, so no one sees another user's history.
The handlers
ChatWidget.registerTools({ get_balance: async (_args, { signal }) => { const r = await fetch("/api/wallet/balance", { signal, credentials: "include" }); if (!r.ok) throw new Error("Balance is unavailable right now."); const { amount, currency } = await r.json(); return { amount, currency }; }, list_transactions: { handler: async ({ limit = 5, direction }, { signal }) => { const q = new URLSearchParams({ limit: String(limit), ...(direction ? { direction } : {}) }); const r = await fetch(`/api/wallet/transactions?${q}`, { signal, credentials: "include" }); if (!r.ok) throw new Error("Transactions are unavailable right now."); const rows = await r.json(); return rows.map((t) => ({ amount: t.amount, at: t.created_at, status: t.status })); }, available: () => Boolean(window.currentUser), }, });ChatWidget.registerTools({ get_balance: async (_args, { signal }) => { const r = await fetch("/api/wallet/balance", { signal, credentials: "include" }); if (!r.ok) throw new Error("Balance is unavailable right now."); const { amount, currency } = await r.json(); return { amount, currency }; }, list_transactions: { handler: async ({ limit = 5, direction }, { signal }) => { const q = new URLSearchParams({ limit: String(limit), ...(direction ? { direction } : {}) }); const r = await fetch(`/api/wallet/transactions?${q}`, { signal, credentials: "include" }); if (!r.ok) throw new Error("Transactions are unavailable right now."); const rows = await r.json(); return rows.map((t) => ({ amount: t.amount, at: t.created_at, status: t.status })); }, available: () => Boolean(window.currentUser), }, });
Each handler receives (args, { signal, user, conversationId, call_id, status }):
signalaborts when the call times out or is cancelled; pass it tofetch;call_idis the same for every retry and reload of this call: use it as the idempotency key for writes;status("Verifying payment")replaces what the visitor sees while the call runs (60 characters, plain text);useris{ id }only when Sarufi accepted the host token, andnullotherwise. It never comes fromidentify(userId). Never authorise withuser.idalone: your server must use its own session.conversationIdis the chat session.
identify("someone@example.com") with a plain string still names an anonymous visitor in the inbox; it does not verify anyone.
Choosing which tools are live
ChatWidget.setTools(["get_balance", "list_transactions"]); // only these, on this page ChatWidget.setTools([]); // none ChatWidget.setTools(null); // lift the page's listChatWidget.setTools(["get_balance", "list_transactions"]); // only these, on this page ChatWidget.setTools([]); // none ChatWidget.setTools(null); // lift the page's list
The widget sends the live names with every message: registered handlers, cut by setTools and each tool's available(). This only narrows what the bot offers, never adds to it.
Logging hooks
ChatWidget.onToolCall(({ name, step, mode }) => console.debug("tool", name, step, mode)); ChatWidget.onToolResult(({ name, ok, error, durationMs }) => metrics.track(name, ok, durationMs));ChatWidget.onToolCall(({ name, step, mode }) => console.debug("tool", name, step, mode)); ChatWidget.onToolResult(({ name, ok, error, durationMs }) => metrics.track(name, ok, durationMs));
The widget itself never logs args or results. onToolCall receives the args; keep them out of your logs if they hold personal data.
Safety
A tool call is your page making a request for the visitor, with arguments a model chose after reading the visitor's words. Treat it exactly like a request from an untrusted client.
- Never take a user or account id as an argument. Your server derives who the user is from its own session. A
get_order({ order_id })tool must fetch withcredentials: "include", and your server must check that the order belongs to the signed-in user, or any visitor can read anyone's orders. - Validate the args again on your server. The widget checks them against the schema, but the browser is not a security boundary.
- Make writes idempotent. Send
ctx.call_idas the idempotency key for every write (see the example below), and havepayrefuse an order that is already paid. A retried call must never charge twice. - Mark writes
"concurrency": "serial"in the tool set, so they never run at the same time. - Rate-limit the endpoints your tools call, as you would for any client.
- Return the minimum. Never return secrets, tokens, full card or account numbers, or another user's data. Results reach the model and may be stored in the conversation transcript.
- Origin is not authentication. A host token is a bearer credential: whoever holds it is that user on that site until it expires. Keep it short-lived and serve it only to the signed-in user.
- The webhook secret is the root of trust. It is in the bot's Settings, Webhook. The host-token signing key is derived from it, so regenerating the secret breaks both webhook verification and every host token until your server has the new one.
- Who sees the history: signing in mid-conversation keeps the chat; signing out (
signOut(), or a token that stops being accepted) or a different user signing in starts a fresh conversation before anything else is said. - Developer API callers: pass
verified_user_id(your own user id) only for a user you have authenticated, and use eachsession_idfor one user only.
Declare tools in page code
Instead of defining tools in a tool set, you can declare them next to the handler with ChatWidget.tool(). Page-declared definitions are offered to the model only when your server signs them into the host token (the tools_digest claim). Without that they are ignored, and the page only supplies handlers.
With the t schema builder
const { tool, t } = ChatWidget; ChatWidget.registerTools({ list_transactions: tool({ description: "The user's most recent transactions, newest first.", args: { limit: t.integer().min(1).max(50).default(5), direction: t.enum(["in", "out"]).optional().describe("Money in or money out"), }, timeout_ms: 15000, requires_verified_user: true, status: "Fetching your transactions", handler: async ({ limit, direction }, { signal }) => { const q = new URLSearchParams({ limit: String(limit), ...(direction ? { direction } : {}) }); const r = await fetch(`/api/wallet/transactions?${q}`, { signal, credentials: "include" }); if (!r.ok) throw new Error("Transactions are unavailable right now."); return r.json(); }, }), });const { tool, t } = ChatWidget; ChatWidget.registerTools({ list_transactions: tool({ description: "The user's most recent transactions, newest first.", args: { limit: t.integer().min(1).max(50).default(5), direction: t.enum(["in", "out"]).optional().describe("Money in or money out"), }, timeout_ms: 15000, requires_verified_user: true, status: "Fetching your transactions", handler: async ({ limit, direction }, { signal }) => { const q = new URLSearchParams({ limit: String(limit), ...(direction ? { direction } : {}) }); const r = await fetch(`/api/wallet/transactions?${q}`, { signal, credentials: "include" }); if (!r.ok) throw new Error("Transactions are unavailable right now."); return r.json(); }, }), });
| Builder | JSON Schema |
|---|---|
t.string(), t.number(), t.integer(), t.boolean() | {"type": ...} |
t.enum(["a", "b"]) | {"enum": ["a", "b"]} |
t.array(item) | {"type": "array", "items": ...} |
t.object({ ... }) | {"type": "object", "properties": ..., "required": [...]} |
.min(n) / .max(n) | minLength/maxLength on strings, minItems/maxItems on arrays, minimum/maximum on numbers |
.optional() | left out of required |
.default(v) | "default": v, not required, and filled in before your handler runs |
.describe(text) | "description": text |
args takes a map of t types or a t.object(...). A tool() without a handler throws at once, naming its description.
With Zod or a raw JSON Schema
import { z } from "zod"; ChatWidget.registerTools({ get_order: tool({ description: "One of the signed-in user's orders.", args: z.object({ order_id: z.string().min(3) }), // The server answers only for an order owned by the session's user. handler: ({ order_id }, { signal }) => fetch(`/api/orders/${encodeURIComponent(order_id)}`, { signal, credentials: "include" }).then((r) => r.json()), }), search_products: tool({ description: "Search the catalogue.", parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] }, handler: ({ q }) => searchCatalogue(q), }), });import { z } from "zod"; ChatWidget.registerTools({ get_order: tool({ description: "One of the signed-in user's orders.", args: z.object({ order_id: z.string().min(3) }), // The server answers only for an order owned by the session's user. handler: ({ order_id }, { signal }) => fetch(`/api/orders/${encodeURIComponent(order_id)}`, { signal, credentials: "include" }).then((r) => r.json()), }), search_products: tool({ description: "Search the catalogue.", parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] }, handler: ({ q }) => searchCatalogue(q), }), });
The widget does not bundle Zod. It recognises a Zod schema by its shape and uses toJSONSchema() when your Zod version has it, or else converts the common types itself (string, number, int, boolean, enum, array, object, optional, nullable, default, with min/max and descriptions). A raw parameters object is used as written. When both your page schema and Sarufi's definition exist, the args must pass both, yours first.
Inference, when you give no schema
When a tool() has neither args nor parameters, the widget reads the handler's source:
tool({ handler: async ({ account, limit = 10, currency = "TZS" }) => { /** The user's transactions on one account. */ // ... }, }); // parameters: { type: "object", // properties: { account: {}, limit: { type: "number", default: 10 }, currency: { type: "string", default: "TZS" } }, // required: ["account"] } // description: "The user's transactions on one account."tool({ handler: async ({ account, limit = 10, currency = "TZS" }) => { /** The user's transactions on one account. */ // ... }, }); // parameters: { type: "object", // properties: { account: {}, limit: { type: "number", default: 10 }, currency: { type: "string", default: "TZS" } }, // required: ["account"] } // description: "The user's transactions on one account."
- It sees the names in a destructured first parameter. A name with a literal default (number, string, boolean) gets that type and is optional; a name without a default is required and untyped.
- A
/** ... */comment inside the function becomes the description whendescriptionis not given. Many bundlers strip comments, so setdescriptionyourself in production. - It never fails: anything it cannot read, including minified code, gives
{ "type": "object" }. - Explicit
argsorparametersalways win. Inference is best effort, for prototypes; give a schema for anything real.
The manifest and its digest
const { tools, json, digest } = await ChatWidget.toolManifest();const { tools, json, digest } = await ChatWidget.toolManifest();
jsonis the canonical form of every definition declared withtool(): keys at every depth in the default JavaScript string sort (UTF-16 code units, so"10"comes before"9"), no whitespace, the array sorted byname.digestis the lowercase hex SHA-256 of that string's UTF-8 bytes;toolsis the same list parsed back for reading.- The widget sends
jsonitself, as a string, asdeclared_toolswhen it connects, so nothing on the way can change the bytes.
Keep the definitions in one place your build shares with the server (for example a host-tools.json the page imports and the server reads), or copy tools from toolManifest() into your server once per release. Your server signs the same digest; the helpers in Signing host tokens compute it. Schemas use whole numbers only: a number like 10.5 may be printed differently by another language and break the digest, and ChatWidget.debug(true) warns when a declared schema contains one.
Steps and modes
The agent sends calls in batches. Every batch has:
step: 1 for the first batch of a visitor's message, then 2, 3 and so on, up to 4 steps and 16 calls per message;mode:parallelwhen every call may overlap,sequentialwhen any call in it isserial;calls, each with anorder.
In parallel mode the widget runs every call at once and returns each result as soon as it is ready. In sequential mode it runs them in order; after the first failure the remaining calls are answered skipped and never run.
A call that needs an earlier result never shares a batch with it: it comes in a later step, after the agent has seen the first results.
Example: create an order, then pay
The tool set, as the owner registers it. Both tools are serial and need a signed-in user:
{ "name": "Checkout", "tools": [ { "name": "create_order", "description": "Create an order for the signed-in user.", "parameters": { "type": "object", "properties": { "product_id": { "type": "string" }, "quantity": { "type": "integer", "minimum": 1, "maximum": 20 } }, "required": ["product_id", "quantity"] }, "concurrency": "serial", "requires_verified_user": true, "status": "Creating your order" }, { "name": "pay", "description": "Pay one of the user's unpaid orders from their wallet.", "parameters": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] }, "concurrency": "serial", "requires_verified_user": true, "status": "Paying from your wallet" } ] }{ "name": "Checkout", "tools": [ { "name": "create_order", "description": "Create an order for the signed-in user.", "parameters": { "type": "object", "properties": { "product_id": { "type": "string" }, "quantity": { "type": "integer", "minimum": 1, "maximum": 20 } }, "required": ["product_id", "quantity"] }, "concurrency": "serial", "requires_verified_user": true, "status": "Creating your order" }, { "name": "pay", "description": "Pay one of the user's unpaid orders from their wallet.", "parameters": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] }, "concurrency": "serial", "requires_verified_user": true, "status": "Paying from your wallet" } ] }
The page. Each write sends its call_id as the idempotency key, so a retried call can never create a second order or charge twice:
ChatWidget.registerTools({ create_order: async ({ product_id, quantity }, { signal, call_id }) => { const r = await fetch("/api/orders", { method: "POST", signal, credentials: "include", headers: { "Content-Type": "application/json", "Idempotency-Key": call_id }, body: JSON.stringify({ product_id, quantity }), }); if (!r.ok) throw new Error("The order could not be created."); const order = await r.json(); return { order_id: order.id, total: order.total, currency: order.currency }; }, // The server pays only an unpaid order owned by the session's user. pay: async ({ order_id }, { signal, call_id, status }) => { const r = await fetch(`/api/orders/${encodeURIComponent(order_id)}/pay`, { method: "POST", signal, credentials: "include", headers: { "Idempotency-Key": call_id }, }); if (!r.ok) throw new Error("Payment did not go through."); status("Confirming with your bank"); const { status: paid } = await r.json(); return { status: paid }; }, });ChatWidget.registerTools({ create_order: async ({ product_id, quantity }, { signal, call_id }) => { const r = await fetch("/api/orders", { method: "POST", signal, credentials: "include", headers: { "Content-Type": "application/json", "Idempotency-Key": call_id }, body: JSON.stringify({ product_id, quantity }), }); if (!r.ok) throw new Error("The order could not be created."); const order = await r.json(); return { order_id: order.id, total: order.total, currency: order.currency }; }, // The server pays only an unpaid order owned by the session's user. pay: async ({ order_id }, { signal, call_id, status }) => { const r = await fetch(`/api/orders/${encodeURIComponent(order_id)}/pay`, { method: "POST", signal, credentials: "include", headers: { "Idempotency-Key": call_id }, }); if (!r.ok) throw new Error("Payment did not go through."); status("Confirming with your bank"); const { status: paid } = await r.json(); return { status: paid }; }, });
The visitor says "buy two of the blue kitenge and pay with my wallet":
- Step 1 (
sequential):create_order({ product_id, quantity: 2 })returns{ order_id: "ord_9", total: 60000 }. - Step 2 (
sequential): with the order id now known, the agent callspay({ order_id: "ord_9" }). - The agent confirms the payment to the visitor.
If create_order fails, there is no step 2: the agent tells the visitor and does not try to pay.
What the visitor sees
While the agent works, the widget shows a status line (a small spinner and a label) in place of the typing dots:
- for your tools, the tool's
status, or "Working on it" when it has none; a handler can change it withctx.status(...); - for the bot's own steps, a fixed label such as "Searching" or "Connecting you";
- calls that run in order (a
sequentialstep) show as a short list: a check on each finished step, "Couldn't finish" on a failed one, at most 4 rows and then "+N more". The list collapses when the reply arrives; - plain thinking still shows the dots.
Labels are one line of plain text, at most 60 characters, cut with an ellipsis when the chat is narrow; the full text stays available to screen readers, which hear updates politely. They never contain tool arguments or results. Motion stops for visitors who ask for reduced motion.
What the widget guarantees
Every handler runs inside a safe wrapper, so a tool can never break your page:
| Situation | The agent receives |
|---|---|
No handler, available() false, or not in setTools | not_available, at once |
| Args break the page's schema or the tool's definition | invalid_args; the handler is not called |
| Handler throws or rejects | failed, with your error message clipped to 300 characters |
Handler runs past timeout_ms | timeout; your signal is aborted |
| Result over 32 KB once serialized | failed |
| An earlier call in a sequential batch failed | skipped |
| The call was started in another window of the same chat | not run again; this window shows "Finishing in another window" and answers with that window's result |
| The call was started by a page that was reloaded or closed before it finished | interrupted after a short wait; it is not run again, so check the outcome on your server |
Results are made JSON-safe first: functions and symbols are dropped, cycles are cut, dates become ISO strings, and keys like __proto__ are removed from args and results. Each call_id runs at most once: the widget tells Sarufi before a handler starts and remembers it for the tab session, so a reload or a second tab never runs it again. A handler stops early enough for its result to arrive before the call's deadline. Results are sent as each call finishes, kept while the widget is offline, and retried until Sarufi answers. A call Sarufi cancels (a new message, the conversation ending, a person taking over) is aborted through signal, and its result is discarded.
The agent is told to apologise or retry once on an error, never to invent data. The error message you throw may reach the visitor in the agent's words, so keep it plain and free of internals.
Signing host tokens
A host token is how your server tells Sarufi what it cannot learn from the browser:
- who the signed-in user is (
user_id), so tools that need a signed-in user can be offered; - which site the token is for (
origin), so a token copied to another site is worthless; - which page-declared tools you vouch for (
tools_digest, optional), so a script injected into your page cannot add tools the model would trust.
Rules
- Mint only on your server. The webhook secret never goes into the browser, an app bundle or mobile code. Anyone holding it can mint tokens for any user.
- Short expiry. 15 minutes is recommended; 1 hour is the maximum, and longer tokens are refused.
- Refresh before
expby callingChatWidget.identify({ token })again with a new token. - Set
originto the exact page origin: scheme, host and port, lowercase, no path, no trailing slash, the default port left out (https://app.example.com,http://localhost:3000). - That origin must be in the bot's allowed domains. With no allowed domains configured, every host token is refused.
- One user per token. Mint per signed-in user and never reuse a token across users.
- Rotate the secret if it leaks. Regenerate it in the bot's Settings, Webhook and deploy the new one to your server. Tokens signed with the old secret stop working, and so does webhook verification, until your server has the new secret.
The format
| Part | Value |
|---|---|
| Claims | chatbot_id, user_id, origin, exp (Unix seconds), and tools_digest only when the page declares tools |
| Body | the claims as canonical JSON: keys in UTF-16 code-unit order (the JavaScript default sort), no whitespace |
| Signing key | k = HMAC-SHA256(webhook secret, "host-token"), the raw 32 bytes |
| Signature | v1 = hex HMAC-SHA256 with k over "<t>." + body bytes, where t is the current Unix time |
| Token | base64url(body) + "." + "t=<t>,v1=<hex>", base64url without padding |
Sarufi verifies the HMAC over the exact bytes it decodes and never re-serialises them, so sign exactly the bytes you encode.
Node (Express)
import { createHash, createHmac } from "node:crypto"; import express from "express"; import declaredTools from "./host-tools.json" with { type: "json" }; // only if the page declares tools // Keys in default JS string order at every depth, no whitespace. Built by hand because a // JS object moves integer-like keys ("9" before "10"), which would change the bytes. function canonical(v) { if (v === null || typeof v !== "object") return JSON.stringify(v); if (Array.isArray(v)) return `[${v.map((x) => (x === undefined ? "null" : canonical(x))).join(",")}]`; const parts = Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`); return `{${parts.join(",")}}`; } export function toolsDigest(tools) { const sorted = [...tools].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); return createHash("sha256").update(canonical(sorted), "utf8").digest("hex"); } export function hostToken({ chatbotId, userId, origin, exp, tools, secret, now = Math.floor(Date.now() / 1000) }) { const claims = { chatbot_id: chatbotId, user_id: userId, origin, exp }; if (tools?.length) claims.tools_digest = toolsDigest(tools); const body = canonical(claims); const key = createHmac("sha256", secret).update("host-token").digest(); const v1 = createHmac("sha256", key).update(`${now}.${body}`).digest("hex"); return `${Buffer.from(body).toString("base64url")}.t=${now},v1=${v1}`; } const app = express(); // requireSession is your own sign-in middleware; never mint for an anonymous request. app.get("/chat-token", requireSession, (req, res) => { const now = Math.floor(Date.now() / 1000); res.set("Cache-Control", "no-store").json({ token: hostToken({ chatbotId: process.env.CHATBOT_ID, userId: String(req.user.id), origin: "https://app.example.com", exp: now + 15 * 60, tools: declaredTools, secret: process.env.CHATBOT_WEBHOOK_SECRET, now, }), }); });import { createHash, createHmac } from "node:crypto"; import express from "express"; import declaredTools from "./host-tools.json" with { type: "json" }; // only if the page declares tools // Keys in default JS string order at every depth, no whitespace. Built by hand because a // JS object moves integer-like keys ("9" before "10"), which would change the bytes. function canonical(v) { if (v === null || typeof v !== "object") return JSON.stringify(v); if (Array.isArray(v)) return `[${v.map((x) => (x === undefined ? "null" : canonical(x))).join(",")}]`; const parts = Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`); return `{${parts.join(",")}}`; } export function toolsDigest(tools) { const sorted = [...tools].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); return createHash("sha256").update(canonical(sorted), "utf8").digest("hex"); } export function hostToken({ chatbotId, userId, origin, exp, tools, secret, now = Math.floor(Date.now() / 1000) }) { const claims = { chatbot_id: chatbotId, user_id: userId, origin, exp }; if (tools?.length) claims.tools_digest = toolsDigest(tools); const body = canonical(claims); const key = createHmac("sha256", secret).update("host-token").digest(); const v1 = createHmac("sha256", key).update(`${now}.${body}`).digest("hex"); return `${Buffer.from(body).toString("base64url")}.t=${now},v1=${v1}`; } const app = express(); // requireSession is your own sign-in middleware; never mint for an anonymous request. app.get("/chat-token", requireSession, (req, res) => { const now = Math.floor(Date.now() / 1000); res.set("Cache-Control", "no-store").json({ token: hostToken({ chatbotId: process.env.CHATBOT_ID, userId: String(req.user.id), origin: "https://app.example.com", exp: now + 15 * 60, tools: declaredTools, secret: process.env.CHATBOT_WEBHOOK_SECRET, now, }), }); });
Python (FastAPI)
import base64, hashlib, hmac, json, os, time from fastapi import Depends, FastAPI def _utf16(k: str) -> bytes: return k.encode("utf-16-be") def canonical(v) -> str: """Keys in UTF-16 code-unit order at every depth (the JS default sort), no whitespace.""" if isinstance(v, dict): return "{" + ",".join( json.dumps(k, ensure_ascii=False) + ":" + canonical(v[k]) for k in sorted(v, key=_utf16) ) + "}" if isinstance(v, list): return "[" + ",".join(canonical(x) for x in v) + "]" return json.dumps(v, ensure_ascii=False) def tools_digest(tools: list) -> str: ordered = sorted(tools, key=lambda d: _utf16(d["name"])) return hashlib.sha256(canonical(ordered).encode("utf-8")).hexdigest() def host_token(*, chatbot_id: str, user_id: str, origin: str, exp: int, secret: str, tools: list | None = None, now: int | None = None) -> str: claims = {"chatbot_id": chatbot_id, "user_id": user_id, "origin": origin, "exp": exp} if tools: claims["tools_digest"] = tools_digest(tools) body = canonical(claims) t = int(time.time()) if now is None else now key = hmac.new(secret.encode(), b"host-token", hashlib.sha256).digest() v1 = hmac.new(key, f"{t}.{body}".encode(), hashlib.sha256).hexdigest() b64 = base64.urlsafe_b64encode(body.encode()).decode().rstrip("=") return f"{b64}.t={t},v1={v1}" DECLARED_TOOLS = [] # or json.load(open("host-tools.json")) when the page declares tools app = FastAPI() # current_user is your own sign-in dependency; never mint for an anonymous request. @app.get("/chat-token") def chat_token(user=Depends(current_user)): now = int(time.time()) return {"token": host_token( chatbot_id=os.environ["CHATBOT_ID"], user_id=str(user.id), origin="https://app.example.com", exp=now + 15 * 60, tools=DECLARED_TOOLS, secret=os.environ["CHATBOT_WEBHOOK_SECRET"], now=now, )}import base64, hashlib, hmac, json, os, time from fastapi import Depends, FastAPI def _utf16(k: str) -> bytes: return k.encode("utf-16-be") def canonical(v) -> str: """Keys in UTF-16 code-unit order at every depth (the JS default sort), no whitespace.""" if isinstance(v, dict): return "{" + ",".join( json.dumps(k, ensure_ascii=False) + ":" + canonical(v[k]) for k in sorted(v, key=_utf16) ) + "}" if isinstance(v, list): return "[" + ",".join(canonical(x) for x in v) + "]" return json.dumps(v, ensure_ascii=False) def tools_digest(tools: list) -> str: ordered = sorted(tools, key=lambda d: _utf16(d["name"])) return hashlib.sha256(canonical(ordered).encode("utf-8")).hexdigest() def host_token(*, chatbot_id: str, user_id: str, origin: str, exp: int, secret: str, tools: list | None = None, now: int | None = None) -> str: claims = {"chatbot_id": chatbot_id, "user_id": user_id, "origin": origin, "exp": exp} if tools: claims["tools_digest"] = tools_digest(tools) body = canonical(claims) t = int(time.time()) if now is None else now key = hmac.new(secret.encode(), b"host-token", hashlib.sha256).digest() v1 = hmac.new(key, f"{t}.{body}".encode(), hashlib.sha256).hexdigest() b64 = base64.urlsafe_b64encode(body.encode()).decode().rstrip("=") return f"{b64}.t={t},v1={v1}" DECLARED_TOOLS = [] # or json.load(open("host-tools.json")) when the page declares tools app = FastAPI() # current_user is your own sign-in dependency; never mint for an anonymous request. @app.get("/chat-token") def chat_token(user=Depends(current_user)): now = int(time.time()) return {"token": host_token( chatbot_id=os.environ["CHATBOT_ID"], user_id=str(user.id), origin="https://app.example.com", exp=now + 15 * 60, tools=DECLARED_TOOLS, secret=os.environ["CHATBOT_WEBHOOK_SECRET"], now=now, )}
In Flask the route body is the same under @app.get("/chat-token") with your login_required.
Test vectors
Check your implementation before going live. The widget's own tests, the Node helper and the Python helper all produce these exact strings. Common inputs:
{ "secret": "whsec_test_vector", "chatbot_id": "01JBOT0000000000000000000A", "user_id": "user_42", "origin": "https://app.example.com", "exp": 1767225600, "t": 1767222000 }{ "secret": "whsec_test_vector", "chatbot_id": "01JBOT0000000000000000000A", "user_id": "user_42", "origin": "https://app.example.com", "exp": 1767225600, "t": 1767222000 }
No declared tools (tool sets only):
eyJjaGF0Ym90X2lkIjoiMDFKQk9UMDAwMDAwMDAwMDAwMDAwMDAwMEEiLCJleHAiOjE3NjcyMjU2MDAsIm9yaWdpbiI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIiwidXNlcl9pZCI6InVzZXJfNDIifQ.t=1767222000,v1=bb9e1fbc0be6d127c02af1ab6ba29110844d0c4319902f56f1de99d538c73cc4Two declared tools:
[ {"name":"list_transactions","description":"The user's most recent transactions, newest first.","parameters":{"type":"object","properties":{"limit":{"type":"integer","minimum":1,"maximum":50,"default":5},"direction":{"enum":["in","out"]}}},"timeout_ms":15000,"requires_verified_user":true}, {"name":"get_balance","description":"The signed-in user's current wallet balance.","parameters":{"type":"object","properties":{}},"concurrency":"parallel","requires_verified_user":true} ][ {"name":"list_transactions","description":"The user's most recent transactions, newest first.","parameters":{"type":"object","properties":{"limit":{"type":"integer","minimum":1,"maximum":50,"default":5},"direction":{"enum":["in","out"]}}},"timeout_ms":15000,"requires_verified_user":true}, {"name":"get_balance","description":"The signed-in user's current wallet balance.","parameters":{"type":"object","properties":{}},"concurrency":"parallel","requires_verified_user":true} ]
tools_digest:037c65d14fa6cb5f336d1b4928f03e7f30e7e65730acc39daa69b844f5c757b6- token:
eyJjaGF0Ym90X2lkIjoiMDFKQk9UMDAwMDAwMDAwMDAwMDAwMDAwMEEiLCJleHAiOjE3NjcyMjU2MDAsIm9yaWdpbiI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIiwidG9vbHNfZGlnZXN0IjoiMDM3YzY1ZDE0ZmE2Y2I1ZjMzNmQxYjQ5MjhmMDNlN2YzMGU3ZTY1NzMwYWNjMzlkYWE2OWI4NDRmNWM3NTdiNiIsInVzZXJfaWQiOiJ1c2VyXzQyIn0.t=1767222000,v1=48d48a0a1db6a9ee154b5c69b4a41428df443c6f19d7c068f88ecabb8681e315Integer-like keys, which sort as strings ("10" before "9"):
[{"name":"pick_slot","description":"Book one of the day's slots.","parameters":{"type":"object","properties":{"9":{"type":"string"},"10":{"type":"string"},"a":{"type":"integer","minimum":0}},"required":["10","9"]}}][{"name":"pick_slot","description":"Book one of the day's slots.","parameters":{"type":"object","properties":{"9":{"type":"string"},"10":{"type":"string"},"a":{"type":"integer","minimum":0}},"required":["10","9"]}}]
- canonical tools:
[{"description":"Book one of the day's slots.","name":"pick_slot","parameters":{"properties":{"10":{"type":"string"},"9":{"type":"string"},"a":{"minimum":0,"type":"integer"}},"required":["10","9"],"type":"object"}}] tools_digest:52fed1cfe5253151bf671634d8dfd2c12b360880bd456cd70496bf7e1be8881b- token:
eyJjaGF0Ym90X2lkIjoiMDFKQk9UMDAwMDAwMDAwMDAwMDAwMDAwMEEiLCJleHAiOjE3NjcyMjU2MDAsIm9yaWdpbiI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIiwidG9vbHNfZGlnZXN0IjoiNTJmZWQxY2ZlNTI1MzE1MWJmNjcxNjM0ZDhkZmQyYzEyYjM2MDg4MGJkNDU2Y2Q3MDQ5NmJmN2UxYmU4ODgxYiIsInVzZXJfaWQiOiJ1c2VyXzQyIn0.t=1767222000,v1=75ed7f929d490b97ed3f81f2d36f41879624714716ef5d8e2a245a066970fb9bTroubleshooting
Turn on diagnostics while you set up: ChatWidget.debug(true). The widget then explains in the console what Sarufi decided about your token and which handlers are not offered. It never prints the token, tool args or results.
Tools are never called
| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing happens while testing in the dashboard | The playground does not run website tools | Test on your own site |
ChatWidget is not defined | Your code runs before the widget script has loaded | Run it after the embed snippet, inside a load handler |
| Console: a handler "is not offered in this session" | Its tool set is not linked to this bot, or it needs a signed-in user and no token was accepted | Link the tool set on the bot's Website tools, or fix the token |
| A linked tool is never called | setTools([]), or a list without it, is still in effect | Call setTools(null), or include the name |
| Same, on some pages only | The tool's available() returns false there | Check its condition |
/chat-token answers 401 | The visitor is signed out | Expected: the chat continues with public tools only |
| Connecting a tool set is refused | It would take the bot past 8 linked tool sets or 24 tools, or a tool name clashes | Disconnect a set you no longer need, merge small sets, or rename the clashing tool |
| Saving a tool set is refused | It has more than 24 tools | Split it into two tool sets |
What the console says about the token
| Status | Meaning | Fix |
|---|---|---|
none | No host token was sent | Call identify({ token }) after sign-in |
accepted | The token is valid; the visitor is verified | |
invalid | Signature, chatbot_id or format is wrong | Check the chatbot id and secret, derive the signing key with "host-token", sign the exact bytes you encode, and compare with the test vectors |
expired | exp has passed, or is more than 1 hour ahead | Mint with a 15-minute expiry and refresh on a timer and in onTokenExpired |
origin | origin differs from the page (www., port, http vs https, trailing slash) | Mint with the page's exact window.location.origin |
no_allowed_domains | The bot has no allowed domains, so tokens are refused | Add your site to the web integration's allowed domains |
user_changed | The token names a different user than this conversation | Expected after a user switch: a fresh conversation starts |
Page-declared tools
| Console says | Cause | Fix |
|---|---|---|
tools_digest does not match | The server's definitions differ from the page's | Regenerate host-tools.json from ChatWidget.toolManifest() and redeploy both |
Declared tools are ignored without a tools_digest | The server signs no digest | Pass the definitions to hostToken |
| A non-integer number in a declared schema | A value like 10.5 | Use whole numbers |
Developer API
Server-to-server callers use the same tools without the widget. Requests carry your API key, so no origin applies.
POST /api/dev/v1/chatbots/{id}/chat/sessions/{sid}/messagestakesverified_user_id(your own id for a user you have authenticated), and optionallytoolsandactive_tools. Withoutverified_user_idthe session is anonymous and tools that need a signed-in user are hidden. A session is bound to the firstverified_user_idit sees; a different one is refused with409 user_changed.- When the reply needs your tools it has
status: "tool_calls"and atoolsbatch{ step, mode, calls }. - Post the answers to
POST /api/dev/v1/chatbots/{id}/chat/sessions/{sid}/tool_resultsas{ "step": n, "results": [ { "call_id", "ok", "result" | "error", "message"? } ] }.
A 200 reply has the same shape as a message reply (messages, status, tools), plus a receipt per result you sent:
{ "status": "complete", "messages": [ ... ], "results": [ { "call_id": "htc_01J...", "ok": true }, { "call_id": "htc_01K...", "ok": false, "error": "expired" } ] }{ "status": "complete", "messages": [ ... ], "results": [ { "call_id": "htc_01J...", "ok": true }, { "call_id": "htc_01K...", "ok": false, "error": "expired" } ] }
Per-result error | Meaning |
|---|---|
unknown_call | No such call in this session |
expired | The call passed its deadline before your result arrived |
step_mismatch | The call belongs to a different step than the one you posted |
A refused result is not used; the others in the same request still are. A whole request is refused with a 4xx:
| Status | Meaning |
|---|---|
422 | The body is malformed, or it carries more than 32 results |
409 user_changed | The session belongs to a different verified_user_id |
Keep each result under 32 KB. (too_large and throttled are acks on the widget's socket only.)
Results are expected for every call in the batch; missing ones expire, and the agent is told so.
Security notes
- Without a valid token the session is anonymous: only tools that do not need a signed-in user are offered, page-declared definitions are ignored, and the page only supplies handlers.
- Definitions are authoritative only from you: from your tool sets, or from page code your server signs.
setToolsandavailable()can only narrow. - A token is bound to one site and one user. It is refused on any origin other than the one it names, when that origin is not in the bot's allowed domains, and after
exp. Verification also ends atexpon an open chat. - Calls are bound. A
call_idis unguessable, single-use and tied to the session; a result for an unknown or foreign call is refused. - Limits per visitor message: at most 4 steps and 16 calls; beyond that the agent answers with what it has.