@xano-sdk/chatbot 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +437 -0
- package/LICENSE +21 -0
- package/README.md +697 -0
- package/dist/index.d.ts +1811 -0
- package/dist/index.js +1128 -0
- package/llms.txt +487 -0
- package/package.json +88 -0
package/llms.txt
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
# @xano-sdk/chatbot
|
|
2
|
+
|
|
3
|
+
> A conversational AI assistant for Xano as typed Xano SDK defs: `conversation` +
|
|
4
|
+
> `conversation_message` tables, a Xano AI agent, a shared reply function, and the
|
|
5
|
+
> chat endpoints. Point it at your users table, write a system prompt, deploy.
|
|
6
|
+
> For agents working ON this repo, read AGENTS.md instead.
|
|
7
|
+
|
|
8
|
+
Install: `npm install @xano-sdk/chatbot @xano/sdk`. `@xano/sdk` is a
|
|
9
|
+
`>=1.0.0 <2.0.0` peer dependency; built and tested against **1.0.0**. ESM-only,
|
|
10
|
+
Node >= 20.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { workspace } from "@xano/sdk";
|
|
16
|
+
import { userTable } from "@xano-sdk/auth"; // or any table() handle
|
|
17
|
+
import { registerChatbot } from "@xano-sdk/chatbot";
|
|
18
|
+
|
|
19
|
+
export const bot = registerChatbot(workspace("my-app"), {
|
|
20
|
+
authTable: userTable, // any table() handle or table name
|
|
21
|
+
llm: { type: "xano-free", systemPrompt: "You are a support agent for Acme." },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export default bot.xano; // the default export must be the Xano registry
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **`registerChatbot` returns the def set, not the instance.** The instance is on
|
|
28
|
+
`.xano`. The defs are factories, so this handle is the ONLY route to the
|
|
29
|
+
registered defs — exporting it is what lets a frontend derive its types from
|
|
30
|
+
them (see "Typed client" below). Use `createChatbot(opts)` to build without
|
|
31
|
+
registering.
|
|
32
|
+
- **The families are typed from the options — no `!` needed.**
|
|
33
|
+
`bot.authenticated` is non-nullable unless you pass `{ authenticated: false }`,
|
|
34
|
+
and `bot.guest` exists only with `{ guest: true }`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const a = registerChatbot(xano, { authTable }); // a.authenticated.sendMessage ✓ a.guest → undefined
|
|
38
|
+
const b = registerChatbot(xano, { authTable, guest: true }); // b.guest.sendMessage ✓
|
|
39
|
+
const c = registerChatbot(xano, { authenticated: false, guest: true }); // c.authenticated → undefined
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Only a NON-LITERAL flag (`{ guest: someBoolean }`) stays `… | undefined`,
|
|
43
|
+
because it is genuinely unknowable at compile time.
|
|
44
|
+
|
|
45
|
+
- **No dependency on `@xano-sdk/auth`.** `authTable` takes any `table()` handle or
|
|
46
|
+
bare table name. `table({ auth: true })` is a convention, not a mechanism — the
|
|
47
|
+
engine gates by comparing the token's table to the endpoint's *by name* and
|
|
48
|
+
mints a token for any table by name; the flag only drives the editor's picker.
|
|
49
|
+
- A bare-name `authTable` must still be **registered** on the workspace (the SDK
|
|
50
|
+
errors at export on an unresolvable reference) and needs
|
|
51
|
+
`{ userIdType: "uuid" }` for a uuid-keyed table, since a name carries no schema.
|
|
52
|
+
Prefer the handle.
|
|
53
|
+
- A raw numeric `dbo.id` is **rejected**, unlike the SDK's `auth`: the same table is
|
|
54
|
+
the target of `conversation.user_id`, and `f.tableRef` resolves through
|
|
55
|
+
`ObjectRef`, which has no numeric form.
|
|
56
|
+
|
|
57
|
+
## Build a frontend on it — the whole path
|
|
58
|
+
|
|
59
|
+
Everything below is expanded later; this is the order to do it in.
|
|
60
|
+
|
|
61
|
+
**1. Generate the routes.** Never import a def into the browser for its
|
|
62
|
+
`getPath()` — the chat endpoints run an AI agent, so importing one drags the SDK
|
|
63
|
+
runtime (~236 kB min / ~62 kB gz) into the bundle. This file imports nothing:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx xanosdk routes ./xano/index.ts --emit xano/routes.gen.ts
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**2. Import the types — all type-only, so they erase.**
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import type {
|
|
73
|
+
PublicConversation, PublicMessage, // rows
|
|
74
|
+
SendMessageBody, ChatReply, // the one call that matters
|
|
75
|
+
ListConversationsResponse, ListMessagesResponse,
|
|
76
|
+
} from "@xano-sdk/chatbot";
|
|
77
|
+
import { routePath } from "../xano/routes.gen.js";
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**3. Four calls make a chat UI.** Every one takes `Authorization: Bearer <token>`
|
|
81
|
+
for your `authTable`.
|
|
82
|
+
|
|
83
|
+
| do | call |
|
|
84
|
+
|---|---|
|
|
85
|
+
| list threads | `routePath("GET chat/conversations")` → `ListConversationsResponse` |
|
|
86
|
+
| open a thread | `routePath("POST chat/conversations/create")` → `PublicConversation` |
|
|
87
|
+
| load a thread | `routePath("GET chat/conversations/{conversation_id}/messages", { conversation_id })` → `ListMessagesResponse` |
|
|
88
|
+
| send | `routePath("POST chat/conversations/{conversation_id}/send", { conversation_id })`, body `SendMessageBody` → `ChatReply` |
|
|
89
|
+
|
|
90
|
+
`SendMessageBody` is `{ content }` — the id rides in the path, not the body.
|
|
91
|
+
|
|
92
|
+
**4. Render.** Assistant turns as **Markdown with raw HTML disabled**; `role:
|
|
93
|
+
"user"` turns as **plain text**. The reply is model output shaped by user input.
|
|
94
|
+
|
|
95
|
+
**5. Four behaviours to build around**, each with its own section below:
|
|
96
|
+
|
|
97
|
+
- **Serialize sends per thread.** Keep one in flight and disable the composer
|
|
98
|
+
while pending — concurrent sends to one thread scramble it.
|
|
99
|
+
- **Trim before sending.** Whitespace-only content is refused with `400`.
|
|
100
|
+
- **Handle `401` (sign out), `404` (thread gone — `404` *is* the permission
|
|
101
|
+
failure), `429` (rate limited, back off).**
|
|
102
|
+
- **No pagination and no streaming.** Reads are capped and silently truncated;
|
|
103
|
+
a reply arrives whole, so show one pending state.
|
|
104
|
+
|
|
105
|
+
That is the complete contract. Adding `tools` later changes none of it — see
|
|
106
|
+
*What a client sees when a tool runs*.
|
|
107
|
+
|
|
108
|
+
## Endpoints (default `routePrefix: "chat"`)
|
|
109
|
+
|
|
110
|
+
Authenticated family (`authenticated: true`, the default; `Authorization: Bearer`,
|
|
111
|
+
scoped to `auth("id")`):
|
|
112
|
+
|
|
113
|
+
- `POST chat/conversations/create` → the conversation
|
|
114
|
+
- `GET chat/conversations` → the caller's conversations, most recently active first
|
|
115
|
+
- `GET chat/conversations/{conversation_id}/messages` → transcript, oldest first
|
|
116
|
+
- `POST chat/conversations/{conversation_id}/send` `{ content }` →
|
|
117
|
+
`{ conversation_id, reply, message_id, tool_calls }`
|
|
118
|
+
- `DELETE chat/conversations/{conversation_id}` → `null`
|
|
119
|
+
- `POST chat/conversations/{conversation_id}/claim` `{ session_token }` → the
|
|
120
|
+
claimed conversation *(only when `guest: true`)*
|
|
121
|
+
|
|
122
|
+
Guest family (`guest: true`, off by default; public, scoped by `session_token`):
|
|
123
|
+
|
|
124
|
+
- `POST chat/guest/conversations/create` → the conversation **plus `session_token`**
|
|
125
|
+
- `GET chat/guest/conversations/{conversation_id}/messages`
|
|
126
|
+
- `POST chat/guest/conversations/{conversation_id}/send`
|
|
127
|
+
- `POST chat/guest/conversations/{conversation_id}/delete` *(POST, not DELETE, to
|
|
128
|
+
keep the token out of the URL)*
|
|
129
|
+
|
|
130
|
+
**Route names are not RESTful verb pairs.** A verb pair on one name is legal in the
|
|
131
|
+
SDK, but the names cannot change: a query's identity includes its name, so a rename
|
|
132
|
+
moves every endpoint's identity and every consumer's `xano.lock` with it.
|
|
133
|
+
`create` / `send` also read as Xano-idiomatic, the same shape as `auth/signup`.
|
|
134
|
+
|
|
135
|
+
⚠ In YOUR endpoints, check which SDK version you are on before reaching for a verb pair.
|
|
136
|
+
|
|
137
|
+
## Options
|
|
138
|
+
|
|
139
|
+
`authTable` (required unless `authenticated: false`) · `userIdType` (`"int"`) ·
|
|
140
|
+
`authenticated` (`true`) · `guest` (`false`) · `llm` (`{ type: "xano-free" }`) ·
|
|
141
|
+
`historyLimit` (`20`) · `listLimit` (`100`) · `transcriptLimit` (`200`) ·
|
|
142
|
+
`canonical` (unset) · `history` (`false`) · `routePrefix` (`"chat"`) · `names` ·
|
|
143
|
+
`tags` (`["xano:chatbot"]`) · `tools` (`[]`) · `rateLimit` (`{ max: 20, ttl: 60 }`,
|
|
144
|
+
`false` disables).
|
|
145
|
+
|
|
146
|
+
- **`llm.prompt` / `llm.messages` are refused** at compile time and at runtime.
|
|
147
|
+
This package owns the run prompt because that is how the transcript reaches the
|
|
148
|
+
model, and Xano stores ONE prompt behind a `prompt_type` discriminator — a
|
|
149
|
+
supplied one would replace the history, not add to it. Use `systemPrompt`.
|
|
150
|
+
- **Request history defaults `false`**, against the engine's inherit-on: the
|
|
151
|
+
request body carries the user's message text and, on the guest family, the
|
|
152
|
+
`session_token` granting access to the whole thread.
|
|
153
|
+
- **`canonical`** pins the API group's URL segment so `getPath()` resolves with no
|
|
154
|
+
lock file. Unset, identity comes from `xano.lock` and a bare `getPath()` throws.
|
|
155
|
+
|
|
156
|
+
## Architecture & Behaviour
|
|
157
|
+
|
|
158
|
+
- **`messages` template:** Sends structured message objects natively to the model
|
|
159
|
+
engine rather than interpolating raw text into a prompt, optimizing token usage.
|
|
160
|
+
- **Message validation:** `content` is required (`min: 1`) and automatically trimmed
|
|
161
|
+
at input; `role` is typed as an enum (`["user", "assistant", "system"]`).
|
|
162
|
+
- **Binary endpoint authentication:** Authenticated endpoints require a valid token,
|
|
163
|
+
while guest endpoints operate publicly using capability tokens. Separate endpoint
|
|
164
|
+
families ensure clean security boundaries.
|
|
165
|
+
|
|
166
|
+
## Factories, not module-level defs
|
|
167
|
+
|
|
168
|
+
There is no `import { conversationTable }`. `f.tableRef` resolves its target's
|
|
169
|
+
guid eagerly at column-construction time, so a module-level def would bake in one
|
|
170
|
+
auth table forever. Use `createChatbot`, which builds everything and registers
|
|
171
|
+
nothing:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const bot = createChatbot({ authTable: userTable });
|
|
175
|
+
xano.registerTables([bot.conversation, bot.message])
|
|
176
|
+
.registerAgents([bot.agent])
|
|
177
|
+
.registerFunctions([bot.replyFn])
|
|
178
|
+
.registerApiGroups([bot.group])
|
|
179
|
+
.registerQueries([bot.authenticated.sendMessage]);
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Dependencies travel together (queries need both tables, the reply function and the
|
|
183
|
+
agent). `registerChatbot(xano, opts)` does all of it and returns the same
|
|
184
|
+
`Chatbot` set with the instance added as `.xano`. Calling it twice on one instance
|
|
185
|
+
throws; two chatbots in one workspace need distinct `routePrefix` **and** `names`.
|
|
186
|
+
|
|
187
|
+
## Typed client (frontend)
|
|
188
|
+
|
|
189
|
+
Every request and response type is exported. Import them **type-only** — they
|
|
190
|
+
erase, so nothing reaches the bundle:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import type { SendMessageParams, SendMessageBody, ChatReply } from "@xano-sdk/chatbot";
|
|
194
|
+
|
|
195
|
+
const params: SendMessageParams = { conversation_id: 42 }; // → interpolated into the URL
|
|
196
|
+
const body: SendMessageBody = { content: "Hello" }; // → the JSON body
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Naming rule:** `<HandleName><Part>`, where the handle name is the property on
|
|
200
|
+
`bot.authenticated` / `bot.guest` — `sendMessage` → `SendMessage…`.
|
|
201
|
+
|
|
202
|
+
| part | is | goes |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| `…Params` | path parameters | interpolated into the URL |
|
|
205
|
+
| `…Query` | query-string parameters | `?a=b` |
|
|
206
|
+
| `…Body` | the JSON body | the request body |
|
|
207
|
+
| `…Input` | all of the above at once (what the SDK derives) | — |
|
|
208
|
+
| `…Response` | what comes back | — |
|
|
209
|
+
|
|
210
|
+
A part an endpoint does not take is **not exported**, so the existence of a name
|
|
211
|
+
answers "does this take a body?". Do not send `…Input` as the body — it contains
|
|
212
|
+
the path params, which the endpoint reads off the URL.
|
|
213
|
+
|
|
214
|
+
### `ChatbotEndpoints` — the whole surface as one map
|
|
215
|
+
|
|
216
|
+
Keyed by the same names as the def handles, so a client can be written or
|
|
217
|
+
generated from it alone:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
type Send = ChatbotEndpoints["sendMessage"];
|
|
221
|
+
// verb "POST" · route "conversations/{conversation_id}/send" · auth "token"
|
|
222
|
+
// params { conversation_id } · query never · body { content } · response ChatReply
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Keys: `createConversation`, `listConversations`, `listMessages`, `sendMessage`,
|
|
226
|
+
`deleteConversation`, `claimConversation`, and `guestCreateConversation`,
|
|
227
|
+
`guestListMessages`, `guestSendMessage`, `guestDeleteConversation`. Each entry
|
|
228
|
+
carries `verb`, `route` (relative to `routePrefix`), `auth`
|
|
229
|
+
(`"token"` | `"session_token"` | `"none"`), `params`, `query`, `body`, `response`
|
|
230
|
+
— `never` where a part does not apply.
|
|
231
|
+
|
|
232
|
+
⚠ `session_token` is a bearer capability, and the map records where it travels:
|
|
233
|
+
`guestListMessages` is a GET so it rides in the **query string** (and therefore
|
|
234
|
+
into access logs); `guestDeleteConversation` is a POST **specifically** so it
|
|
235
|
+
rides in the body instead.
|
|
236
|
+
|
|
237
|
+
⚠ **Do not call `createChatbot()` in browser code to reach a def.** It is a
|
|
238
|
+
runtime call: the `s.*`/`c.*` factories in each stack execute at module load and
|
|
239
|
+
cannot be tree-shaken, so you pay ~236 kB min / ~62 kB gz for a type that erases.
|
|
240
|
+
Import the types above, or `import type` the `bot` handle your `xano/index.ts`
|
|
241
|
+
exports:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import type { bot } from "../xano/index.js"; // erases completely
|
|
245
|
+
type Send = typeof bot.authenticated.sendMessage;
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
For **paths**, do not import defs either — `xanosdk routes <entry> --emit
|
|
249
|
+
xano/routes.gen.ts` emits verbs and paths as plain data that imports nothing.
|
|
250
|
+
|
|
251
|
+
## Concurrent sends to one thread — serialize client-side
|
|
252
|
+
|
|
253
|
+
A send operation writes the user turn, retrieves history, executes the AI
|
|
254
|
+
model, and records the assistant reply. Because model generation is asynchronous,
|
|
255
|
+
concurrent sends to the same conversation thread can interleave: multiple user
|
|
256
|
+
turns may be written before an assistant reply returns, causing subsequent model
|
|
257
|
+
calls to see in-flight messages.
|
|
258
|
+
|
|
259
|
+
**Recommended client practice:** Keep one send request in flight per conversation
|
|
260
|
+
and disable the composer while awaiting a reply.
|
|
261
|
+
|
|
262
|
+
Transcripts are ordered by `created_at` timestamp rather than auto-incrementing ID.
|
|
263
|
+
|
|
264
|
+
## Rate limiting — ON by default
|
|
265
|
+
|
|
266
|
+
The guest family is public and every send invokes an LLM call, so `rateLimit`
|
|
267
|
+
defaults to `{ max: 20, ttl: 60 }` (20 requests per 60s per caller) on the
|
|
268
|
+
endpoints that consume resources: both `send` endpoints and
|
|
269
|
+
`guest/conversations/create`. Reads are not rate-limited.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
registerChatbot(xano, { authTable, rateLimit: { max: 5, ttl: 30 } });
|
|
273
|
+
registerChatbot(xano, { authTable, rateLimit: false }); // remove it entirely
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The rate limiter runs first, before database lookups, so unauthorized probes
|
|
277
|
+
consume only the caller's request budget. Rate limit buckets are namespaced by
|
|
278
|
+
`routePrefix`. Authenticated endpoints key on `auth("id")`, while guest endpoints
|
|
279
|
+
key on `sys.remoteIp()`.
|
|
280
|
+
|
|
281
|
+
## Limits truncate silently — there is no pagination
|
|
282
|
+
|
|
283
|
+
No read endpoint paginates. Each caps its result and returns the capped list with
|
|
284
|
+
no cursor or total count:
|
|
285
|
+
|
|
286
|
+
| option | default | caps | drops |
|
|
287
|
+
|---|---|---|---|
|
|
288
|
+
| `transcriptLimit` | `200` | messages per transcript read | the **oldest** messages |
|
|
289
|
+
| `listLimit` | `100` | conversations per list read | the least recently active |
|
|
290
|
+
| `historyLimit` | `20` | **messages** replayed to the model per send, *including the current one* | the oldest messages |
|
|
291
|
+
|
|
292
|
+
Consequences to design around:
|
|
293
|
+
- Do not implement pagination controls against these endpoints.
|
|
294
|
+
- `historyLimit` counts messages including the turn being sent. For example, at
|
|
295
|
+
`historyLimit: 2` the model receives only the previous assistant message and
|
|
296
|
+
current user message.
|
|
297
|
+
- `historyLimit` is independent of `transcriptLimit`. Raise options in configuration
|
|
298
|
+
if needed.
|
|
299
|
+
|
|
300
|
+
## Giving the agent tools
|
|
301
|
+
|
|
302
|
+
The chat agent is tool-less by default. Pass `tools` to allow the assistant to
|
|
303
|
+
call workspace functions and query external data on demand:
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
import { tool, input, s, inp, ref } from "@xano/sdk";
|
|
307
|
+
|
|
308
|
+
const orderStatus = tool({
|
|
309
|
+
name: "order_status",
|
|
310
|
+
description: "Look up the delivery status of an order by its id.",
|
|
311
|
+
input: { order_id: input.int({ required: true }) },
|
|
312
|
+
stack: [s.db.get({ table: orders, fieldName: "id", fieldValue: inp("order_id"), as: "row" })],
|
|
313
|
+
response: ref("row"),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const bot = registerChatbot(xano, { authTable: userTable, tools: [orderStatus] });
|
|
317
|
+
bot.xano.registerTools([orderStatus]); // ← REQUIRED: register tools on the workspace
|
|
318
|
+
export default bot.xano;
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
⚠ **You must register the tool yourself.** This package only references tools.
|
|
322
|
+
Registering them on the workspace ensures proper resolution at deployment.
|
|
323
|
+
|
|
324
|
+
Key behaviors when adding tools:
|
|
325
|
+
- **`llm.maxSteps`:** Bounds reasoning/tool steps (defaults to `5`).
|
|
326
|
+
- **Tool authorization:** a tool has **no caller identity unless its toolset
|
|
327
|
+
entry names an auth table**. With `authTable` set, this package gives every
|
|
328
|
+
entry that names none `auth: <authTable>`, so `auth("id")` inside the tool
|
|
329
|
+
binds the chatting user. See *Per-tool auth* below before you write `auth()`.
|
|
330
|
+
- **Transcript context:** Tool outputs returned to the model become part of the
|
|
331
|
+
conversation context. Ensure tools return concise, relevant data.
|
|
332
|
+
|
|
333
|
+
### Per-tool auth — read this before writing `auth()` in a tool
|
|
334
|
+
|
|
335
|
+
A toolset entry carries its own `auth`, and the engine's default is
|
|
336
|
+
`auth: false` — a **public** tool stack. `auth("id")` in a public stack does not
|
|
337
|
+
resolve to null; it raises `ERROR_CODE_ACCESS_DENIED` on the first statement
|
|
338
|
+
that reads it, the agent swallows the throw, and the model still answers "I
|
|
339
|
+
saved that note". Nothing is written and nothing fails loudly.
|
|
340
|
+
|
|
341
|
+
So when `authTable` is set, **this package scopes your tools for you**:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
tools: [saveNote] // → { tool: saveNote, auth: userTable }
|
|
345
|
+
tools: [{ tool: saveNote }] // → { tool: saveNote, auth: userTable }
|
|
346
|
+
tools: [{ tool: saveNote, auth: other }] // → left alone
|
|
347
|
+
tools: [{ tool: ping, auth: false }] // → left PUBLIC — the explicit opt-out
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Rules that follow from it:
|
|
351
|
+
|
|
352
|
+
- **`{ tool, auth: false }` is now the only spelling that produces a public
|
|
353
|
+
tool.** A tool written that way must not call `auth()`; scope what it reads by
|
|
354
|
+
the arguments the model supplies.
|
|
355
|
+
- **A guest-only bot** (`{ authenticated: false, guest: true }`) has no auth
|
|
356
|
+
table, so nothing is scoped and every tool is public. `auth()` cannot work
|
|
357
|
+
there at all.
|
|
358
|
+
- **Both families at once is a hazard the package cannot resolve.** One agent
|
|
359
|
+
serves both, and an entry carries one `auth`: a scoped tool has no identity to
|
|
360
|
+
bind on a public guest send. `createChatbot` warns, and the fix is either
|
|
361
|
+
`{ tool, auth: false }` plus no `auth()` in the tool, or a second
|
|
362
|
+
`registerChatbot` install (own `routePrefix` and `names`) for the guest bot.
|
|
363
|
+
|
|
364
|
+
### What a client sees when a tool runs
|
|
365
|
+
|
|
366
|
+
**The API contract remains identical.** Whether tools are enabled or not:
|
|
367
|
+
|
|
368
|
+
| | with tools | without tools |
|
|
369
|
+
|---|---|---|
|
|
370
|
+
| `send` response | `ChatReply` — `{ conversation_id, reply, message_id, tool_calls }` | identical |
|
|
371
|
+
| `reply` | a plain string, still Markdown | identical |
|
|
372
|
+
| `tool_calls` | the **names** of the tools that ran, in call order | always `[]` |
|
|
373
|
+
| transcript rows added per send | **2** — the user turn and the final answer | 2 |
|
|
374
|
+
| tool calls in the transcript | **none** | — |
|
|
375
|
+
|
|
376
|
+
A client interacting with the chatbot uses the exact same interface: calling
|
|
377
|
+
`send` returns the final answer in `reply`, and the stored transcript contains
|
|
378
|
+
clean user and assistant turns.
|
|
379
|
+
|
|
380
|
+
`tool_calls` is always an **array of strings**, never null, so
|
|
381
|
+
`reply.tool_calls.length` needs no branch on how the bot was configured. Names
|
|
382
|
+
only, and deliberately: a call record also carries the arguments the model
|
|
383
|
+
produced and whatever the tool returned, which is data no client asked for.
|
|
384
|
+
|
|
385
|
+
⚠ It is **not an audit log.** It reports which tools the model reached for on
|
|
386
|
+
this run, not which of them succeeded. To know a tool worked, read the rows it
|
|
387
|
+
should have written.
|
|
388
|
+
|
|
389
|
+
Key considerations when enabling tools:
|
|
390
|
+
- **Execution loop:** The agent loops (calling tools and evaluating responses)
|
|
391
|
+
until generating a final reply, bounded by `llm.maxSteps` (default `5`). All
|
|
392
|
+
tool calls complete within the single `send` request.
|
|
393
|
+
- **Latency:** Tool execution adds to request duration. Keep tool functions
|
|
394
|
+
fast and keep `maxSteps` bounded to your use case.
|
|
395
|
+
- **Client visibility:** `tool_calls` names what ran; the arguments, results and
|
|
396
|
+
intermediate turns stay server-side and are not written to
|
|
397
|
+
`conversation_message`.
|
|
398
|
+
- **Security:** Model output remains untrusted content. Keep raw HTML disabled
|
|
399
|
+
in frontend renderers.
|
|
400
|
+
|
|
401
|
+
### The model has to be told the tools exist
|
|
402
|
+
|
|
403
|
+
When `tools` are configured, this package automatically appends instructions to
|
|
404
|
+
the system prompt:
|
|
405
|
+
|
|
406
|
+
> You have tools available. When a question needs information you do not have,
|
|
407
|
+
> call the appropriate tool rather than guessing or saying you do not know. Use
|
|
408
|
+
> what a tool returns to answer in your own words — never paste the raw tool
|
|
409
|
+
> response or its wrapper into your reply.
|
|
410
|
+
|
|
411
|
+
This ensures the model proactively executes available tools when needed and
|
|
412
|
+
translates raw tool response structures into natural language replies. Exported
|
|
413
|
+
as `TOOLS_SYSTEM_PROMPT` for reference or reuse.
|
|
414
|
+
|
|
415
|
+
### Structured output is deliberately not configurable
|
|
416
|
+
|
|
417
|
+
`AgentDef` supports an `output` schema; this package pins none and exposes no
|
|
418
|
+
option for it. `ChatReply.reply` is declared `string` and the send endpoints hand
|
|
419
|
+
`run.result` straight back, so a structured-output schema would make `.result` an
|
|
420
|
+
object where every consumer's type — and every markdown renderer — expects text.
|
|
421
|
+
|
|
422
|
+
If you want structured data out of a conversation, give the agent a **tool** that
|
|
423
|
+
records it and keep the reply itself free text. That also keeps the thing the
|
|
424
|
+
user reads and the thing your system stores from competing for one field.
|
|
425
|
+
|
|
426
|
+
## Errors — what a client must handle
|
|
427
|
+
|
|
428
|
+
Common HTTP status codes returned by the chatbot endpoints (wrapped in Xano's
|
|
429
|
+
standard `{ code, message, payload? }` envelope):
|
|
430
|
+
|
|
431
|
+
| status | `code` | when | client should |
|
|
432
|
+
|---|---|---|---|
|
|
433
|
+
| `400` | `ERROR_CODE_INPUT_ERROR` | a required param is absent or empty | fix the request; not retryable |
|
|
434
|
+
| `401` | `ERROR_CODE_UNAUTHORIZED` | no token, or an invalid/expired one (tokens last 24h) | sign the user out and re-authenticate |
|
|
435
|
+
| `403` | `ERROR_CODE_ACCESS_DENIED` | a guest token on a claimed thread; duplicate claim attempt | stop using the session token; prompt to sign in |
|
|
436
|
+
| `404` | `ERROR_CODE_NOT_FOUND` | thread missing, not owned by user, or invalid guest token | treat the thread as unavailable; refresh list |
|
|
437
|
+
| `429` | `ERROR_CODE_TOO_MANY_REQUESTS` | rate limit exceeded | back off and retry after the window |
|
|
438
|
+
| `500` | — | agent execution failure or empty reply | retryable; user turn may already be recorded |
|
|
439
|
+
|
|
440
|
+
Key behavior notes:
|
|
441
|
+
- **Unified 404s for security:** A missing thread and another user's thread both
|
|
442
|
+
return `404 Not Found` to prevent ID enumeration.
|
|
443
|
+
- **Empty parameter handling:** Empty strings (`""`) are treated as missing
|
|
444
|
+
parameters and return `400 Bad Request`.
|
|
445
|
+
- **Automatic trimming:** User message `content` is automatically trimmed before
|
|
446
|
+
storage and processing. Whitespace-only messages are rejected with `400`.
|
|
447
|
+
|
|
448
|
+
## Types
|
|
449
|
+
|
|
450
|
+
`Conversation`, `PublicConversation`, `Message`, `PublicMessage`, `MessageRole`,
|
|
451
|
+
`ChatReply`, plus `ChatbotOptions` / `ChatbotAuthTable` / `ChatbotLlmOptions`. All
|
|
452
|
+
erase at compile time, so a frontend can `import type` them without pulling a def
|
|
453
|
+
into the bundle.
|
|
454
|
+
|
|
455
|
+
**Render `reply` as Markdown.** Any frontend works, but replies read far better
|
|
456
|
+
rendered than as plain text, so the default system prompt asks the model for light
|
|
457
|
+
markdown (emphasis, lists, fenced code). Use a markdown component
|
|
458
|
+
(`<Markdown>{message.content}</Markdown>`) rather than a text node, or the reader
|
|
459
|
+
sees literal `**asterisks**`. Three caveats:
|
|
460
|
+
|
|
461
|
+
- The reply is model output shaped by user input, so treat it as **untrusted**:
|
|
462
|
+
keep raw HTML disabled (the default in `react-markdown`/`marked`) or sanitize
|
|
463
|
+
before `dangerouslySetInnerHTML`. The prompt telling the model not to emit HTML
|
|
464
|
+
is a nudge, not a control.
|
|
465
|
+
- Render `role: "user"` turns as **plain text** — those are verbatim user input.
|
|
466
|
+
- Building a plain-text UI? Override `llm.systemPrompt` to drop the formatting
|
|
467
|
+
instruction, rather than stripping markup client-side.
|
|
468
|
+
|
|
469
|
+
`ChatReply` is the only **declared** response shape — `reply` is the agent run's
|
|
470
|
+
`.result`, which the SDK resolves to `unknown` because the agent carries no
|
|
471
|
+
structured-output schema. Every other response is derived from its `output` list.
|
|
472
|
+
|
|
473
|
+
## Read before production
|
|
474
|
+
|
|
475
|
+
- A guest `session_token` is a **bearer capability**, not an identity: whoever
|
|
476
|
+
holds it can read and continue that thread. No expiry; claiming a thread is the
|
|
477
|
+
only thing that ends its authority.
|
|
478
|
+
- **Reads are not rate-limited**; `send` and guest create are (`rateLimit`, on by
|
|
479
|
+
default). The cap is per caller, not a spend budget — it does not bound what
|
|
480
|
+
your provider bill can reach.
|
|
481
|
+
- **Nothing here moderates** input or output.
|
|
482
|
+
- Conversations grow without limit; there is no pruning or retention. You own the
|
|
483
|
+
lifecycle of both tables.
|
|
484
|
+
- A missing thread and someone else's thread both return `notfound`, so ids cannot
|
|
485
|
+
be enumerated.
|
|
486
|
+
|
|
487
|
+
Full detail: [README.md](README.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xano-sdk/chatbot",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Plug-and-play Xano AI chatbot (conversation/message tables + a Xano AI agent + chat endpoints) as typed Xano SDK defs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"author": "Xano",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"xano",
|
|
13
|
+
"xanosdk",
|
|
14
|
+
"xanoscript",
|
|
15
|
+
"chatbot",
|
|
16
|
+
"chat",
|
|
17
|
+
"ai",
|
|
18
|
+
"agent",
|
|
19
|
+
"llm",
|
|
20
|
+
"conversation",
|
|
21
|
+
"backend",
|
|
22
|
+
"codegen"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/xanots/chatbot.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/xanots/chatbot#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/xanots/chatbot/issues"
|
|
31
|
+
},
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"xanosdk": {
|
|
34
|
+
"register": "registerChatbot",
|
|
35
|
+
"returns": "handle",
|
|
36
|
+
"options": {
|
|
37
|
+
"authTable": {
|
|
38
|
+
"package": "@xano-sdk/auth",
|
|
39
|
+
"export": "userTable"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"main": "./dist/index.js",
|
|
44
|
+
"types": "./dist/index.d.ts",
|
|
45
|
+
"exports": {
|
|
46
|
+
".": {
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"import": "./dist/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./package.json": "./package.json"
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"dist",
|
|
54
|
+
"!dist/**/*.map",
|
|
55
|
+
"README.md",
|
|
56
|
+
"AGENTS.md",
|
|
57
|
+
"llms.txt"
|
|
58
|
+
],
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsup",
|
|
61
|
+
"typecheck": "tsc --noEmit",
|
|
62
|
+
"test": "tsc --noEmit && vitest run",
|
|
63
|
+
"test:watch": "vitest",
|
|
64
|
+
"lint": "eslint .",
|
|
65
|
+
"fixture:regen": "tsx scripts/regen-golden.ts",
|
|
66
|
+
"prepublishOnly": "npm run build",
|
|
67
|
+
"release": "npm publish --access public",
|
|
68
|
+
"release:beta": "npm version prerelease --preid=beta -m \"chore(release): %s\" && npm publish --tag beta --access public"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=20"
|
|
72
|
+
},
|
|
73
|
+
"peerDependencies": {
|
|
74
|
+
"@xano/sdk": ">=1.0.0 <2.0.0"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@eslint/js": "^9.0.0",
|
|
78
|
+
"@types/node": "^20.0.0",
|
|
79
|
+
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
80
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
81
|
+
"@xano/sdk": "1.0.0",
|
|
82
|
+
"eslint": "^9.0.0",
|
|
83
|
+
"tsup": "^8.0.0",
|
|
84
|
+
"tsx": "^4.23.1",
|
|
85
|
+
"typescript": "^5.5.0",
|
|
86
|
+
"vitest": "^2.0.0"
|
|
87
|
+
}
|
|
88
|
+
}
|