import * as _xanots_sdk from '@xanots/sdk'; import { TableDef, LlmSettings, ToolsetToolEntry, InferRow, Xano, InferInput, InferResponse } from '@xanots/sdk'; /** * The public configuration surface, plus the resolver that turns a caller's * partial options into the fully-defaulted `ResolvedOptions` every def factory * reads. * * Everything in this package is a **factory** rather than a module singleton, * and `authTable` is why. `f.tableRef` resolves its target's guid eagerly, at * column-construction time (core's `src/fields/catalog.ts` — `resolveRef("dbo", * table)` runs inside the field builder), so a module-level `conversation` def * would bake in one particular user reference the moment this module evaluated * and could never be re-pointed by a later `registerChatbot` call. * * That divergence from `@xanots/auth`'s layout costs the * `import { conversationTable }` cherry-pick, and buys two things back. The * obvious one is a configurable auth table. The subtler one is that the ~100 * lines of conflict guards in `@xanots/auth`'s `register.ts` have no analogue * here: those exist because `authenticationGroup` is a process-wide singleton a * second `registerAuth` could silently retarget. Fresh defs per call cannot have * that bug, so this package needs no cross-call agreement checks. */ /** * The auth table, as this package accepts it: a `table()` def handle or a bare * table name. * * Deliberately NARROWER than core's `AuthRef`, which also allows a raw numeric * `dbo.id`. That escape hatch is unusable here: the same table is both the * endpoints' `auth` (which takes a number happily) and the target of * `conversation.user_id`, and `f.tableRef` resolves through `ObjectRef` — * `string | { name, guid? }` — which has no numeric form. Accepting a number * would mean silently dropping the foreign key or demanding a second option * naming the same table twice, so it is refused at the type level instead. */ type ChatbotAuthTable = TableDef | string; /** * The LLM settings a caller may supply: core's `LlmSettings` with the run prompt * removed from every member of the union. * * Distributive on purpose — a plain `Omit` on a union collapses it * to the keys all members share, which would erase every provider-specific field * and make `{ type: "anthropic", thinkingTokens }` a type error. * * The removal makes `llm.prompt` a COMPILE error rather than only a runtime one. * `resolveOptions` still throws, because a JS caller (or options read from untyped * config) reaches neither check — but a TypeScript author finds out at the call * site, which is the whole point of the package being typed. * * ⚠ The two banned keys are re-declared as `?: never` rather than merely * `Omit`ted. Omission alone makes the rejection an EXCESS-PROPERTY check, which * fires for a direct object-literal assignment and silently stops firing the * moment the argument is inferred into a generic type parameter — as it now is, * so `createChatbot`/`registerChatbot` can tell which endpoint families a given * options object produces. `?: never` is a real assignability failure and * survives that inference. */ type ChatbotLlmOptions = LlmSettings extends infer U ? U extends unknown ? Partial> & { prompt?: never; messages?: never; } : never : never; /** * Per-caller rate limiting on the endpoints that invoke the model. * * ON by default, because the guest family is **public and every send costs an * LLM call** — an unauthenticated, metered endpoint with no ceiling is the one * configuration a turnkey package should never ship. Same reasoning as request * history being off by default. * * Applied to the two `send` endpoints and to `guest/conversations/create` (the * only unauthenticated write). Reads are not limited: they are cheap and * limiting them mostly breaks legitimate UIs. * * Keys are per caller and per install: * * - authenticated → `auth("id")`, a real identity; * - guest → `sys.remoteIp()`, because `auth()` is null on a public endpoint and * every caller would otherwise share ONE bucket — core warns about exactly * this collapse. * * ⚠ An IP key is the best available signal on a public endpoint, not a strong * one: callers behind one NAT share a bucket, and a distributed source gets a * fresh bucket per address. Treat it as a cost ceiling, not an anti-abuse * control. * * Pass `false` to remove it entirely. */ interface ChatbotRateLimit { /** Requests per window, per caller. Default {@link DEFAULT_RATE_LIMIT_MAX}. */ max?: number; /** Window length in seconds. Default {@link DEFAULT_RATE_LIMIT_TTL}. */ ttl?: number; /** Message returned when the limit is hit. */ error?: string; } /** * The default system prompt — deliberately bland, meant to be replaced. * * The markdown sentence is the one part that is not filler. Replies read far * better in a chat UI when the frontend renders them as markdown, and that only * works if the model is actually told to produce it, so the shipped default and * the rendering advice in README.md are two halves of one decision. * * **Light** markdown on purpose. A model told simply to "use markdown" reaches * for headings and tables in a two-line answer, which reads worse than prose in a * chat bubble — and every element it uses is one more thing a frontend has to * style. Emphasis, lists and code spans cover what a conversational reply needs. * * If your frontend renders replies as PLAIN TEXT, override this: unrendered * markdown shows the reader literal `**asterisks**`, which is worse than the * prose it replaced. Pass your own `llm.systemPrompt` — that is the field this * package expects most installs to set. */ declare const DEFAULT_SYSTEM_PROMPT: string; /** * Appended to the system prompt when — and only when — `tools` are configured. * * Two clauses, each earning its place from observed behaviour: * * 1. **"call the tool rather than saying you do not know."** Verified live: with * a correctly wired tool but no mention of it in the prompt, the model * answered "I do not know the secret word" and never called it — the default * prompt's own "if you do not know something, say so rather than guessing" * steers it away from trying. Asked by name it called the tool immediately. * 2. **"answer in your own words, never paste the raw tool response."** A tool * result arrives wrapped in an engine envelope * (`{"_response": {"content": …, "name": …}}`), and the model will * happily paste that verbatim into `reply` — which then renders as a JSON * blob in the UI. Observed exactly that before this clause existed. * * Kept to two sentences so it composes with a caller's own `systemPrompt` * instead of arguing with it. */ declare const TOOLS_SYSTEM_PROMPT: string; /** How many prior turns the send endpoint replays into the model. */ declare const DEFAULT_HISTORY_LIMIT = 20; /** How many conversations the list endpoint returns. */ declare const DEFAULT_LIST_LIMIT = 100; /** How many messages a transcript endpoint returns. */ declare const DEFAULT_TRANSCRIPT_LIMIT = 200; /** Default stored-object names. */ declare const DEFAULT_NAMES: { readonly conversation: "conversation"; readonly message: "conversation_message"; readonly replyFn: "chatbot/generate_reply"; readonly agent: "chatbot_agent"; readonly apiGroup: "Chatbot"; }; /** Overridable stored-object names — see {@link ChatbotOptions.names}. */ interface ChatbotNames { /** The conversation table. Default `"conversation"`. */ conversation?: string; /** * The message table. Default `"conversation_message"` rather than the more * obvious `"message"`: a bare `message` is a name a realtime or notification * workspace plausibly already holds, and a collision surfaces as a duplicate * -guid throw at export rather than anything self-explanatory. */ message?: string; /** The shared reply function. Default `"chatbot/generate_reply"`. */ replyFn?: string; /** The agent. Default `"chatbot_agent"`. */ agent?: string; /** The API group. Default `"Chatbot"`. */ apiGroup?: string; } /** Options for `createChatbot` / `registerChatbot`. */ interface ChatbotOptions { /** * The table conversations belong to, and the table the authenticated * endpoints authenticate against. Required whenever `authenticated` is on * (the default). * * Takes a `table()` def handle or a bare table name (see * {@link ChatbotAuthTable} for why a numeric `dbo.id` is not accepted). * **Nothing in this package imports `@xanots/auth`**; handing it that * package's `userTable` is just the documented happy path: * * ```ts * import { userTable } from "@xanots/auth"; * registerChatbot(xano, { authTable: userTable }); * ``` * * Your own table works identically. `table({ auth: true })` is the convention, * not a mechanism: the engine gates a request by comparing the token's `dbo` * against the endpoint's configured `dbo` **by name**, and mints a token for * any table by name — neither side reads the flag, which is only an editor * concern (see core's `src/refs/auth.ts`). So a table without `auth: true` * authenticates fine; core merely warns at export. * * Prefer the **handle** over a bare name. A name carries no schema, so core * cannot check the referenced primary-key type against the `user_id` column — * an int/uuid mismatch then surfaces as a Xano import failure instead of an * author-time throw. Pass `{ userIdType: "uuid" }` alongside a uuid-keyed * table referenced by name. */ authTable?: ChatbotAuthTable; /** * The primary-key type of `authTable`, and so the type of * `conversation.user_id`. Default `"int"`. * * Only needed when `authTable` is a bare **name** — a name carries no schema. * With a handle, core reads the target's `idType` and throws on a mismatch by * itself, so this option is redundant there (but still checked for agreement). */ userIdType?: "int" | "uuid"; /** * Register the token-authenticated endpoint family (`chat/*`), scoped by * `auth("id")`. Default `true`. * * Set `false` for a purely anonymous bot, in which case `authTable` may be * omitted entirely and `user_id` is dropped from the conversation table. */ authenticated?: boolean; /** * Register the public guest endpoint family (`chat/guest/*`), scoped by an * unguessable `session_token`. Default `false`. * * Two families rather than one because a Xano endpoint's `auth` is **binary** * and there is no optional-auth flag: an authenticated endpoint returns 401 * without a token, and referencing `auth()` inside a *public* stack raises * `ACCESS_DENIED` outright (verified against a live instance — see * AGENTS.md). One endpoint therefore cannot serve both callers, so each family * gets its own, and each has exactly one authorization rule to audit. * * ⚠ A guest conversation is protected by a bearer capability, not a login. * See the security notes in README.md before enabling this on a public site. */ guest?: boolean; /** * The agent's LLM settings, passed through to core's `agent({ llm })`. * * Defaults to `{ type: "xano-free" }` — Xano's keyless provider, so a fresh * install answers a message without any credential wiring. * * `systemPrompt` defaults to {@link DEFAULT_SYSTEM_PROMPT} and is the one * field most installs will set. The **run prompt is not yours to set**: this * package owns it, because the conversation transcript is delivered through * it. Passing `prompt` or `messages` throws rather than silently losing the * history (core stores one prompt behind a `prompt_type` discriminator, so a * caller-supplied one would replace the transcript, not add to it). */ llm?: ChatbotLlmOptions; /** * How many prior messages to replay into the model on each send. Default * {@link DEFAULT_HISTORY_LIMIT}. Must be a positive integer. * * The window is applied in the database (`db.query` + `sort` + `paging`), so a * long conversation costs a bounded read and a bounded context, not a growing * one. Raising it raises per-message token spend on every turn. */ /** * How many messages of the thread are replayed to the model on each send. * Default {@link DEFAULT_HISTORY_LIMIT}. * * ⚠ A count of **messages (rows), including the turn being sent** — not of * exchanges. The user's message is written before the history is read, so the * usable memory is `historyLimit - 1` PRIOR messages, and a user+assistant * exchange costs two. * * Verified live: at `historyLimit: 2` the model is given * `[previous assistant reply, current user message]` — the user's own previous * statement is already outside the window, so "the codeword is X" followed by * "what was the codeword?" cannot be answered. The default of 20 is ~10 * exchanges; treat anything below ~6 as effectively memoryless. * * Independent of `transcriptLimit`, so a user can see a message on screen that * the model was never shown. */ historyLimit?: number; /** * How many conversations `GET chat/conversations` returns, newest-active first. * Default {@link DEFAULT_LIST_LIMIT}. Must be a positive integer. * * A fixed cap rather than caller-supplied paging: input-bound paging would let * an omitted `per_page` reach the engine as null, and a chat sidebar is not the * surface where that trade is worth making. A consumer who needs real paging * registers their own list endpoint over the same table. */ listLimit?: number; /** * Per-caller rate limiting on the model-invoking endpoints. Defaults to * `{ max: 20, ttl: 60 }`; pass `false` to disable. See {@link ChatbotRateLimit}. */ rateLimit?: false | ChatbotRateLimit; /** * Tools the chat agent may call — a `tool()` def handle, its name, or a * `{ tool, enabled?, auth? }` wrapper. * * Empty by default: a tool-less assistant is the right shape for a plain * chatbot, and a tool the model can call is a capability, not a decoration. * * ⚠ **This package references your tools; it never registers them.** Same rule * as `authTable`. Register them yourself: * * ```ts * const lookupOrder = tool({ name: "lookup_order", … }); * * const bot = registerChatbot(xano, { authTable: userTable, tools: [lookupOrder] }); * bot.xano.registerTools([lookupOrder]); * ``` * * A reference resolves to a name-derived guid with no registry lookup, so a * tool you forget to register is caught at **export**, which THROWS naming the * tool and the fix — deliberately, since a mistyped name would otherwise * produce a valid-looking guid that only fails once the import has begun. * * Two things follow from adding tools: * * - **`llm.maxSteps` starts to matter.** It bounds reasoning/tool steps and * defaults to 5; a chain needing more calls than that stops early. * - **A tool runs with the caller's request context.** If it reads data, scope * it yourself — the agent will call it whenever the model decides to, on * behalf of whoever is chatting, including an anonymous guest when the guest * family is enabled. * * ## Per-tool auth is applied for you * * A toolset entry carries its own `auth`, and the engine's default is * `auth: false` — a **public** tool stack. `auth("id")` inside a public stack * does not resolve to null: it raises `ERROR_CODE_ACCESS_DENIED` on the first * statement that reads it. The agent swallows that throw, so the model still * answers "I saved your note" and nothing is written — no 500, no error in the * reply, no export warning (a live failure, recorded in AGENTS.md). * * So when {@link ChatbotOptions.authTable} is set, every entry that does not * name an `auth` of its own is given **that table**: * * ```ts * tools: [saveNote] // → { tool: saveNote, auth: userTable } * tools: [{ tool: saveNote }] // → { tool: saveNote, auth: userTable } * tools: [{ tool: ping, auth: false }] // → left public, the explicit opt-out * tools: [{ tool: saveNote, auth: other }] // → left alone * ``` * * The tools belong to a bot whose endpoints are already token-gated, so a * public tool is almost never the intent. `{ tool, auth: false }` remains the * way to say it deliberately — and such a tool must not call `auth()`. * * ⚠ A **guest-only** install (`{ authenticated: false, guest: true }`) has no * auth table, so nothing is defaulted and every tool is public. A tool reached * from the guest family has no caller identity: do not write `auth()` in it, * and scope what it reads by the arguments the model supplies instead. */ tools?: ToolsetToolEntry[]; /** * How many messages the transcript endpoints return, oldest-first within the * window. Default {@link DEFAULT_TRANSCRIPT_LIMIT}. Must be a positive integer. * * The window takes the NEWEST messages, so a thread longer than this loses its * oldest turns from the response — the right end to drop for a UI that renders * from the bottom. Independent of `historyLimit`: what a human reads back and * what the model is given are different budgets. */ transcriptLimit?: number; /** * Pin the API group's canonical — the `` in * `/api:/chat/conversations`. * * Omitted, identity comes from the consumer's `xano.lock` (or, with no lock, a * random segment assigned at import) and a bare `getPath()` throws. Pinning it * lets a browser resolve `getPath()` with no lock file. Must match * `[A-Za-z0-9_-]+`, and must be unique across the instance's API groups — * which this package cannot check, so a collision surfaces at Xano import. */ canonical?: string; /** * Request-history capture for the API group the endpoints inherit from. * Default `false`. * * `false` rather than the engine's inherit-on for the same reason * `@xanots/auth` overrides it: request history records the request body, and * on this package that body is the user's message text plus — on the guest * family — the `session_token` that grants access to the whole thread. A * turnkey install should not persist either into the workspace's history * store. `true` opts back in at the engine's default depth; a positive integer * sets the capture depth; `"all"` is unlimited. */ history?: boolean | number | "all"; /** * The leading path segment of every endpoint — `chat` in * `chat/conversations/{id}/send`. Default {@link DEFAULT_ROUTE_PREFIX}. * * This is the second half of running TWO chatbots in one workspace. `names` * renames the stored objects, but a query's identity derives from its **route * name**, so a second install also needs its own prefix or every endpoint * collides on guid. Slashes are allowed (`"support/chat"`); braces are not, * since a prefix is not a place for a path param. */ routePrefix?: string; /** Override the stored object names — see {@link ChatbotNames}. */ names?: ChatbotNames; /** Tags applied to every def this package creates. Default `["xano:chatbot"]`. */ tags?: string[]; } /** Fully-defaulted options, as the def factories consume them. */ interface ResolvedOptions { /** `undefined` exactly when the authenticated family is off. */ authTable: ChatbotAuthTable | undefined; userIdType: "int" | "uuid"; authenticated: boolean; guest: boolean; llm: LlmSettings; historyLimit: number; listLimit: number; /** Resolved rate limit, or `false` when disabled. */ rateLimit: false | { max: number; ttl: number; error: string; }; /** * Tools the agent may call. Empty unless the caller supplied some. * * Normalized, not verbatim: with an auth table, every entry that named no * `auth` of its own is now `{ tool, auth: }` — see * {@link ChatbotOptions.tools}. An explicit `auth: false` survives. */ tools: ToolsetToolEntry[]; transcriptLimit: number; canonical: string | undefined; history: boolean | number | "all"; routePrefix: string; names: Required; tags: string[]; } /** * Resolve and validate options. Every check lives here, before any def is * built, so a bad call produces one clear error rather than a half-built * workspace or an opaque throw from deep inside core's encoder. */ declare function resolveOptions(opts?: ChatbotOptions): ResolvedOptions; /** * The agent's run prompt: the whole conversation, as a rendered `messages` * template. See `src/agent/chat-agent.ts` for the verification behind it. */ declare const MESSAGES_TEMPLATE = "{{ $args.messages }}"; /** Build the `conversation` def for the given resolved options. */ declare function conversationTable(opts: ResolvedOptions): _xanots_sdk.TableDef<_xanots_sdk.SchemaCols<{ title: _xanots_sdk.FieldDescriptor & _xanots_sdk.TypeBrand; last_message_at: _xanots_sdk.FieldDescriptor & _xanots_sdk.TypeBrand; session_token?: (_xanots_sdk.FieldDescriptor & _xanots_sdk.TypeBrand) | undefined; user_id?: (_xanots_sdk.FieldDescriptor & _xanots_sdk.TypeBrand) | undefined; }>, { id: number; created_at: number; title: string; last_message_at: number | null; session_token?: unknown; user_id?: unknown; }>; /** The `conversation` def type, for consumers naming it explicitly. */ type ConversationTable = ReturnType; /** * A `conversation` row. * * Declared rather than `InferRow`-derived off a factory result, because which * columns exist depends on the options passed — an inferred type would be * per-call and unnameable. `user_id` and `session_token` are therefore optional * here: present in the store exactly when their family is enabled. */ interface Conversation { id: number; created_at: number; /** Present when the authenticated family is enabled; null on an unclaimed guest thread. */ user_id?: number | string | null; /** Present when the guest family is enabled. `internal` access — never returned by this package's endpoints. */ session_token?: string; title: string; last_message_at: number | null; } /** * The columns this package's endpoints return for a conversation — the single * source of truth for their `output` lists and for {@link PublicConversation}, * so the two cannot drift. * * `session_token` is deliberately absent: it is the guest bearer capability and * is handed back exactly once, by the create endpoint that mints it, from that * statement's own binding — never re-read and re-served afterwards. */ declare const PUBLIC_CONVERSATION_FIELDS: readonly ["id", "created_at", "title", "last_message_at"]; /** The conversation projection this package's endpoints hand back. */ type PublicConversation = Pick; /** * The roles the engine accepts in a decoded `messages` array. Exported because * it is simultaneously the column's enum, the {@link MessageRole} type, and the * set the reply function is allowed to write — one reviewed list, not three. */ declare const MESSAGE_ROLES: readonly ["user", "assistant", "system"]; /** A stored message's role. */ type MessageRole = (typeof MESSAGE_ROLES)[number]; /** Build the `conversation_message` def for the given resolved options. */ declare function messageTable(opts: ResolvedOptions, conversation: ConversationTable): _xanots_sdk.TableDef<_xanots_sdk.SchemaCols<{ conversation_id: _xanots_sdk.FieldDescriptor & _xanots_sdk.TypeBrand; role: _xanots_sdk.FieldDescriptor & _xanots_sdk.TypeBrand<"system" | "user" | "assistant", { readonly required: true; readonly description: "Who produced this turn. The engine rejects any other value when replaying it."; }, "enum">; content: _xanots_sdk.FieldDescriptor & _xanots_sdk.TypeBrand; }>, { id: number; created_at: number; conversation_id: number; role: "system" | "user" | "assistant"; content: string; }>; /** The `conversation_message` def type. */ type MessageTable = ReturnType; /** A `conversation_message` row. */ type Message = InferRow>; /** * The columns this package's endpoints return for a message — the single source * of truth for their `output` lists and for {@link PublicMessage}. */ declare const PUBLIC_MESSAGE_FIELDS: readonly ["id", "created_at", "conversation_id", "role", "content"]; /** The message projection this package's endpoints hand back. */ interface PublicMessage { id: number; created_at: number; conversation_id: number; role: MessageRole; content: string; } /** * Build the agent def. * * `llm` arrives already merged and validated by `resolveOptions`, which also * pins `messages` to the template and refuses a caller-supplied `prompt` / * `messages` — see `src/options.ts`. * * `tools` comes from `opts.tools` and is empty by default — a tool-less agent is * the right shape for a plain conversational assistant. When a consumer supplies * tools, this package REFERENCES them and never registers them, exactly as it * treats `authTable`; see `ChatbotOptions.tools`. * * No `output` schema, and that one is not configurable. `ChatReply.reply` is * declared `string` and the send endpoints hand `run.result` straight back, so a * structured-output schema would make `.result` an object where every consumer's * type — and every markdown renderer — expects text. A caller who wants * structured data should give the agent a TOOL that writes it, and keep the * reply itself free text. */ declare function chatAgent(opts: ResolvedOptions): _xanots_sdk.AgentHandle<{ readonly name: string; readonly description: "Conversational assistant. Receives the thread's recent turns as decoded LLM messages and returns the next one."; readonly tags: string[]; readonly tools: _xanots_sdk.ToolsetToolEntry[]; readonly llm: _xanots_sdk.LlmSettings; }>; /** The agent def type. */ type ChatAgent = ReturnType; /** * Response shapes this package hand-declares. * * Only one, and only because core's static walk genuinely cannot see it. Every * other endpoint's response IS derived — a projection off a `db.query`, a * `db.add`, or a `db.edit`, all of which the walk can read off the table schema * and the `output` list. Those stay underived on purpose, so editing a * `PUBLIC_*_FIELDS` array moves the consumer's type with it. */ /** * What the send endpoints (and the reply function) hand back. * * Declared rather than derived because `reply` is `ref("run.result")` — the * completion from `s.ai.agent.run`. The agent carries no `output.schema` (a chat * reply is free text), so core resolves the run envelope's `.result` to `unknown`, * and `unknown` propagating into every consumer's send call is the one place a * declaration earns itself here. * * `string` is an assumption about the provider's completion, not something core * pins — the same class of hand-maintained contract as `@xanots/auth`'s * `authToken: string`. The type tests pin the declared *keys* against the keys the * walk does see, which is the part that can drift silently. */ interface ChatReply { /** The thread the turn was appended to — echoed back for client-side routing. */ conversation_id: number; /** * The assistant's reply. * * **Markdown.** The default system prompt asks for light markdown, because a * chat reply reads better rendered than as a wall of plain text. Render it with * a markdown component rather than dropping it into a text node — otherwise the * reader sees literal `**asterisks**`. (Using a plain-text UI? Override * `llm.systemPrompt` so the model stops emitting markup at all.) * * ⚠ **Render it as untrusted input.** This string is model output shaped by * whatever the user typed, so a prompt-injection attempt can try to steer it * into `