Resources

Website tools (host-run tools)

Let your chatbot's agent call functions that run on your own website, from a public site search to actions for a signed-in user.

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 wantPathDefinitions come fromBackend or token needed
Public features anyone can use: search, store hours, stockPublic site toolsA tool set in your workspace, linked to the botNo
Features for a signed-in user: balance, orders, paymentsAccount actionsA tool set, with "Needs signed-in user" onYes: a host token per user
Definitions kept next to your code, shipped with your releaseDeclare tools in page codeChatWidget.tool() on the pageYes: 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:

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


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.

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

json
{
  "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:

json
{ "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 nameat most 80 characters
Tool name^[a-z][a-z0-9_]{1,47}$, unique across all of a bot's linked tool sets
Descriptionat most 1024 characters
parametersan object JSON Schema of at most 8 KB, whole numbers only
timeout_msdefault 30000, from 1000 to 300000
requires_verified_usertrue: offered only with a valid host token. false: offered to every visitor
concurrency"parallel" (default) or "serial"; use serial for anything that changes data
statusoptional, at most 60 characters of plain text the visitor sees while it runs, such as "Checking your balance"
Per tool setat most 24 tools
Per botat 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:

json
{ "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.

  1. Turn on "Needs signed-in user" for each personal tool in its tool set. Without a valid token those tools are never offered.
  2. Add your site to the web integration's allowed domains. Host tokens are refused for a bot with no allowed domains.
  3. 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.
  4. Add a /chat-token route to your server that requires your own sign-in and returns a token for the current user (below).
  5. On the page, fetch the token and pass it to the widget, and refresh it before it expires.
  6. 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.

javascript
// 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

javascript
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

javascript
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 }):

  • signal aborts when the call times out or is cancelled; pass it to fetch;
  • call_id is 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);
  • user is { id } only when Sarufi accepted the host token, and null otherwise. It never comes from identify(userId). Never authorise with user.id alone: your server must use its own session.
  • conversationId is 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

javascript
ChatWidget.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

javascript
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 with credentials: "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_id as the idempotency key for every write (see the example below), and have pay refuse 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 each session_id for 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

javascript
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();
    },
  }),
});
BuilderJSON 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

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

javascript
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 when description is not given. Many bundlers strip comments, so set description yourself in production.
  • It never fails: anything it cannot read, including minified code, gives { "type": "object" }.
  • Explicit args or parameters always win. Inference is best effort, for prototypes; give a schema for anything real.

The manifest and its digest

javascript
const { tools, json, digest } = await ChatWidget.toolManifest();
  • json is the canonical form of every definition declared with tool(): keys at every depth in the default JavaScript string sort (UTF-16 code units, so "10" comes before "9"), no whitespace, the array sorted by name.
  • digest is the lowercase hex SHA-256 of that string's UTF-8 bytes; tools is the same list parsed back for reading.
  • The widget sends json itself, as a string, as declared_tools when 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: parallel when every call may overlap, sequential when any call in it is serial;
  • calls, each with an order.

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:

json
{
  "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:

javascript
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":

  1. Step 1 (sequential): create_order({ product_id, quantity: 2 }) returns { order_id: "ord_9", total: 60000 }.
  2. Step 2 (sequential): with the order id now known, the agent calls pay({ order_id: "ord_9" }).
  3. 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 with ctx.status(...);
  • for the bot's own steps, a fixed label such as "Searching" or "Connecting you";
  • calls that run in order (a sequential step) 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:

SituationThe agent receives
No handler, available() false, or not in setToolsnot_available, at once
Args break the page's schema or the tool's definitioninvalid_args; the handler is not called
Handler throws or rejectsfailed, with your error message clipped to 300 characters
Handler runs past timeout_mstimeout; your signal is aborted
Result over 32 KB once serializedfailed
An earlier call in a sequential batch failedskipped
The call was started in another window of the same chatnot 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 finishedinterrupted 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

  1. 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.
  2. Short expiry. 15 minutes is recommended; 1 hour is the maximum, and longer tokens are refused.
  3. Refresh before exp by calling ChatWidget.identify({ token }) again with a new token.
  4. Set origin to 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).
  5. That origin must be in the bot's allowed domains. With no allowed domains configured, every host token is refused.
  6. One user per token. Mint per signed-in user and never reuse a token across users.
  7. 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

PartValue
Claimschatbot_id, user_id, origin, exp (Unix seconds), and tools_digest only when the page declares tools
Bodythe claims as canonical JSON: keys in UTF-16 code-unit order (the JavaScript default sort), no whitespace
Signing keyk = HMAC-SHA256(webhook secret, "host-token"), the raw 32 bytes
Signaturev1 = hex HMAC-SHA256 with k over "<t>." + body bytes, where t is the current Unix time
Tokenbase64url(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)

javascript
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)

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

json
{
  "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):

text
eyJjaGF0Ym90X2lkIjoiMDFKQk9UMDAwMDAwMDAwMDAwMDAwMDAwMEEiLCJleHAiOjE3NjcyMjU2MDAsIm9yaWdpbiI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIiwidXNlcl9pZCI6InVzZXJfNDIifQ.t=1767222000,v1=bb9e1fbc0be6d127c02af1ab6ba29110844d0c4319902f56f1de99d538c73cc4

Two declared tools:

json
[
  {"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:
text
eyJjaGF0Ym90X2lkIjoiMDFKQk9UMDAwMDAwMDAwMDAwMDAwMDAwMEEiLCJleHAiOjE3NjcyMjU2MDAsIm9yaWdpbiI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIiwidG9vbHNfZGlnZXN0IjoiMDM3YzY1ZDE0ZmE2Y2I1ZjMzNmQxYjQ5MjhmMDNlN2YzMGU3ZTY1NzMwYWNjMzlkYWE2OWI4NDRmNWM3NTdiNiIsInVzZXJfaWQiOiJ1c2VyXzQyIn0.t=1767222000,v1=48d48a0a1db6a9ee154b5c69b4a41428df443c6f19d7c068f88ecabb8681e315

Integer-like keys, which sort as strings ("10" before "9"):

json
[{"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:
text
eyJjaGF0Ym90X2lkIjoiMDFKQk9UMDAwMDAwMDAwMDAwMDAwMDAwMEEiLCJleHAiOjE3NjcyMjU2MDAsIm9yaWdpbiI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIiwidG9vbHNfZGlnZXN0IjoiNTJmZWQxY2ZlNTI1MzE1MWJmNjcxNjM0ZDhkZmQyYzEyYjM2MDg4MGJkNDU2Y2Q3MDQ5NmJmN2UxYmU4ODgxYiIsInVzZXJfaWQiOiJ1c2VyXzQyIn0.t=1767222000,v1=75ed7f929d490b97ed3f81f2d36f41879624714716ef5d8e2a245a066970fb9b

Troubleshooting

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

SymptomLikely causeFix
Nothing happens while testing in the dashboardThe playground does not run website toolsTest on your own site
ChatWidget is not definedYour code runs before the widget script has loadedRun 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 acceptedLink the tool set on the bot's Website tools, or fix the token
A linked tool is never calledsetTools([]), or a list without it, is still in effectCall setTools(null), or include the name
Same, on some pages onlyThe tool's available() returns false thereCheck its condition
/chat-token answers 401The visitor is signed outExpected: the chat continues with public tools only
Connecting a tool set is refusedIt would take the bot past 8 linked tool sets or 24 tools, or a tool name clashesDisconnect a set you no longer need, merge small sets, or rename the clashing tool
Saving a tool set is refusedIt has more than 24 toolsSplit it into two tool sets

What the console says about the token

StatusMeaningFix
noneNo host token was sentCall identify({ token }) after sign-in
acceptedThe token is valid; the visitor is verified
invalidSignature, chatbot_id or format is wrongCheck the chatbot id and secret, derive the signing key with "host-token", sign the exact bytes you encode, and compare with the test vectors
expiredexp has passed, or is more than 1 hour aheadMint with a 15-minute expiry and refresh on a timer and in onTokenExpired
originorigin differs from the page (www., port, http vs https, trailing slash)Mint with the page's exact window.location.origin
no_allowed_domainsThe bot has no allowed domains, so tokens are refusedAdd your site to the web integration's allowed domains
user_changedThe token names a different user than this conversationExpected after a user switch: a fresh conversation starts

Page-declared tools

Console saysCauseFix
tools_digest does not matchThe server's definitions differ from the page'sRegenerate host-tools.json from ChatWidget.toolManifest() and redeploy both
Declared tools are ignored without a tools_digestThe server signs no digestPass the definitions to hostToken
A non-integer number in a declared schemaA value like 10.5Use 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}/messages takes verified_user_id (your own id for a user you have authenticated), and optionally tools and active_tools. Without verified_user_id the session is anonymous and tools that need a signed-in user are hidden. A session is bound to the first verified_user_id it sees; a different one is refused with 409 user_changed.
  • When the reply needs your tools it has status: "tool_calls" and a tools batch { step, mode, calls }.
  • Post the answers to POST /api/dev/v1/chatbots/{id}/chat/sessions/{sid}/tool_results as { "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:

json
{
  "status": "complete",
  "messages": [ ... ],
  "results": [
    { "call_id": "htc_01J...", "ok": true },
    { "call_id": "htc_01K...", "ok": false, "error": "expired" }
  ]
}
Per-result errorMeaning
unknown_callNo such call in this session
expiredThe call passed its deadline before your result arrived
step_mismatchThe 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:

StatusMeaning
422The body is malformed, or it carries more than 32 results
409 user_changedThe 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. setTools and available() 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 at exp on an open chat.
  • Calls are bound. A call_id is 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.