@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/README.md
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
# @xano-sdk/chatbot
|
|
2
|
+
|
|
3
|
+
A [Xano SDK](https://www.npmjs.com/package/@xano/sdk) package that ships a
|
|
4
|
+
working conversational AI assistant — the `conversation` and
|
|
5
|
+
`conversation_message` tables, a Xano AI **agent**, and the chat endpoints that
|
|
6
|
+
drive them — as typed defs you can register into any workspace and version behind
|
|
7
|
+
npm.
|
|
8
|
+
|
|
9
|
+
Install it, point it at the table your users live in, write a system prompt. That
|
|
10
|
+
is the whole setup: the thread, its history, and the model call are already wired.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @xano-sdk/chatbot @xano/sdk
|
|
14
|
+
# or scaffold a project with it already registered (auth is wired first):
|
|
15
|
+
xanosdk init my-app --marketplace @xano-sdk/auth,@xano-sdk/chatbot
|
|
16
|
+
# or, inside an existing project: install it and print the registration to paste
|
|
17
|
+
xanosdk marketplace install @xano-sdk/chatbot
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// xano/index.ts
|
|
22
|
+
import { workspace } from "@xano/sdk";
|
|
23
|
+
import { registerAuth, userTable } from "@xano-sdk/auth";
|
|
24
|
+
import { registerChatbot } from "@xano-sdk/chatbot";
|
|
25
|
+
|
|
26
|
+
const xano = registerAuth(workspace("my-app"), { canonical: "authn" });
|
|
27
|
+
|
|
28
|
+
export const bot = registerChatbot(xano, {
|
|
29
|
+
authTable: userTable,
|
|
30
|
+
llm: { type: "xano-free", systemPrompt: "You are a support agent for Acme." },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export default bot.xano; // the default export must be the Xano registry
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx xanosdk deploy ./xano/index.ts
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
curl -X POST "$BASE/api:$CANONICAL/chat/conversations/create" \
|
|
42
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'
|
|
43
|
+
# → { "id": 1, "created_at": …, "title": "", "last_message_at": null }
|
|
44
|
+
|
|
45
|
+
curl -X POST "$BASE/api:$CANONICAL/chat/conversations/1/send" \
|
|
46
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
47
|
+
-d '{"content":"My favourite colour is chartreuse. Just acknowledge."}'
|
|
48
|
+
# → { "conversation_id": 1, "reply": "Acknowledged.", "message_id": 2 }
|
|
49
|
+
|
|
50
|
+
curl -X POST "$BASE/api:$CANONICAL/chat/conversations/1/send" \
|
|
51
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
52
|
+
-d '{"content":"What is my favourite colour?"}'
|
|
53
|
+
# → { "reply": "Your favourite colour is chartreuse.", … }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That second reply is the point of the package. Nothing in your stack had to
|
|
57
|
+
assemble a transcript, and nothing in the client had to resend one.
|
|
58
|
+
|
|
59
|
+
## It does not depend on `@xano-sdk/auth`
|
|
60
|
+
|
|
61
|
+
Nothing here imports it. `authTable` takes any `table()` handle or table name, so
|
|
62
|
+
the `@xano-sdk/auth` example above is a convention, not a coupling — your own auth
|
|
63
|
+
table works identically:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const members = table({ name: "members", auth: true, schema: { /* … */ } });
|
|
67
|
+
registerChatbot(xano, { authTable: members });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`table({ auth: true })` is the **convention**, not the mechanism. Xano gates a
|
|
71
|
+
request by comparing the token's table against the endpoint's configured table
|
|
72
|
+
*by name*, and mints a token for any table by name — neither side reads the flag,
|
|
73
|
+
which only decides what the editor's auth picker offers. So an unflagged table
|
|
74
|
+
authenticates fine; the SDK just warns at export.
|
|
75
|
+
|
|
76
|
+
Two things to know when passing a **bare name**:
|
|
77
|
+
|
|
78
|
+
- The table must still be registered on the workspace. The SDK resolves the
|
|
79
|
+
reference to a name-derived guid with no registry lookup, then errors at export
|
|
80
|
+
if nothing matches — deliberately, since a mistyped name would otherwise
|
|
81
|
+
produce a valid-looking guid that only fails at deploy.
|
|
82
|
+
- The SDK cannot read a primary-key type off a name, so pass
|
|
83
|
+
`{ userIdType: "uuid" }` for a uuid-keyed table. With a **handle** the SDK reads
|
|
84
|
+
`idType` itself and throws on a mismatch, which is why the handle is preferred.
|
|
85
|
+
|
|
86
|
+
A raw numeric `dbo.id` is **not** accepted, unlike the SDK's `auth` option. The same
|
|
87
|
+
table is also the target of `conversation.user_id`, and `f.tableRef` resolves
|
|
88
|
+
through `ObjectRef` (`string | { name, guid? }`), which has no numeric form.
|
|
89
|
+
|
|
90
|
+
## Building a frontend
|
|
91
|
+
|
|
92
|
+
The short version, in order: generate `xano/routes.gen.ts` with
|
|
93
|
+
`npx xanosdk routes ./xano/index.ts --emit xano/routes.gen.ts`, `import type`
|
|
94
|
+
the request/response types from this package, and wire four calls — list,
|
|
95
|
+
create, transcript, send. Render assistant turns as Markdown with raw HTML disabled and user turns as plain
|
|
96
|
+
text, serialize sends per thread, and handle `401`/`404`/`429`.
|
|
97
|
+
|
|
98
|
+
`llms.txt` carries the same path as a single checklist, and each item has a
|
|
99
|
+
section of its own below.
|
|
100
|
+
|
|
101
|
+
## Endpoints
|
|
102
|
+
|
|
103
|
+
Paths below assume the default `routePrefix: "chat"`.
|
|
104
|
+
|
|
105
|
+
### Authenticated (`{ authenticated: true }`, the default)
|
|
106
|
+
|
|
107
|
+
Every endpoint takes `Authorization: Bearer <token>` for `authTable` and is scoped
|
|
108
|
+
to `auth("id")`.
|
|
109
|
+
|
|
110
|
+
| Verb | Path | Returns |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| POST | `chat/conversations/create` | the new conversation |
|
|
113
|
+
| GET | `chat/conversations` | the caller's conversations, most recently active first |
|
|
114
|
+
| GET | `chat/conversations/{conversation_id}/messages` | the transcript, oldest first |
|
|
115
|
+
| POST | `chat/conversations/{conversation_id}/send` | `{ conversation_id, reply, message_id, tool_calls }` |
|
|
116
|
+
| DELETE | `chat/conversations/{conversation_id}` | `null` |
|
|
117
|
+
| POST | `chat/conversations/{conversation_id}/claim` | the claimed conversation *(only with `guest: true`)* |
|
|
118
|
+
|
|
119
|
+
### Guest (`{ guest: true }`, off by default)
|
|
120
|
+
|
|
121
|
+
Public endpoints scoped by an unguessable `session_token` instead of a login.
|
|
122
|
+
**Read [Guest threads are protected by a bearer capability](#guest-threads-are-protected-by-a-bearer-capability) before enabling this.**
|
|
123
|
+
|
|
124
|
+
| Verb | Path | Returns |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| POST | `chat/guest/conversations/create` | the conversation **plus its `session_token`** |
|
|
127
|
+
| GET | `chat/guest/conversations/{conversation_id}/messages` | the transcript |
|
|
128
|
+
| POST | `chat/guest/conversations/{conversation_id}/send` | `{ conversation_id, reply, message_id, tool_calls }` |
|
|
129
|
+
| POST | `chat/guest/conversations/{conversation_id}/delete` | `null` |
|
|
130
|
+
|
|
131
|
+
Guest endpoints take `session_token` as a parameter. The delete is a `POST`
|
|
132
|
+
rather than a `DELETE` on purpose: a `DELETE` would have to carry the token in
|
|
133
|
+
the query string, and a URL travels into access logs, proxies and `Referer`
|
|
134
|
+
headers.
|
|
135
|
+
|
|
136
|
+
### Why the routes are not RESTful verb pairs
|
|
137
|
+
|
|
138
|
+
The SDK composes a query's identity from `(api group, verb, name)`, and
|
|
139
|
+
`xanosdk routes --emit` keys its manifest on `"<VERB> <name>"`, so a verb pair on
|
|
140
|
+
one name is legal. The names here stay as they are anyway: a query's identity
|
|
141
|
+
includes its name, so renaming one moves its identity and every consumer's
|
|
142
|
+
`xano.lock` with it. `create` / `send` also read as Xano-idiomatic, the same shape
|
|
143
|
+
as `auth/signup` and `auth/login`.
|
|
144
|
+
|
|
145
|
+
## Authenticated *and* anonymous, in one install
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
registerChatbot(xano, { authTable: userTable, guest: true });
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A visitor chats before signing up; after they log in, the client calls
|
|
152
|
+
`chat/conversations/{id}/claim` with the `session_token` it held, and the thread
|
|
153
|
+
becomes theirs. Claiming sets `user_id` and thereby **ends the token's
|
|
154
|
+
authority** — the guest endpoints reject it from then on, so logging in does not
|
|
155
|
+
leave a second, weaker credential valid against the thread.
|
|
156
|
+
|
|
157
|
+
These are two endpoint families rather than one flexible family because Xano
|
|
158
|
+
endpoints enforce authentication at the endpoint level (`auth` is binary). In
|
|
159
|
+
public endpoints, accessing authentication context raises `ACCESS_DENIED` rather
|
|
160
|
+
than resolving to null. Providing separate authenticated and guest endpoints
|
|
161
|
+
ensures clear access rules and security boundaries, while both delegate to a
|
|
162
|
+
single shared reply function.
|
|
163
|
+
|
|
164
|
+
## The agent
|
|
165
|
+
|
|
166
|
+
`llm` is passed straight through to the SDK's `agent({ llm })`, minus the run
|
|
167
|
+
prompt. It defaults to `{ type: "xano-free" }` — Xano's keyless provider — so a
|
|
168
|
+
fresh install answers a message with no credential wiring at all. Swap in a keyed
|
|
169
|
+
provider whenever you like:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
registerChatbot(xano, {
|
|
173
|
+
authTable: userTable,
|
|
174
|
+
llm: {
|
|
175
|
+
type: "anthropic",
|
|
176
|
+
model: "claude-opus-5",
|
|
177
|
+
apiKey: "{{ $env.ANTHROPIC_API_KEY }}", // env var, not a literal in the bundle
|
|
178
|
+
systemPrompt: "You are a support agent for Acme. Never discuss pricing.",
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**`llm.prompt` and `llm.messages` are rejected**, at compile time and again at
|
|
184
|
+
runtime. This package owns the run prompt because that is how the transcript
|
|
185
|
+
reaches the model, and Xano stores exactly one prompt behind a `prompt_type`
|
|
186
|
+
discriminator — so a supplied prompt would *replace* the history rather than add
|
|
187
|
+
to it. Put your instructions in `systemPrompt`.
|
|
188
|
+
|
|
189
|
+
### How history reaches the model
|
|
190
|
+
|
|
191
|
+
The send endpoint reads the last `historyLimit` turns, projects them to
|
|
192
|
+
`[{ role, content }]`, JSON-encodes them, and hands the result to the agent's
|
|
193
|
+
`messages` template. The engine decodes it back into native LLM message roles.
|
|
194
|
+
|
|
195
|
+
Using structured message roles allows the model to process conversation history
|
|
196
|
+
natively rather than relying on unstructured text interpolation, optimizing token
|
|
197
|
+
efficiency and preserving role boundaries.
|
|
198
|
+
|
|
199
|
+
To maintain conversation integrity:
|
|
200
|
+
- `role` is constrained to an enum (`["user", "assistant", "system"]`).
|
|
201
|
+
- `content` requires a non-empty string (`min: 1`) and is automatically trimmed
|
|
202
|
+
so blank or invalid turns cannot enter the history.
|
|
203
|
+
|
|
204
|
+
## Options
|
|
205
|
+
|
|
206
|
+
| Option | Default | Notes |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `authTable` | — | Required unless `authenticated: false`. A `table()` handle or a table name. |
|
|
209
|
+
| `userIdType` | `"int"` | The primary-key type of `authTable`. Only needed with a bare name. |
|
|
210
|
+
| `authenticated` | `true` | Register the token-authenticated family. |
|
|
211
|
+
| `guest` | `false` | Register the public guest family. |
|
|
212
|
+
| `llm` | `{ type: "xano-free" }` | Provider settings; `systemPrompt` is the field most installs set. |
|
|
213
|
+
| `historyLimit` | `20` | **Messages** replayed to the model per send, including the current one. |
|
|
214
|
+
| `listLimit` | `100` | Conversations returned by the list endpoint. |
|
|
215
|
+
| `transcriptLimit` | `200` | Messages returned by a transcript endpoint. |
|
|
216
|
+
| `canonical` | *(unset)* | Pin the API group's URL segment so `getPath()` resolves without a lock. |
|
|
217
|
+
| `tools` | `[]` | Tools the agent may call. You register them; this package only references them. |
|
|
218
|
+
| `rateLimit` | `{ max: 20, ttl: 60 }` | Per-caller ceiling on the model-invoking endpoints; `false` disables. |
|
|
219
|
+
| `history` | `false` | Request-history capture. Off by default — see below. |
|
|
220
|
+
| `routePrefix` | `"chat"` | Leading segment of every endpoint path. |
|
|
221
|
+
| `names` | see below | Stored object names: `conversation`, `message`, `agent`, `apiGroup`, `replyFn`. |
|
|
222
|
+
| `tags` | `["xano:chatbot"]` | Tags applied to every def. |
|
|
223
|
+
|
|
224
|
+
### Request history is off by default
|
|
225
|
+
|
|
226
|
+
Xano's request history defaults **on** and records the request body. Here that
|
|
227
|
+
body is the user's message text and — on the guest family — the `session_token`
|
|
228
|
+
that grants access to the entire thread. A turnkey install should persist
|
|
229
|
+
neither, so `registerChatbot` sets the group's history to `false` and the
|
|
230
|
+
endpoints inherit it. Opt back in for local debugging:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
registerChatbot(xano, { authTable: userTable, history: true }); // engine default depth
|
|
234
|
+
registerChatbot(xano, { authTable: userTable, history: 25 }); // capture depth 25
|
|
235
|
+
registerChatbot(xano, { authTable: userTable, history: "all" }); // unlimited depth
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The depth caps how many statement executions one history record's stack trace
|
|
239
|
+
keeps — it is not a retention limit.
|
|
240
|
+
|
|
241
|
+
## Identity & the lock
|
|
242
|
+
|
|
243
|
+
This package pins **no** guids, and no canonical unless you ask for one.
|
|
244
|
+
|
|
245
|
+
- **With `xano.lock` (recommended):** your first locked export mints and freezes a
|
|
246
|
+
guid for every object and a canonical for the API group, then reuses them on
|
|
247
|
+
every later export. Repeated imports are idempotent and your API URL is stable.
|
|
248
|
+
Commit `xano.lock`.
|
|
249
|
+
- **Without a lock:** each guid derives from its name (`md5("<kind>:<name>")`;
|
|
250
|
+
a query's from its api group, verb and name) and
|
|
251
|
+
the engine assigns a random canonical at import. Fine for a one-shot import.
|
|
252
|
+
|
|
253
|
+
Pin the canonical instead if you want a browser to resolve `getPath()` with no
|
|
254
|
+
lock file:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
export const bot = registerChatbot(xano, { authTable: userTable, canonical: "chat" });
|
|
258
|
+
export default bot.xano;
|
|
259
|
+
// → sendMessage.getPath({ params: { conversation_id: 42 } })
|
|
260
|
+
// "/api:chat/chat/conversations/42/send"
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The segment must match `[A-Za-z0-9_-]+` and be unique across the instance's API
|
|
264
|
+
groups — which this package cannot check, so a collision surfaces at Xano import.
|
|
265
|
+
|
|
266
|
+
## Calling it from a typed client
|
|
267
|
+
|
|
268
|
+
Every request and response type is exported directly, so a client never re-types
|
|
269
|
+
a body. They are types, so `import type` erases them — a browser bundle pays
|
|
270
|
+
nothing:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import type { SendMessageBody, ChatReply } from "@xano-sdk/chatbot";
|
|
274
|
+
|
|
275
|
+
const body: SendMessageBody = { content: "Hello" }; // no conversation_id — it rides in the path
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Names follow one rule — `<HandleName><Part>`, where the handle name is the
|
|
279
|
+
property on `bot.authenticated` / `bot.guest`:
|
|
280
|
+
|
|
281
|
+
| part | is | goes |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| `…Params` | path parameters | interpolated into the URL |
|
|
284
|
+
| `…Query` | query-string parameters | `?a=b` |
|
|
285
|
+
| `…Body` | the JSON body | the request body |
|
|
286
|
+
| `…Input` | all of the above at once (what the SDK derives) | — |
|
|
287
|
+
| `…Response` | what comes back | — |
|
|
288
|
+
|
|
289
|
+
A part an endpoint does not take is **not exported**, so the existence of a name
|
|
290
|
+
answers "does this take a body?". Never send `…Input` as the body: it contains
|
|
291
|
+
the path params, which the endpoint reads off the URL — that is what used to
|
|
292
|
+
force `Partial<…>` and throw away the check on the fields you do send.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import type { SendMessageParams, SendMessageBody, ChatReply } from "@xano-sdk/chatbot";
|
|
296
|
+
|
|
297
|
+
async function ask(token: string, conversationId: number, content: string): Promise<ChatReply> {
|
|
298
|
+
const params: SendMessageParams = { conversation_id: conversationId };
|
|
299
|
+
const body: SendMessageBody = { content };
|
|
300
|
+
|
|
301
|
+
const res = await fetch(`${BASE}/api:chat/chat/conversations/${params.conversation_id}/send`, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
304
|
+
body: JSON.stringify(body),
|
|
305
|
+
});
|
|
306
|
+
return res.json();
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### `ChatbotEndpoints` — the whole surface as one map
|
|
311
|
+
|
|
312
|
+
Keyed by the same names as the def handles, so `bot.authenticated.sendMessage`
|
|
313
|
+
and `ChatbotEndpoints["sendMessage"]` describe one thing:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
type Send = ChatbotEndpoints["sendMessage"];
|
|
317
|
+
// Send["verb"] → "POST"
|
|
318
|
+
// Send["route"] → "conversations/{conversation_id}/send" (relative to routePrefix)
|
|
319
|
+
// Send["auth"] → "token"
|
|
320
|
+
// Send["params"] → { conversation_id: number }
|
|
321
|
+
// Send["query"] → never
|
|
322
|
+
// Send["body"] → { content: string }
|
|
323
|
+
// Send["response"] → ChatReply
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Keys: `createConversation`, `listConversations`, `listMessages`, `sendMessage`,
|
|
327
|
+
`deleteConversation`, `claimConversation`, `guestCreateConversation`,
|
|
328
|
+
`guestListMessages`, `guestSendMessage`, `guestDeleteConversation`.
|
|
329
|
+
|
|
330
|
+
The map also records **where the guest bearer token travels**, which is a
|
|
331
|
+
security property rather than a style choice: `guestListMessages` is a `GET`, so
|
|
332
|
+
`session_token` rides in the query string — and therefore into access logs,
|
|
333
|
+
proxies and `Referer` headers — while `guestDeleteConversation` is a `POST`
|
|
334
|
+
*specifically* so it rides in the body instead.
|
|
335
|
+
|
|
336
|
+
### Where the URL comes from
|
|
337
|
+
|
|
338
|
+
Don't hand-type it, and **don't import a def to call `getPath()` in the browser**.
|
|
339
|
+
A def import is a *runtime* import: the `s.*`/`c.*` factories in its stack execute
|
|
340
|
+
at module load and cannot be tree-shaken, so reaching for a path costs ~236 kB
|
|
341
|
+
minified (~62 kB gzipped). Instead generate the routes as plain data — the file
|
|
342
|
+
imports nothing and a rename becomes a type error rather than a 404:
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
npx xanosdk routes ./xano/index.ts --emit xano/routes.gen.ts
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
import { routePath } from "../xano/routes.gen.js";
|
|
350
|
+
|
|
351
|
+
routePath("POST chat/conversations/{conversation_id}/send", { conversation_id: 42 });
|
|
352
|
+
// → "/api:chat/chat/conversations/42/send"
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Server-side, where bundle size is irrelevant, the def handle is the direct route:
|
|
356
|
+
`bot.authenticated.sendMessage.getPath({ params: { conversation_id: 42 } })`.
|
|
357
|
+
|
|
358
|
+
### Reaching the defs themselves
|
|
359
|
+
|
|
360
|
+
`registerChatbot` returns the def set (the instance is on `.xano`), so export it
|
|
361
|
+
and a client can `import type` the handle — which erases, unlike a value import:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
// xano/index.ts
|
|
365
|
+
export const bot = registerChatbot(xano, { authTable: userTable, canonical: "chat" });
|
|
366
|
+
export default bot.xano;
|
|
367
|
+
|
|
368
|
+
// frontend — costs the bundle nothing
|
|
369
|
+
import type { bot } from "../xano/index.js";
|
|
370
|
+
type Send = typeof bot.authenticated.sendMessage;
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Render the reply as Markdown
|
|
374
|
+
|
|
375
|
+
Any frontend works — the endpoints are ordinary JSON over HTTP. But replies read
|
|
376
|
+
markedly better rendered as **Markdown** than as plain text, so the default system
|
|
377
|
+
prompt asks the model for light markdown (emphasis, lists, fenced code) and your
|
|
378
|
+
UI should render it. Drop it into a text node instead and the reader sees literal
|
|
379
|
+
`**asterisks**`.
|
|
380
|
+
|
|
381
|
+
```tsx
|
|
382
|
+
import Markdown from "react-markdown";
|
|
383
|
+
|
|
384
|
+
<div className="prose">
|
|
385
|
+
<Markdown>{message.content}</Markdown>
|
|
386
|
+
</div>
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Two things to get right:
|
|
390
|
+
|
|
391
|
+
- **Treat the reply as untrusted input.** It is model output shaped by whatever
|
|
392
|
+
the user typed, so a prompt-injection attempt can try to steer it into
|
|
393
|
+
`<script>` or a `javascript:` link. Keep raw HTML **disabled** — that is the
|
|
394
|
+
default in `react-markdown` and `marked` — or sanitize before any
|
|
395
|
+
`dangerouslySetInnerHTML`. The prompt telling the model not to emit HTML is a
|
|
396
|
+
nudge, not a control; the renderer is the control.
|
|
397
|
+
- **Plain-text UI? Override the prompt.** If you are not rendering markdown,
|
|
398
|
+
pass your own `llm.systemPrompt` without the formatting instruction, so the
|
|
399
|
+
model stops emitting markup you will only have to strip.
|
|
400
|
+
|
|
401
|
+
The same applies to `role: "user"` messages from the transcript — those are
|
|
402
|
+
verbatim user input and should be rendered as plain text, not markdown, so one
|
|
403
|
+
user cannot post markup into a thread another user reads.
|
|
404
|
+
|
|
405
|
+
Only **one** response shape is declared rather than derived: the send endpoints'
|
|
406
|
+
`ChatReply`. `reply` is the agent run's `.result`, and the agent carries no
|
|
407
|
+
structured-output schema (a chat reply is free text), so the SDK's static walk
|
|
408
|
+
resolves it to `unknown`. Every other endpoint's response is derived from its
|
|
409
|
+
`output` list, so editing a `PUBLIC_*_FIELDS` array moves the consumer type with
|
|
410
|
+
it.
|
|
411
|
+
|
|
412
|
+
## Factories, not module-level defs
|
|
413
|
+
|
|
414
|
+
`@xano-sdk/auth` exports its defs as module singletons — `import { userTable }`.
|
|
415
|
+
This package cannot, and the reason is `authTable`: `f.tableRef` resolves its
|
|
416
|
+
target's guid **eagerly**, at column-construction time, so a module-level
|
|
417
|
+
`conversation` def would bake in one particular user reference the moment the
|
|
418
|
+
module evaluated and could never be re-pointed.
|
|
419
|
+
|
|
420
|
+
So the cherry-pick path is `createChatbot`, which builds everything and registers
|
|
421
|
+
nothing:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
const bot = createChatbot({ authTable: userTable });
|
|
425
|
+
|
|
426
|
+
xano
|
|
427
|
+
.registerTables([bot.conversation, bot.message])
|
|
428
|
+
.registerAgents([bot.agent])
|
|
429
|
+
.registerFunctions([bot.replyFn])
|
|
430
|
+
.registerApiGroups([bot.group])
|
|
431
|
+
.registerQueries([bot.authenticated.sendMessage]); // only the endpoints you want
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Dependencies travel together: the queries need both tables, the reply function
|
|
435
|
+
and the agent. The upside of factories is that the whole class of process-wide
|
|
436
|
+
singleton bugs disappears — two `createChatbot` calls simply produce two
|
|
437
|
+
independent sets, so there is nothing to reconcile between them.
|
|
438
|
+
|
|
439
|
+
### Two chatbots in one workspace
|
|
440
|
+
|
|
441
|
+
Give the second one its own `routePrefix` **and** `names`. The prefix keeps the
|
|
442
|
+
endpoint guids apart (a query's identity includes its route name); `names` keeps the
|
|
443
|
+
tables, agent, function and group apart.
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
registerChatbot(xano, { authTable: userTable }); // the default set
|
|
447
|
+
const support = createChatbot({
|
|
448
|
+
authTable: userTable,
|
|
449
|
+
routePrefix: "support",
|
|
450
|
+
names: {
|
|
451
|
+
conversation: "support_conversation", message: "support_message",
|
|
452
|
+
agent: "support_agent", apiGroup: "Support", replyFn: "support/reply",
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
// …then register `support`'s defs by hand, as above.
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Calling `registerChatbot` twice on one instance throws, rather than letting the
|
|
459
|
+
collision surface much later as an opaque duplicate-guid error at export.
|
|
460
|
+
|
|
461
|
+
## Concurrent sends to one thread — serialize client-side
|
|
462
|
+
|
|
463
|
+
A send operation writes the user message, retrieves conversation history,
|
|
464
|
+
executes the AI agent, and records the assistant reply. Because model generation
|
|
465
|
+
is asynchronous, concurrent sends to the same conversation thread can interleave:
|
|
466
|
+
multiple user turns may be written before an assistant reply returns, causing
|
|
467
|
+
subsequent model calls to see in-flight messages.
|
|
468
|
+
|
|
469
|
+
**Recommended client practice:** Keep one send request in flight per conversation
|
|
470
|
+
and disable the composer while awaiting a reply.
|
|
471
|
+
|
|
472
|
+
Transcripts are ordered by `created_at` timestamp rather than auto-incrementing ID.
|
|
473
|
+
|
|
474
|
+
## Rate limiting — ON by default
|
|
475
|
+
|
|
476
|
+
The guest family is public and every send invokes an LLM call, so `rateLimit`
|
|
477
|
+
defaults to `{ max: 20, ttl: 60 }` (20 requests per 60s per caller) on the
|
|
478
|
+
endpoints that consume resources: both `send` endpoints and
|
|
479
|
+
`guest/conversations/create`. Reads are not rate-limited.
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
registerChatbot(xano, { authTable: userTable, rateLimit: { max: 5, ttl: 30 } });
|
|
483
|
+
registerChatbot(xano, { authTable: userTable, rateLimit: false }); // remove it
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
The rate limiter runs first, before database lookups, so unauthorized probes
|
|
487
|
+
consume only the caller's request budget. Rate limit buckets are namespaced by
|
|
488
|
+
`routePrefix`.
|
|
489
|
+
|
|
490
|
+
Authenticated endpoints key on `auth("id")`, while guest endpoints key on
|
|
491
|
+
`sys.remoteIp()`.
|
|
492
|
+
|
|
493
|
+
## Limits truncate silently — there is no pagination
|
|
494
|
+
|
|
495
|
+
No read endpoint paginates. Each caps its result and returns it with no cursor
|
|
496
|
+
or total count: `transcriptLimit` (default 200) keeps the **newest** messages,
|
|
497
|
+
`listLimit` (default 100) returns the most recently active conversations, and
|
|
498
|
+
`historyLimit` (default 20) controls how many **messages** reach the model per
|
|
499
|
+
send (including the current message).
|
|
500
|
+
|
|
501
|
+
For example, with `historyLimit: 2`, the model context receives only the
|
|
502
|
+
immediate previous assistant message and the current user message.
|
|
503
|
+
|
|
504
|
+
Design implications:
|
|
505
|
+
- Do not implement pagination controls against these endpoints.
|
|
506
|
+
- `historyLimit` is configured independently of `transcriptLimit`. Adjust
|
|
507
|
+
`historyLimit` in configuration if your assistant requires a larger memory
|
|
508
|
+
window.
|
|
509
|
+
|
|
510
|
+
## Giving the agent tools
|
|
511
|
+
|
|
512
|
+
The chat agent is tool-less by default. Pass `tools` to allow the assistant to
|
|
513
|
+
call workspace functions and query external data on demand — passing a `tool()`
|
|
514
|
+
handle, a bare name, or a `{ tool, enabled?, auth? }` wrapper:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
import { tool, input, s, inp, ref } from "@xano/sdk";
|
|
518
|
+
|
|
519
|
+
const orderStatus = tool({
|
|
520
|
+
name: "order_status",
|
|
521
|
+
description: "Look up the delivery status of an order by its id.",
|
|
522
|
+
input: { order_id: input.int({ required: true }) },
|
|
523
|
+
stack: [s.db.get({ table: orders, fieldName: "id", fieldValue: inp("order_id"), as: "row" })],
|
|
524
|
+
response: ref("row"),
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const bot = registerChatbot(xano, { authTable: userTable, tools: [orderStatus] });
|
|
528
|
+
bot.xano.registerTools([orderStatus]); // ← REQUIRED: register tools on the workspace
|
|
529
|
+
export default bot.xano;
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
⚠ **You must register the tool yourself.** This package only references tools.
|
|
533
|
+
Registering them on the workspace ensures proper resolution at deployment.
|
|
534
|
+
|
|
535
|
+
Key behaviors when adding tools:
|
|
536
|
+
- **`llm.maxSteps`:** Bounds the number of reasoning/tool steps (defaults to `5`).
|
|
537
|
+
- **Tool authorization:** a tool has **no caller identity unless its toolset entry
|
|
538
|
+
names an auth table**. With `authTable` set, this package gives every entry that
|
|
539
|
+
names none `auth: <authTable>`, so `auth("id")` inside the tool binds the
|
|
540
|
+
chatting user. Read *Per-tool auth* below before writing `auth()` in a tool.
|
|
541
|
+
- **Transcript context:** Tool outputs returned to the model become part of the
|
|
542
|
+
conversation context. Ensure tools return concise, relevant data.
|
|
543
|
+
|
|
544
|
+
### Per-tool auth
|
|
545
|
+
|
|
546
|
+
A toolset entry carries its own `auth`, and the engine's default is `auth: false`
|
|
547
|
+
— a **public** tool stack. `auth("id")` in a public stack does not resolve to
|
|
548
|
+
null; it raises `ERROR_CODE_ACCESS_DENIED` on the first statement that reads it.
|
|
549
|
+
The agent swallows the throw, so the model answers "I saved that note", nothing
|
|
550
|
+
is written, and no surface a client can reach reports a failure.
|
|
551
|
+
|
|
552
|
+
That failure cost a full debugging session on a real build, so the default moved.
|
|
553
|
+
When `authTable` is set, every tool entry that names no `auth` of its own is given
|
|
554
|
+
that table:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
tools: [saveNote] // → { tool: saveNote, auth: userTable }
|
|
558
|
+
tools: [{ tool: saveNote }] // → { tool: saveNote, auth: userTable }
|
|
559
|
+
tools: [{ tool: saveNote, auth: other }] // → left alone
|
|
560
|
+
tools: [{ tool: ping, auth: false }] // → left PUBLIC — the explicit opt-out
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
- `{ tool, auth: false }` is now the only spelling that produces a public tool. A
|
|
564
|
+
tool written that way must not call `auth()` — scope what it reads by the
|
|
565
|
+
arguments the model supplies.
|
|
566
|
+
- A **guest-only** bot (`{ authenticated: false, guest: true }`) has no auth
|
|
567
|
+
table, so nothing is scoped and `auth()` cannot work in its tools at all.
|
|
568
|
+
- **Both families at once** is a hazard this package cannot resolve for you: one
|
|
569
|
+
agent serves both, and an entry carries one `auth`, so a scoped tool has no
|
|
570
|
+
identity to bind on a public guest send. `createChatbot` warns. Either mark the
|
|
571
|
+
guest-reachable tools `{ tool, auth: false }` and keep `auth()` out of them, or
|
|
572
|
+
give the guest bot its own `registerChatbot` install with its own
|
|
573
|
+
`routePrefix`, `names` and tools.
|
|
574
|
+
|
|
575
|
+
### What a client sees when a tool runs
|
|
576
|
+
|
|
577
|
+
**The API contract remains identical.** Whether tools are enabled or not:
|
|
578
|
+
|
|
579
|
+
| | with tools | without tools |
|
|
580
|
+
|---|---|---|
|
|
581
|
+
| `send` response | `ChatReply` — `{ conversation_id, reply, message_id, tool_calls }` | identical |
|
|
582
|
+
| `reply` | a plain string, formatted as Markdown | identical |
|
|
583
|
+
| `tool_calls` | the **names** of the tools that ran, in call order | always `[]` |
|
|
584
|
+
| transcript rows added per send | **2** — the user turn and the final answer | 2 |
|
|
585
|
+
| tool calls in the transcript | **none** | — |
|
|
586
|
+
|
|
587
|
+
A client interacting with the chatbot uses the exact same interface: calling
|
|
588
|
+
`send` returns the final answer in `reply`, and the stored transcript contains
|
|
589
|
+
clean user and assistant turns.
|
|
590
|
+
|
|
591
|
+
`tool_calls` is always an array of strings, never null, so
|
|
592
|
+
`reply.tool_calls.length` needs no branch on how the bot was configured. Names
|
|
593
|
+
only, and deliberately: a call record also carries the arguments the model
|
|
594
|
+
produced and whatever the tool returned, which is data no client asked for.
|
|
595
|
+
|
|
596
|
+
⚠ It is **not an audit log**. It reports which tools the model reached for on
|
|
597
|
+
this run, not which of them succeeded. To know a tool worked, read the rows it
|
|
598
|
+
should have written.
|
|
599
|
+
|
|
600
|
+
Key considerations when enabling tools:
|
|
601
|
+
- **Execution loop:** The agent iterates (calling tools and evaluating responses)
|
|
602
|
+
until generating a final reply, bounded by `llm.maxSteps` (default `5`). All
|
|
603
|
+
tool calls complete within the single `send` request.
|
|
604
|
+
- **Latency:** Tool execution adds to request duration. Keep tool functions
|
|
605
|
+
fast and keep `maxSteps` bounded to your use case.
|
|
606
|
+
- **Client visibility:** `tool_calls` names what ran; the arguments, results and
|
|
607
|
+
intermediate turns stay server-side and are not written to
|
|
608
|
+
`conversation_message`.
|
|
609
|
+
- **Security:** Model output remains untrusted content. Keep raw HTML disabled
|
|
610
|
+
in frontend renderers.
|
|
611
|
+
|
|
612
|
+
### The model has to be told the tools exist
|
|
613
|
+
|
|
614
|
+
When `tools` are configured, this package automatically appends instructions to
|
|
615
|
+
the system prompt:
|
|
616
|
+
|
|
617
|
+
> You have tools available. When a question needs information you do not have,
|
|
618
|
+
> call the appropriate tool rather than guessing or saying you do not know. Use
|
|
619
|
+
> what a tool returns to answer in your own words — never paste the raw tool
|
|
620
|
+
> response or its wrapper into your reply.
|
|
621
|
+
|
|
622
|
+
This ensures the model proactively executes available tools when needed and
|
|
623
|
+
translates raw tool response structures into natural language replies. Exported
|
|
624
|
+
as `TOOLS_SYSTEM_PROMPT` for reference or reuse.
|
|
625
|
+
|
|
626
|
+
### Structured output is deliberately not configurable
|
|
627
|
+
|
|
628
|
+
`AgentDef` supports an `output` schema; this package pins none and exposes no
|
|
629
|
+
option for it. `ChatReply.reply` is declared `string` and the send endpoints hand
|
|
630
|
+
`run.result` straight back, so a structured-output schema would make `.result` an
|
|
631
|
+
object where every consumer's type — and every markdown renderer — expects text.
|
|
632
|
+
|
|
633
|
+
If you want structured data out of a conversation, give the agent a **tool** that
|
|
634
|
+
records it and keep the reply itself free text. That also keeps the thing the
|
|
635
|
+
user reads and the thing your system stores from competing for one field.
|
|
636
|
+
|
|
637
|
+
## Errors
|
|
638
|
+
|
|
639
|
+
Common HTTP status codes returned by the chatbot endpoints (wrapped in Xano's
|
|
640
|
+
standard `{ code, message, payload? }` envelope):
|
|
641
|
+
|
|
642
|
+
| status | `code` | when | client should |
|
|
643
|
+
|---|---|---|---|
|
|
644
|
+
| `400` | `ERROR_CODE_INPUT_ERROR` | a required param is absent or empty | fix the request; not retryable |
|
|
645
|
+
| `401` | `ERROR_CODE_UNAUTHORIZED` | no token, or invalid/expired (tokens last 24h) | sign out and re-authenticate |
|
|
646
|
+
| `403` | `ERROR_CODE_ACCESS_DENIED` | a guest token on a claimed thread; duplicate claim attempt | stop using the session token; prompt to sign in |
|
|
647
|
+
| `404` | `ERROR_CODE_NOT_FOUND` | thread missing, not owned by user, or invalid guest token | treat the thread as unavailable; refresh list |
|
|
648
|
+
| `429` | `ERROR_CODE_TOO_MANY_REQUESTS` | rate limit exceeded | back off and retry after the window |
|
|
649
|
+
| `500` | — | agent execution failure or empty reply | retryable; user turn may already be recorded |
|
|
650
|
+
|
|
651
|
+
Key behavior notes:
|
|
652
|
+
- **Unified 404s for security:** A non-existent conversation and an unauthorized
|
|
653
|
+
conversation both return `404 Not Found` to prevent conversation ID enumeration.
|
|
654
|
+
- **Empty parameter handling:** Empty strings (`""`) are treated as missing
|
|
655
|
+
parameters and return `400 Bad Request`.
|
|
656
|
+
- **Automatic trimming:** User message `content` is automatically trimmed before
|
|
657
|
+
storage and processing. Whitespace-only messages are rejected with `400`.
|
|
658
|
+
|
|
659
|
+
## Security notes (read before production)
|
|
660
|
+
|
|
661
|
+
### Guest threads are protected by a bearer capability
|
|
662
|
+
|
|
663
|
+
Whoever holds a `session_token` can read and continue that conversation. It is
|
|
664
|
+
minted by `security.create_uuid`, stored in an `internal` column, and returned
|
|
665
|
+
exactly once — by the create endpoint, from that statement's own binding; no
|
|
666
|
+
endpoint ever reads it back out to a caller. Even so:
|
|
667
|
+
|
|
668
|
+
- It travels in a request parameter on every guest call, so it lands in anything
|
|
669
|
+
that logs request bodies. (This is why request history defaults off.)
|
|
670
|
+
- A client that persists it in `localStorage` has persisted a credential.
|
|
671
|
+
- It has no expiry. Claiming a thread ends its authority; nothing else does.
|
|
672
|
+
|
|
673
|
+
If that trade is wrong for your site, leave `guest` off and require a login.
|
|
674
|
+
|
|
675
|
+
### What the guards do and do not cover
|
|
676
|
+
|
|
677
|
+
- A **missing** thread and **someone else's** thread both return `notfound`. An
|
|
678
|
+
`accessdenied` on a thread that exists but is not yours would confirm its
|
|
679
|
+
existence to anyone enumerating ids.
|
|
680
|
+
- Nothing here rate-limits reads. Rate limits apply to writes and AI invocations.
|
|
681
|
+
- Nothing here moderates input or output. The model sees user text verbatim, and
|
|
682
|
+
its reply is stored and served verbatim.
|
|
683
|
+
- `historyLimit` bounds the context window per turn, but a conversation grows
|
|
684
|
+
without limit and there is no pruning or retention mechanism. You own the
|
|
685
|
+
lifecycle of both tables.
|
|
686
|
+
- Deleting a conversation deletes its messages first; the foreign key is a
|
|
687
|
+
reference, not a database cascade, so nothing else collects them.
|
|
688
|
+
|
|
689
|
+
## Versioning & Compatibility
|
|
690
|
+
|
|
691
|
+
- `@xano/sdk`: `>=1.0.0 <2.0.0` peer dependency (built and tested against `1.0.0`).
|
|
692
|
+
- Node.js: `>=20`.
|
|
693
|
+
- Module format: ESM-only.
|
|
694
|
+
|
|
695
|
+
## License
|
|
696
|
+
|
|
697
|
+
MIT
|