<div align="center">

# XanoTS

### The official TypeScript SDK for [Xano](https://xano.com).

**Write your app in TypeScript — the database, the APIs, even AI agents. Or let an
AI write it for you. Then run one command and it's live on Xano's cloud, with its own
URL. No servers, no setup, no config. That's it.**

[![npm](https://img.shields.io/npm/v/@xanots/core?color=%230055FF&label=%40xanots%2Fcore)](https://www.npmjs.com/package/@xanots/core)
[![node](https://img.shields.io/node/v/@xanots/core)](https://nodejs.org)
[![license](https://img.shields.io/npm/l/@xanots/core)](LICENSE)

</div>

```bash
npx @xanots/core login                      # 1. sign in (opens your browser)
npx @xanots/core init my-app && cd my-app   # 2. scaffold your backend + frontend
npm run build                               # 3. build your frontend → frontend/dist
npx xanots deploy ./xano/index.ts --static ./frontend/dist   # 4. deploy both → live URLs
```

```
→ Deploying ./xano/index.ts → new ephemeral "my-app"
✓ Ephemeral e4f2-9ab1 deployed
! New ephemeral URL:
    https://e4f2-9ab1.xano.io                                     ← backend, live
✓ Static host deployed
    https://my-app.xano.io                                        ← frontend, live
    Expires in 1h 0m
```

<div align="center">

**From an empty folder to a live full-stack app.** `init` sets up your project — a
TypeScript backend and a React or Svelte frontend. `npm run build` builds your frontend, then
`deploy` puts both online and hands you a live URL. Change your code — yourself or with
an AI — and deploy again; your app updates in seconds. No servers to set up, nothing to
configure, no glue code between your backend and your frontend.

[Deploy it](#deploy-it-backend--frontend-one-command) ·
[The model](#the-model-typescript-in-real-infrastructure-out) ·
[Quickstart](#60-second-quickstart) ·
[Type-safe frontend](#the-payoff-a-type-safe-frontend-for-free) ·
[Reference](#reference)

</div>

---

## Why XanoTS

Xano gives you a genuinely scalable backend — Postgres, serverless functions, background
tasks, realtime, MCP servers, AI agents — without you running a single server. XanoTS is
Xano's **officially supported** TypeScript SDK, and it gives you that backend **as code
you own**:

- **📦 TypeScript is the source of truth.** Your whole workspace — tables, indexes, API
  endpoints, functions, triggers, tasks, middleware, AI toolsets — is typed TS in your
  repo. Version it, review it in PRs, diff it, roll it back. No more clicking through a
  dashboard and hoping prod matches staging.

- **🚀 Deploy is built in.** `xanots deploy` compiles your code and ships it straight to
  a live Xano **ephemeral environment** over an authenticated connection, then prints its
  URL. No export/import dance, no upload script to maintain. Backend **and** static frontend
  in one command. Use ephemerals for QA and dev, then `xanots release` the same workspace
  to your main Xano instance for production — same code, same command shape, promoted.

- **⚡ Fast, safe iteration.** Ephemerals are disposable (they auto-expire, ~1h by default),
  so you rebuild as often as you like — `deploy` figures out whether to refresh the one
  you're iterating on or spin up a fresh one, and calls out the URL when it changes. Deploys
  are identity-stable: re-running never duplicates objects, and with a committed `xano.lock`
  renames stay renames instead of delete-and-recreate.

- **🧩 The types flow to your frontend.** Import a `query()` def into your React/Svelte/Angular
  app and get the endpoint path, HTTP verb, and a fully-typed request payload — with
  **zero codegen**. Rename a column and every consumer lights up red.

- **🏗️ Highly scalable, zero ops.** You write intent; Xano runs the infrastructure.
  Autoscaling compute, managed Postgres, edge-served static hosting. You never touch a
  Dockerfile.

- **🤖 AI-first by design.** A deterministic, fully-typed authoring surface — an agent (or
  you) emits well-typed TS that always compiles to a valid, importable workspace. Ships with
  machine-readable grounding on three tiers: **`llms.txt`** is a small always-read router —
  the mental model, the deploy contract, every gotcha — ending in a list of **`llms/*.md`**
  topic files with the condition for opening each, and the exhaustive per-entry catalog —
  full field schemas, filter argument lists, engine mappings — a targeted lookup away in
  **`manifest.json`**. An agent reads the router plus the one or two topic files its task
  needs, and learns the whole SDK without reading source.

---

## Deploy it: backend + frontend, one command

XanoTS's primary deploy target is an **ephemeral environment**: a named, disposable Xano
workspace that spins up on demand, auto-expires (~1h by default), and is meant to be written
to constantly while you build. `xanots deploy` create-or-refreshes one and prints its URL —
run it again and it refreshes the same environment; if it expired, a fresh one is minted and
the new URL is called out. (Prefer a single throwaway singleton? `--dest sandbox`.) Most
stacks make you deploy your API and your app through two separate pipelines. XanoTS
collapses that into **one command** — point `--static` at your built frontend and it archives
and uploads it to the edge-served static host in the same run, right after the backend
import — a deploy is a full replace, and clearing the environment takes its static hosting
with it, so the frontend is published onto the workspace the import just rebuilt:

```bash
# Build once, then ship backend + frontend together. The deploy wires the
# backend URL into the frontend for you — no need to know it before building.
npm run build                                          # → frontend/dist
npx xanots deploy ./xano/index.ts --static ./frontend/dist
```

```
→ Deploying ./xano/index.ts → new ephemeral "my-app"
✓ Ephemeral e4f2-9ab1 deployed
! New ephemeral URL:
    https://e4f2-9ab1.xano.io                                         ← backend, live
    Expires in 1h 0m
✓ Config injected into 1 document: window.XANO_HOST                   ← backend URL, wired in
✓ Static host deployed
    https://my-app.xano.io                                            ← frontend, live
✓ Frontend is live                                                    ← edge confirmed serving THIS build
```

> Deploying again keeps the **backend** URL; the **frontend** one changes. The full
> replace clears the environment's static hosting along with its workspace, so every
> `--static` run publishes to a new host and the URL the last deploy printed stops
> serving. Hand out the URL from the latest run. If an import fails, the frontend is
> republished anyway so the site keeps serving — against the environment's previous
> backend until a deploy imports cleanly.

One authenticated call ships your database schema, your APIs, your functions and
triggers, **and** your compiled web app. No separate frontend host to configure, no CI
glue wiring the two together. Manage your environments with `xanots ephemeral
<list|get|delete|export>`.

> **Wiring the frontend to the backend.** The deploy bakes the environment's backend URL into
> every HTML document in your build automatically, as a `window.XANO_HOST` global evaluated
> *before* your app bundle — every document, so a prerendered build's deep links and refreshes
> boot with the same backend the root does. So read it at runtime with a build-time fallback and you never have to
> know the URL ahead of time:
>
> ```ts
> const HOST = (typeof window !== "undefined" && window.XANO_HOST) || import.meta.env.VITE_XANO_HOST;
> ```
>
> `window.XANO_HOST` is the **sandbox tenant** URL your deployed APIs answer at (the same
> value `xanots sandbox details` prints as `baseUrl`); it is *not* `xanots profile me`,
> which prints your account's instance origin. Because injection happens at deploy time, a
> prebuilt `frontend/dist` retargets any sandbox with **no rebuild** — ideal for headless agents.
> Add your own public config (base URLs, *publishable* keys) with `--static-env KEY=VALUE`
> (repeatable), exposed the same way as `window.<KEY>`. A static host serves these files
> verbatim to the browser, so everything injected is **public** — never put secrets here;
> those belong in backend env, read server-side via `env(name)`.
>
> **Showing a stored file.** A file column comes back as `{ path, name, type, size, meta,
> access, url }`. Don't use its `url`: on a tenant-scoped environment that field addresses the
> instance host *without* the `/tenant/<name>` segment and 404s — as a broken `<img>`, while
> every assertion about the response still passes. Build the URL from `path` and the host you
> already have:
>
> ```ts
> import { fileUrl } from "@xanots/core";
>
> <img src={fileUrl(row.avatar, HOST) ?? ""} />   // null for an absent file
> ```
>
> **Verifying the injection:** the served `index.html` writes the global in **bracket
> notation** — `window["XANO_HOST"]="…";` — so grep for the bare token `XANO_HOST`, not the
> exact string `window.XANO_HOST` (the dot form is valid to *read* the global in your app,
> but it's not what the file contains, so an exact-string grep for it wrongly reads as "not
> injected"). Note it can also be served from cache for up to an hour after a deploy — fetch
> once with a cache-buster (`curl -s "$URL/?nocache=$(date +%s)" | grep XANO_HOST`) rather
> than retrying the bare URL.

**Two targets**, so the dev loop and the production step stay distinct:

| Command | Where it goes |
|---|---|
| `xanots deploy` | A disposable **ephemeral** environment (default) — create-or-refreshed each run, auto-expiring, with its own URL. `--dest sandbox` targets your throwaway singleton instead. |
| `xanots release` | Your **main Xano instance** workspace — the production target. **Merges** by default: objects are updated in place or added, and your table data is never touched unless you ask. |
| `xanots test` | Nothing — it only reads. Runs the tests an already-deployed environment carries; `--dest` picks which one, `workspace` included. |

Not every instance has ephemeral environments enabled. Where they are off, the default
`deploy` says so and points at `--dest sandbox` — note that `--static` then publishes the
frontend to your own (parent) workspace, replacing whatever it is hosting.

Every `deploy` is a **full replace** of the disposable environment — always fresh, no
merge mode, no flags to get wrong. A `release` is the opposite by design: it changes what
your code defines and leaves the rest of the workspace — including every row in every
table — alone.

| `release` flag | What it does to your workspace |
|---|---|
| *(none)* | Adds new objects, updates existing ones. Nothing is deleted, no rows are written. |
| `--dry-run` | Prints the plan and exits without sending anything. |
| `--prune` | Also deletes objects **this project released** and no longer defines. Requires `xano.lock` — see below. |
| `--reset-data` | Empties every table the bundle carries. |
| `--seed` | Writes the bundle's table rows. Combine with `--reset-data` to reset **and** re-seed. |
| `--replace` | The disposable-environment behavior: wipe the workspace and import in its place. |

**An unchanged project is a no-op.** Before importing, the release compares the bundle it is
about to send against the workspace it is about to send it to, object by object. When every
object is already there, nothing is sent: the plan prints `no changes`, the JSON summary
reports `"upToDate": true` with `"operations": 0`, and no `updated_at` moves. That makes
`release` usable as a reconcile step — safe to run on a schedule, in CI on every merge, or
behind a "make production match main" button.

The comparison is deliberately one-sided: a false "changed" costs one import that was
happening anyway, while a false "unchanged" would silently skip a real release. So anything
it cannot prove equal counts as changed, and it is skipped entirely for `--replace` (which
mints fresh identities) and for `--seed`/`--reset-data` (which write table rows, about which
the comparison knows nothing). Under `--prune`, an object the workspace holds and the project
no longer defines is work to do, so that is not a no-op either.

Anything destructive is **previewed first** — the CLI fetches the plan, prints what would
change, and asks. `--yes` skips the prompt for CI but never skips the preview. Start with
`xanots release ./xano/index.ts --dry-run` to see the plan without committing to it.

> ⚠️ `--prune` removes tables this project released and no longer defines, and a removed
> table takes its rows with it — no flag prevents that. The preview reports it explicitly;
> read it before confirming.

**`--prune` is scoped to what this project released**, and `xano.lock` is what defines that
scope: it holds an entry for every object the project has ever exported. A planned deletion
with no lock entry is an object the project never created — a table built in the UI, another
team's API group — and the release is refused rather than deleting it. A prune with no lock
at all is refused too: without one there is no record of what belongs to the project, so
every deletion would be a guess. To delete something that is genuinely yours to remove,
adopt it first (`xanots lock adopt`). Releasing a pre-exported `--bundle` carries no entry
file to find a lock beside, so name it with `--lock=<path>`.

**A dropped column is destructive, and does not need a flag to happen.** Removing a column
from a table schema and releasing destroys the column and every value in it. The server's
plan calls that a routine in-place update, so the release compares your schema against the
live workspace, names each column that would be dropped, and asks before doing it — on an
ordinary release, with no destructive flag passed.

**Environment variables are add-only on a release.** A merge creates keys that do not exist
yet and leaves existing ones as they are, so changing a value in code and releasing will not
change it on the workspace. The preview names any key it will decline to update. To change
one, set it on the workspace directly, or use `--replace` (which rebuilds the workspace).

A merge matches objects by the stable identity your project assigns them, so it only
recognizes a workspace it has released to before. Releasing into one built by hand — or
populated by `--replace`, which assigns its own — matches nothing: every object is a
create, and with `--prune` the workspace is emptied and rebuilt rather than updated. The
preview says so in as many words when it happens. To adopt objects that are already there,
pin their `guid` on the matching defs first. Deploys are **authenticated over OAuth** — sign in once,
and the CLI refreshes tokens automatically. The target instance comes from your token
(never a stray flag), and the CLI prints what it's about to do before it touches anything.

**CI & agents** run fully headless from two env vars — no browser needed:

```bash
XANO_REFRESH_TOKEN=… XANO_CLIENT_ID=… npx @xanots/core deploy --bundle ws.json
```

> ⚠️ A deploy is a full replace of the target environment, including its table records,
> before importing. The blast radius is your own disposable ephemeral/sandbox — but anything
> you only ever created by hand in it (or any data it accumulated) is gone. That's exactly
> why production has a separate `release` path.

### Prove it, don't hope

The tests you author — a `tests` entry on a query, function, or middleware, or a standalone
`workflowTest()` — run against a deployed environment:

```bash
npx xanots test run-all                    # the ephemeral you last deployed to
npx xanots test run-all --dest sandbox     # or your sandbox
npx xanots test run-all --dest workspace   # or your real workspace (this only reads)
```

`xanots test list` shows what is there without running it, and `xanots test run "<name>"`
runs one. A failing suite exits **5**, distinct from a crash, so CI can tell the two apart.

To deploy and prove it in one step:

```bash
npx xanots deploy ./xano/index.ts --test
```

A failing test exits 5 **without** retracting the deploy — the environment is live either
way, and the summary still carries its URL.

---

## Already have a Xano workspace? Pull it into TypeScript

`codegen` runs the loop the other way: it reads a workspace and writes it back out as
readable XanoTS source — real `s.db.query(...)`, `f.email()`, typed defs — not a JSON dump.
And not a loose pile of files either: you get the same runnable project `xanots init`
scaffolds, with the pulled workspace filling `xano/`. So a pull deploys:

```bash
xanots workspace codegen my-app   # your real workspace (the one your login is scoped to)
cd my-app
npm run build
npm run xano:deploy                 # → a live ephemeral URL
```

The other three sources are the same command with a different origin:

```bash
xanots sandbox codegen my-app          # your sandbox
xanots ephemeral codegen pr-42 my-app  # a named ephemeral (tenant first, path second)
xanots codegen ws.json my-app          # a bundle already on disk — offline, no login
```

Inside, `xano/` is shaped the way the workspace is: one directory per kind, with each
object under its parent — queries under the API group that owns them, triggers under
what they fire on. Each table gets its own `table/<name>.ts`, settings sit in `xano/workspace.ts`,
`_shared.ts` holds anything else referenced from more than one file, and `xano/README.md`
lists anything that did not translate cleanly.
Object identities (`guid`) are preserved, so cross-references stay intact. A statement
this SDK does not model yet round-trips verbatim rather than breaking the pull.

Pulled objects are authored the same way you would write them by hand — `table({...})`,
`query({...})`, `defineFunction({...})` — so the generated tree keeps its types. A pulled
table's columns still check on `fieldName`/`output`/`sortBy`, `InferInput<typeof q>` still
resolves a pulled query's payload, and a pulled agent still types `s.ai.agent.run`.

A pull states what the source workspace actually holds and leaves out what the SDK would
put back anyway. A table's `primary(id)` / `created_at` / `gin(xdo)` indexes are the
engine's standard set, so only the indexes someone created are listed. A trigger comes back
through the factory that built it (`tableTrigger`, `realtimeTrigger`, …) rather than a bare
`satisfies TriggerDef`, which keeps its typed stack handle; the two realtime types that bind
a def handle are the exception, since a stored trigger carries two guids with no way to know
they agree. And two objects that reference each other — a pair of tables joined both ways,
two functions that call each other — can't both be declared first, so the second reference
is a `{name, guid}` const hoisted to the top of the file (`const OrdersRef = {…}`) instead of
an import that would close a cycle. Only the guid is ever read, so it binds exactly.

`xano/README.md` also lists objects that were **already empty in the source** — an
endpoint someone created and never filled in pulls as a def with no `stack`, which looks
identical to a decode that gave up. The report is what tells the two apart.

A few options exist only so a pull can be *faithful*, and reading them in generated code
is the only time you should see them: `table: null` / `fn: null` (a statement whose target
was deleted or never bound), `merge` / `hidden` on a field, `paging: { enabled }` on a
query, `c.blank(tag)` (the editor's unconfigured value box — **not** a zero or an
empty collection; the engine reads `""` and `"0"` differently, so tidying one into the
other changes what the workspace stores), and `c.null("const:obj")` (the object-typed null
a `db.*` statement's `@meta` slot carries — different stored bytes from `c.obj(null)`,
which is the blank object, though both evaluate to null). They describe what the source workspace actually
stored — a pulled `table: null` is a defect to fix upstream, not a shape to copy — and
each carries that warning at the call site. A blank binding also reports, because a
statement wired to a table or function that no longer exists is worth seeing even though
it round-trips exactly.

Then it checks its own work: the project it just wrote is loaded, exported, and diffed
against the workspace it came from. A mismatch names the object and fails the command
(`--no-verify` opts out). So "it compiled" and "it means the same thing" are separate
claims, and you get both.

The findings above are printed either way. Verification runs after decoding is finished,
so whether it passes, disagrees, or cannot run at all, the report describing the decode is
rendered first — and a tree that was written but does not re-export exits **2**, distinct
from the **1** you get when nothing was written because the bundle could not be read.

**Reading the report.** It opens with a headline (`27 distinct issues across 424 findings;
3 need your attention`) and splits into three sections, most actionable first: *problems in
your workspace*, *things XanoTS could not model*, and the things stated only so the output
is not ambiguous. That split is the question a reader actually has — a `raw()` passthrough
is ours to close, a lambda reading an unbound name is theirs to fix, and an empty object is
neither. Findings that repeat the same sentence across objects collapse to one line with a
count and a collapsed object list, and each names the generated file it landed in.
`--report full` prints every site instead; `--report json` prints the findings as data, and
the same data is written into `xano/.xanots-codegen.json` on every pull, so parity is
trackable release over release and gateable in CI without scraping output.

Re-pulling is a real workflow: a second `codegen` into the same directory refreshes
`xano/` and leaves the rest of the project — your `package.json`, your `frontend/` —
exactly as you left it. No `--force` needed, because the tree carries a marker saying it
was machine-written.

> ⚠️ **`xano/` is a scratch surface, and there is no `xanots workspace deploy`.**
> Regenerating rewrites it (a directory that isn't a previous pull still needs `--force`),
> it carries schema only — no table rows — and deploying it is a *full replace* of the
> target. Pull from your real workspace, edit, and `deploy` to a disposable ephemeral or
> sandbox. Workspace env var **values** ride inline in `xano/workspace.ts` (that is what a
> deploy sends), so treat a pulled tree as secret-bearing before you commit it.

---

## The model: TypeScript in, real infrastructure out

You author declarative def-objects, register them on one `Xano` instance, and XanoTS
compiles the whole thing into Xano's importable bundle.

```ts
import { workspace, table, query, apiGroup, f, s, ref, c, expr, col } from "@xanots/core";

// A database table — `id` + `created_at` auto-inject, so declare only your own columns.
const user = table({
  name: "user",
  auth: true,
  schema: {
    email: f.email({ required: true, methods: ["trim", "lower"] }),
    name:  f.text(),
  },
});

const post = table({
  name: "post",
  schema: {
    title:     f.text({ required: true }),
    body:      f.text(),
    published: f.bool({ default: false }),
    author:    f.tableRef(user),          // a real foreign key, type-checked
  },
});

// A public API group + endpoint. This query def is also the contract your frontend imports.
const blog = apiGroup({ name: "blog", canonical: "blog" });

const listPosts = query({
  verb: "GET",
  apiGroup: blog,
  name: "list_posts",
  stack: [
    s.db.query({ table: post, where: expr(col("published"), "=", c.bool(true)), as: "rows" }),
  ],
  response: ref("rows"),
});

export default workspace("blog")
  .registerApiGroups([blog])
  .registerTables([user, post])
  .registerQueries([listPosts]);
```

Composing a workspace out of separately-authored modules is one line, but
`modules.flatMap((m) => m.tables)` will not typecheck — `Array.prototype.flatMap` binds its
element type to the first element, so every later table is checked against that one table's
schema. Pass an array literal (`[...a.tables, ...b.tables]` — spreads are fine), or annotate
the module array with `AnyTableDef` / `AnyQueryDef` / `AnyFunctionDef` / `AnyAddonDef`.

Tab-complete `s.` to discover the entire statement catalog — `s.db.*`, `s.math.*`,
`s.array.*`, `s.text.*`, `s.storage.*`, `s.api.*`, `s.cloud.*`, control flow, AI agent
runs, and more. **All 214 engine statement surfaces are authorable** — every field name
matches the Xano engine, and the output is proven byte-for-byte against the engine's own
golden fixtures.

### Seed data

Give a table `seed` rows and they ship into the database on deploy — so a fresh
environment comes up with lookup tables, demo content, or fixtures already in place,
not empty:

```ts
const product = table({
  name: "product",
  schema: {
    sku:   f.text({ required: true }),
    name:  f.text({ required: true }),
    price: f.decimal(),
    tags:  f.text({ array: true }),
  },
  // Rows are validated against the column types before deploy. A column without
  // `required: true` may be omitted (the engine applies its default). Omit `id` and
  // rows are keyed for you — 1..N for an int PK, a stable uuid for a uuid PK (or
  // set `id` on every row); a bad value
  // or unknown column is a loud error, never a silent drop.
  seed: [
    { sku: "SKU-001", name: "Aeron Chair",   price: 1395, tags: ["furniture", "ergonomic"] },
    { sku: "SKU-002", name: "Standing Desk", price: 599,  tags: ["furniture"] },
  ],
});
```

Pinning an `id` this way is a `seed` property, not a general bulk-insert one: the runtime
statement `s.db.bulk.add` **drops `id` from every row** unless you pass `allowIdField: true`,
assigning the next sequence value instead.

Its sibling `s.db.bulk.delete` has the mirror-image rule: a filter that constrains nothing
matches every row, so a `where`-less delete is a truncate. It **throws** unless you say so
with `allRows: true`, which wipes the table and returns the deleted count — the wipe can no
longer be reached by forgetting an argument. Reach for `s.db.truncate({ table, reset: true })`
when the id sequence should restart too.

Deploy is a full replace, so re-deploying re-seeds cleanly — no duplicate rows. Seed
data travels only in the deploy package (resolved at deploy time); it never enters the
compiled workspace bundle.

For data in a file, use `seedFile`:

```ts
seed: seedFile("./products.seed.json", import.meta.url),
```

The path resolves against the file that declares the table, and it is read with `node:fs`
at deploy time. Note the tradeoff: inline rows are typed against the table schema at compile
time, while a `seedFile`/thunk seed is opaque to the typechecker — `xanots export`/`deploy`
validates it instead, naming the row index, the offending column and the table's known
columns. A thunk (`seed: () => import("./products.seed.json")`) also works and is
the right shape for *computed* seeds — but be aware it does **not** keep seed values out of
a frontend build: the `import()` lives in your module, so a bundler emits the JSON as a
served chunk, and any frontend that imports a def whose module graph reaches that table
ships the seed to the browser. `seedFile` stores a path string, which a bundler has nothing
to follow.

Either way, keep secrets out of `seed` — it is throwaway fixture data for disposable
environments. As a backstop, `xanots deploy <entry> --static <dir>` and `xanots release <entry> --static <dir>`
refuse to publish a frontend build containing seed values from columns your schema marks
`access: "internal"` or `sensitive` (pass `--allow-seed-in-static` if the data is
deliberately public).

Typing is unaffected by the form you choose — the table's row type and column names stay
inferred.

---

## 60-second quickstart

The fastest start is `xanots init`, which scaffolds the whole project — a Vite
frontend under `frontend/` (styled with Tailwind v4), a XanoTS backend under
`xano/`, and the `xano:export`/`xano:deploy` scripts already wired:

```bash
npx @xanots/core init my-app   # scaffold; prompts for framework + AI instructions
cd my-app
npm run dev                    # run the frontend right away
```

Pick the frontend with `--framework`: **`react`** (the default — Vite + React 19 +
[shadcn/ui](https://ui.shadcn.com)) or **`svelte`** ([SvelteKit](https://svelte.dev/docs/kit)
with Svelte 5 + [shadcn-svelte](https://shadcn-svelte.com)). In a terminal `init` prompts; scripted
runs get React unless the flag says otherwise. Both scaffolds are the same project
either way — same backend, same `frontend/dist` build output, same deploy command —
so nothing downstream depends on which you choose.

```bash
npx @xanots/core init my-app --framework svelte
```

`init` flags: `--framework <react|svelte>` (default: `react`), `--name <name>`
(default: the folder name), `--ai <claude|codex|cursor|none>`
(repeatable; writes `CLAUDE.md`/`AGENTS.md`/Cursor rules — none by default),
`--force` (scaffold into a non-empty folder), `--no-install` (skip `npm install`).
The starter backend is empty but already compiles and deploys — grow it from the
walkthrough in `xano/EXAMPLE.md`.

A scaffold ships `@xanots/core` and nothing else from the `@xanots` scope. Add-ons
install on demand:

```bash
xanots marketplace list                      # every published add-on
xanots marketplace search auth               # …or narrow by keyword
xanots marketplace details @xanots/auth      # what it installs + how to register it
xanots marketplace install @xanots/auth      # add it to the project you're in
```

The three read verbs hit a public catalogue, so they work before you log in.
Every add-on is optional and none is assumed by anything in the scaffold —
install one when you need it.

`details` is the one to reach for when wiring an add-on: it prints the objects
the add-on puts on your workspace, what you have to supply, and the
`xano/index.ts` registration to copy. Piped, it emits JSON; `--prompt` emits
instructions written to be handed straight to a coding agent.

That is `npm install` with two additions: add-ons are discoverable from `xanots
--help`, and the command refuses before npm runs when you are not standing in a
project — the mistake npm answers by silently writing to the wrong `package.json`.
The package name is passed through exactly as typed, so version specifiers, tags,
and third-party packages all work.

To point `npm run dev` at a real backend, copy `.env.example` to `.env.local` — both
live at the **project root**, next to `vite.config.ts` — and set `VITE_XANO_HOST` to a
deployed URL. Deployed builds don't need it: `xanots deploy <entry> --static <dir>` injects the
backend URL as `window.XANO_HOST`, which takes precedence.

The frontend ships `Button` and `Card` already vendored, plus a pre-configured
`components.json`, so `npx shadcn@latest add dialog form input` (or
`npx shadcn-svelte@latest add …` on a Svelte scaffold) works immediately — no
`init` step for either CLI. Components are copied into your repo rather than
installed, so you own and edit them directly. [Lucide](https://lucide.dev/icons)
is installed on both scaffolds — `lucide-react` on React, `@lucide/svelte` on
Svelte — and the landing page already uses it. Rebrand by editing the color tokens
at the top of `frontend/src/index.css` (Tailwind v4 keeps the theme in CSS —
there's no `tailwind.config.js`); that one stylesheet backs both frameworks.

> On a Svelte scaffold `npm run typecheck` runs `svelte-kit sync && svelte-check`
> rather than `tsc`. It checks the backend and the components together — `tsc`
> cannot read `.svelte` files at all.

> **The SvelteKit scaffold prerenders every route.** `frontend/src/routes/+layout.ts`
> sets `prerender = true`, so each route becomes its own HTML document at build
> time and loads as a real page. Pages live in `frontend/src/routes/`, and `files`
> in the `sveltekit()` plugin config keeps the project single-rooted with `xano/`
> as a peer. That config lives in `vite.config.ts` — there is no
> `svelte.config.js`, matching where SvelteKit's own scaffold now puts it.
>
> There is still no server at runtime — Xano is the backend and `deploy --static`
> ships to a host with no runtime, so `+page.server.ts`, form actions, and server
> `load` have nothing to run on, and the build does not stop you. Treat them as
> unavailable rather than trusting a green build.
>
> Two things follow from prerendering. Because it renders at build time,
> module-scope `window`/`document` access fails the **build** rather than the
> browser — use `onMount`, or guard with `browser`. And a dynamic route like
> `/posts/[id]` **fails the build** unless it declares which ids exist:
>
> ```ts
> // frontend/src/routes/posts/[id]/+page.ts
> export const entries = () => [{ id: "1" }, { id: "2" }];
> ```
>
> That is deliberate — a loud build error beats shipping a page that 404s for
> real users. Unmatched paths get a real 404 from
> `frontend/src/routes/404/+page.svelte`, which prerenders to `404.html`. It has
> to be a route: SvelteKit never prerenders `+error.svelte` to a file, so that
> alone would ship no `404.html` and every unknown path would serve the home page
> with a 200 instead.
>
> One more build-time check comes with prerendering: a hash link to an id that is
> not on the page it renders on — `<a href="#pricing">` with no `id="pricing"` —
> **fails the build**, naming the route and the id. A hash nav in
> `+layout.svelte` is exempt on `/404` only, since that route inherits the layout
> and by definition carries none of the page's sections.

Prefer to wire it by hand? The same loop, from scratch:

```bash
# 1. Install
npm install @xanots/core
npm i -D tsx                       # lets the CLI run your .ts entry directly

# 2. Write your workspace in TypeScript
#    xano/index.ts  →  export default workspace("my-app")...

# 3. Sign in once (OAuth — no API keys to copy around)
npx xanots login                 # opens your browser; you pick the instance + workspace

# 4. Deploy to a live ephemeral environment — this is the dev loop
npx xanots deploy ./xano/index.ts                     # → prints your ephemeral URL

# 5. Ship a built frontend alongside it (both land on the same ephemeral).
#    --static injects the backend URL for you, so no pre-build wiring is needed:
npm run build                                           # → ./dist
npx xanots deploy ./xano/index.ts --static ./dist
```

That's the whole loop: **install → write TypeScript → login → deploy → URL.** No dashboards,
no manual imports, no upload scripts. Deploy again to refresh the same environment.

> The entry must be an ES module (XanoTS defs are ESM-only). On Node ≥ 22.6 a `.ts`
> entry loads natively; install [`tsx`](https://tsx.is) for older Node or multi-file
> workspaces. Set `"type": "module"` in the nearest `package.json` if you hit a
> "must be ES modules" error.
>
> **Verifying a def outside a bundler.** Inside a bundler (Vite/webpack) importing a query
> def to read `getPath()`/`verb` just works. To spot-check from Node, run a **real file**
> with `tsx <file.ts>` **from inside the project root** — not `tsx -e "import …"` (its
> CJS-preparse mis-resolves the package `exports` map → `ERR_PACKAGE_PATH_NOT_EXPORTED`) and
> not bare `node file.ts` (chokes on the `.js`-specifier intra-workspace imports the CLI's
> own loader resolves). Intra-workspace imports use `.js` specifiers
> (`../tables/links.js`) under `moduleResolution: bundler`, not extensionless.

---

## The payoff: a type-safe frontend, for free

Because your API is a typed def, the code that *calls* it can reuse that def instead of
re-typing URLs and request bodies. Import the `query()` into your frontend:

```ts
import { listPosts } from "../xano/index.js";          // the same def you deployed
import { post } from "../xano/tables.js";
import type { InferRow } from "@xanots/core";

const BASE = "https://x8ki-letl.n7.xano.io";           // your instance host

type Post = InferRow<typeof post>;                     // { id: number; created_at: number; title: string; … }

async function fetchPosts(): Promise<Post[]> {
  const res = await fetch(BASE + listPosts.getPath(), { method: listPosts.verb });
  return res.json();                                   // typed end to end
}
```

- **`listPosts.getPath()`** → the endpoint path, resolved from your code (or the frozen
  `xano.lock`). No hardcoded strings.
- **`listPosts.verb`** → the HTTP method, straight from the def.
- **`xanots paths ./xano/index.ts`** (alias `routes`) → list every endpoint's verb and
  resolved `api:<canonical>/<name>` path from the CLI, without writing a script — handy for
  wiring a client or curling a live env.
- **`InferInput<typeof someQuery>`** → the request-payload type, derived from a query's
  `input` map at compile time. Required inputs are required keys; enums become literal
  unions; nested objects and lists carry through. **No codegen, always in sync.**
- **`query.toSearchParams(input)`** → the GET transport counterpart to `InferInput`:
  serialize an input map into a `URLSearchParams` (scalars stringify, arrays repeat the
  key, `null`/`undefined` are dropped) instead of hand-building `?id=…`.
- **Endpoint names hold `A-Z a-z 0-9 _ - /` and `{}`** — nothing else, capped at 200 chars.
  A `.` is the trap: Xano does not reject `name: "export.zip"`, it stores the endpoint with
  an *empty* name, so it deploys clean and then 404s `Unable to locate request.` on every
  request. `query()` throws instead. Name it `export_zip` or `export/zip` and set the file
  extension in the response headers. Same rule for `realtimeChannel` and `tool` names;
  `realtimeMessage` is narrower still (no `/` or `{}`).
- **URL path params** → name the endpoint with `{param}` segments and declare an input per
  segment. `getPath({ params })` fills them, with the keys typed from the name itself:

  ```ts
  const getPost = query({
    name: "blog/{slug}/review/{review_id}",       // segments chain; no wildcards
    verb: "GET",
    apiGroup: api,
    input: { slug: input.text(), review_id: input.int(), verbose: input.bool() },
    stack: [s.db.get({ table: post, fieldName: "slug", fieldValue: inp("slug"), as: "row" })],
    response: ref("row"),
  });

  getPost.getPath({ params: { slug: "hello", review_id: 7 } });
  // → "/api:<canonical>/blog/hello/review/7"
  getPost.toSearchParams({ verbose: true });      // → "verbose=true" (path params dropped)
  ```

  Every `{param}` must have a matching input or `query()` throws — Xano treats an unbound
  marker as inert route text, so the endpoint would answer on the path and see nothing.
  Inputs that aren't in the path (`verbose`) stay ordinary query-string params. Never
  interpolate the path by hand: `getPath` percent-encodes each value, so a `?`, `#` or space
  stays inside its segment instead of restructuring the URL, and it throws on the two values
  encoding can't contain — one holding `/`, and one that *is* `.` or `..` (a URL parser drops
  those before routing, addressing a different endpoint). `realtimeChannel()` paths work
  identically, minus the encoding: a channel address is matched literally, not parsed as a URL.
- **`InferRow<typeof post>`** → the table's row type. Rename or retype a column and every
  consumer breaks at compile time — exactly where you want it.
- **`InferResponse<typeof someQuery>`** → the endpoint's **response** type, closing the round
  trip. It auto-derives the common shapes with no codegen: an object-literal response yields
  those keys, and a query that returns a variable filled by a db op derives that op's result —
  the full row for `db.add`/`db.edit`/`db.patch`/`db.add_or_edit` (→ `Row` — each binds the
  full written row rather than null, so it stays non-nullable; a genuine miss throws instead of
  yielding null — `NotFound`/404 for `edit`/`patch`, a unique-constraint error for `add`, while
  `add_or_edit` upserts and never misses), `Row | null` for `db.get` (it binds
  `null` on a miss rather than throwing — handle the not-found path), a row list for
  `db.query`/`db.bulk.patch` (→ `Row[]`), a `boolean` for `db.has`, a `number` count for
  `db.bulk.delete`, and a `get`/`query` `output: [...]` selection narrows to a `Pick` (still
  `| null` for `get`). A dotted entry selects sub-keys of an object column
  (`output: ["id", "meta.url"]`, on a statement or an addon); the narrowing keys off the
  path's root, since an object column's sub-keys aren't declared in the schema.
  Where the shape isn't statically knowable — a value reshaped by a filter/lambda, built by
  control flow, or from an op the engine itself leaves untyped (`db.del`, `db.bulk.add`/`bulk.update`,
  raw `direct_query`) — it resolves to `unknown`; declare `responseShape` to close it.
  In an object-literal response the **keys** are always known, but a **value** is typed only
  when it traces to a binding: `response: { success: c.bool(true) }` derives
  `{ success: unknown }`, not `{ success: boolean }`. A constant carries no reference to
  follow. Reach for `responseShape` when a client needs those keys typed.
  A **nested** member resolves by those same rules, to any depth, in either spelling —
  `response: { user: obj({ id: ref("row.id") }) }` and the raw literal
  `response: { user: { id: ref("row.id") } }` both derive `{ user: { id: number | null } }`.
  A **call** carries the shape across: `s.function.call`/`s.function.run`, `s.tool.call` and
  `s.api.call` (workflow-test only, above) given a def **handle** bind their `as` with the
  target's own `InferResponse`,
  so `ref("discount_result.discount_cents")` in the caller types to that field instead of
  `unknown`. What the target resolves to is what propagates, so declaring `responseShape` on
  the **target** fixes every caller at once. A target named by *string* has no def to read and
  stays `unknown`, as does an async `s.function.run` (it binds a job handle, not the result).
  The same derivation runs on every response-bearing kind — `query()`, `defineFunction()`,
  `realtimeMessage()`, `tool()`, `middleware()`, and the response-bearing triggers — and each
  of them takes `responseShape`. A trigger builds its stack and response through *callbacks*
  (`stack: (t) => [...]`), and the trace follows through them. This matters most where a
  handler's response is the only type a client has: a realtime message's broadcast payload,
  or a channel `deliver` trigger's return, which is that recipient's copy of the message.

```ts
import { listPosts, getPost } from "../xano/index.js";
import type { InferResponse } from "@xanots/core";

type Posts = InferResponse<typeof listPosts>;   // Post[]      — derived from the db.query it returns
type Post  = InferResponse<typeof getPost>;      // Post | null — a db.get misses to null
```

For a **computed or multi-key object response**, author it as a *record of values* —
`response: { success: c.bool(true), id: inp("id") }` — **not** `c.obj({ ... })`. `c.obj` builds a
*constant*, so a tagged value nested inside it would serialize as internal representation the
engine can't decode (a runtime 500); nesting one is now a compile error that points you at the
record form (issue #42). A **nested plain object** in a record
response (`response: { user: { id: ref("u"), age: 3 } }`) is auto-wrapped for you — no manual
`obj({ ... })` — and raw literals in a call/agent `input` map coerce too
(`s.function.run({ fn, input: { max_age_days: 3 } })` — no `c.int(3)`).

When a response is filtered, computed, or otherwise opaque to the static walk, declare it once
on the query and every caller derives from that single source of truth:

```ts
const getPost = query({
  verb: "GET", apiGroup: blog, name: "get_post",
  input: { id: input.int({ required: true }) },
  stack: [s.db.query({ table: post, where: expr(col("id"), "=", inp("id")), as: "rows" })],
  // A filtered response is opaque to the static walk, so derivation is `unknown`.
  response: withFilters(ref("rows"), fl.first()),
  responseShape: null as InferRow<typeof post> | null,   // declare the real shape once
});
type MaybePost = InferResponse<typeof getPost>;           // InferRow<typeof post> | null
```

(A plain `response: ref("row")` off a `s.db.get` needs no `responseShape` — it already
derives `InferRow<typeof post> | null`, since `db.get` misses to `null`.)

**Factoring statements into a helper.** The trace walks the stack's *tuple*, so spreading a
helper typed `Statement[]` widens the stack and nothing in it resolves any more — the response
types as `StackTupleWidened`, which names the cause. Return `statements(...)` from the helper
and the tuple survives the spread:

```ts
import { statements } from "@xanots/core";

function assertOk(v: string) {                 // no `: Statement[]` annotation
  return statements(s.lambda({ ... }), s.precondition({ ... }));
}

stack: [...assertOk("res"), s.db.add({ table: post, row, as: "created" })],
response: ref("created"),                      // still traced
```

Fixed arity only — a helper that builds its array in a loop can't be a tuple, so declare
`responseShape` there.

This mirrors how the Xano engine itself derives an endpoint's response schema (a static walk of
the stack), so what you get in the type is what the endpoint actually returns — and it degrades
to `unknown` in exactly the cases the engine can't resolve either.

A GET endpoint carries its inputs in the query string rather than a JSON body:

```ts
import { getSnippet } from "../xano/index.js";
import { query, type InferInput } from "@xanots/core";

const BASE = "https://your-instance.xano.io";

async function fetchSnippet(id: number) {
  const params = { id } satisfies InferInput<typeof getSnippet>;   // { id: number }
  const res = await fetch(`${BASE}${getSnippet.getPath()}?${query.toSearchParams(params)}`);
  return res.json();
}
```

The `@xanots/core` entry has **zero Node dependencies**, so importing your workspace
graph into a browser bundle just works. The `node:fs`-backed emitters live in the
separate `@xanots/core/node` entry a frontend never pulls in.

**Bundle size & tree-shaking.** `@xanots/core` is `sideEffects: false`, so a bundler drops
the SDK exports your frontend doesn't use. But importing a query **def** for its `getPath()`
also pulls whatever its `stack` builds — the `s.*`/`c.*` factory *calls* run at module load
to construct the def, so they can't be tree-shaken out. Types are free (`InferInput`/
`InferRow` erase to nothing — use `import type`). That cost is a **floor**, not a function of
how lean the def is. Measured on a Vite lib build against the published package: one
`apiGroup` + one empty `query`, imported for a single `getPath()`, is **289 kB minified
(57 kB gzipped)** against 56 B for a hand-written path string. A realistic def — two
tables, a foreign key, a typed input, a `db.query` with a `where` and a sort, plus a
second endpoint — measures 290 kB. That 1 kB spread is the point: the floor is the SDK
runtime itself, so splitting modules or simplifying a def does not move it, and the cost
is paid by importing any def at all.

**Generate a route manifest instead.** It keeps the derived-not-hardcoded contract at
almost no bundle cost:

```bash
xanots paths ./xano/index.ts --emit xano/routes.gen.ts
```

The emitted file is plain data plus one interpolator and imports nothing at all — the same
app builds to 1.3 kB, a 214x saving, with no SDK code in the output. Route names and their `{param}` keys are still checked at compile
time, so a backend rename is a compile error rather than a 404:

```ts
import { routePath, ROUTES } from "../xano/routes.gen";

fetch(BASE + routePath("blog/{slug}", { slug }), { method: ROUTES["blog/{slug}"].verb });
```

Realtime is in the same file when the workspace has any: `socketUrl(server, baseUrl)` for the
websocket URL and `channelPath(channel, params)` for the path a frame's `channel` field takes,
both keyed and `{param}`-checked exactly like the routes. `socketUrl` is the equivalent of
`realtimeServer().getUrl()` down to the tenant rule — a base URL that names a tenant
(`https://host/tenant/ab-cd`, what deploy injects as `window.XANO_HOST`) is rewritten to the
socket's own `wss://host/ws/ab-cd:<canonical>` form, which is the one address a frontend has no
way to reconstruct. Resolve once, from the `https://` base — feeding a resolved socket URL back
in as a `baseUrl` throws rather than append a second `/ws/…`:

```ts
import { socketUrl, channelPath } from "../xano/routes.gen";

const ws = new WebSocket(socketUrl("chat", window.XANO_HOST), token);
ws.send(JSON.stringify({ action: "join", channel: channelPath("rooms/{room_id}", { room_id }) }));
```

Add `--strict` in CI to fail when the committed manifest is out of date. A hand-typed
`ROUTES` table is the option that gives up both the bundle saving and the rename safety.

---

## Reference

**Where the exhaustive reference lives.** Every kind, statement, filter, and field type is
typed, so your editor's autocomplete is the fastest lookup — tab-complete `s.`, `f.`, `c.`,
`fl.`, `input.`. For the written catalog, the package ships two machine-readable files that
are generated from the SDK's own sources and can never drift from it: **`llms.txt`** (the
router — the mental model and the gotchas, plus a map of the **`llms/*.md`** topic files that
carry each surface's signatures and the engine behavior they depend on) and
**`manifest.json`** (per-entry detail: full field schemas with engine defaults, filter
argument lists, stored-name mappings). All of them are readable by people too.

What follows is the part neither of those replaces: the shape of a project, and the
behavior that will bite you.

<details open>
<summary><b>Project structure</b></summary>

Lay objects out however you like and register them explicitly — there's no folder
auto-discovery magic (deliberately):

```
xano/
├── function/     get_user.ts         export const getUser = defineFunction({...})
├── table/        table.ts            export const user = table({...})
│   └── trigger/  on_insert.ts        export const onInsert = tableTrigger({...})
├── query/        public.ts           export const publicApi = apiGroup({...})
│                 public/posts_GET.ts export const posts = query({...})
├── agent/        assistant.ts        export const assistant = agent({...})
├── realtime_server/ chat.ts               export const chat = realtimeServer({...})
│                 chat/room.ts              export const room = realtimeChannel({...})
│                 chat/room/send.ts         export const send = realtimeMessage({...})
├── workspace.ts                      export const workspaceSettings = workspaceConfig({...})
└── index.ts      workspace("my-app").registerTables([...]).registerFunctions([...])…
```

Objects nest under whatever owns them. Anything with children — an API group, a
realtime server, a channel — is a file named for itself sitting *beside* the folder
holding its children, so `chat.ts` opens in a tab you can tell apart and a group with
no queries needs no folder at all. Realtime is the deepest, being the only three-level
hierarchy in a workspace — server, then channel, then message — and a trigger sits in
a `trigger/` folder at whichever level it fires on.

Paths are lower case throughout — an HTTP verb is the one exception, because it is
the method rather than a word. Bindings keep the object's own casing, so a file name
and the symbol it exports can differ.

That is the shape `xanots codegen` writes, and its `index.ts` re-exports every object
by name — import from the tree's root rather than from a file, since a file path moves
when an object's parent or its `_shared.ts` placement changes. Hand-authored projects are
free to use any other layout; only `index.ts` registering the objects matters.

`workspace("my-app")` is the natural entry point — sugar for
`new Xano().registerWorkspace({ name: "my-app" })`, returning the same chainable registry.
Authoring is **declarative def-objects** passed to factories; there is no callback/chaining
builder. `xano.export()` returns the importable `packageExport` bundle, and
`xanots export`/`deploy` read the module's default export.

</details>

<details>
<summary><b>Object kinds</b></summary>

Every top-level Xano object is a registered kind with a factory and a `Xano.register*`
method: `defineFunction`, `table`, `query`, `apiGroup`, `tool`, `mcpServer`, `agent`,
`task`, `workflowTest`, `middleware`, `addon`, `realtimeServer`, `realtimeChannel`,
`realtimeMessage`, `microservice` (its own section below), `workspaceConfig`, and the seven
trigger factories below. Signatures and payload keys are in `llms/object-kinds.md` and
`llms/triggers.md`; what follows is what
the types don't tell you.

**Triggers take a callback stack.** `stack: (t) => [...]`, not the plain array every other
kind uses — because a trigger's inputs are **implied by its type** (fixed by Xano, not
editable) and injected automatically. So triggers take no `input` field, and the typed
handle `t` is the only way to read them (`response: (t) => ...` on response-bearing types).
The seven types are `tableTrigger`, `realtimeServerTrigger`, `realtimeChannelTrigger`,
`mcpServerTrigger`, `agentTrigger`, `workspaceTrigger`, and `errorTrigger`; they share one
stored envelope discriminated by `obj_type`.

```ts
tableTrigger({
  name: "on-user-insert",
  table: users,
  actions: { insert: true },
  // Optional row filter, evaluated by the DATABASE before the stack runs — so it
  // names the SQL pseudo-tables with col(), NOT the t handle. Rejected with
  // `truncate`; insert cannot read OLD.*, delete cannot read NEW.*.
  search: cmp(col("NEW.email"), "!=", c.text("")),
  stack: (t) => [
    // t.new("email") is typed to the row; t.action is the op; t.old is null (insert-only).
    s.db.add({ table: auditLog, row: { email: t.new("email"), event: t.action } }),
  ],
});
```

**A workflow test is an end-to-end test, and its `datasource` is the trap.** `workflowTest`
takes no `input` and no `response` — it calls other objects and asserts on what they bind.
Leave `datasource` off: the default `""` runs against an **empty** datasource. Naming one
makes the engine **clone** that datasource before every run, so pointing a test at
production-sized data is slow enough to fail the run outright. `"live"` warns at compile
time; every other name is your call.

Empty means empty: **no `table({ seed })` rows exist while the test runs**, so every `db`
read misses unless the test creates what it needs first — typically a `defineFunction`
fixture the stack calls before anything else. A test written against a seeded row fails
with your own precondition message, which reads as a wrong id rather than an empty
database. A failing `s.api.call` is the other surprise: it **binds the error envelope**
(`{code, message}`) to its `as` and carries on rather than raising, so a later assertion
gets blamed for a call that failed several statements earlier — assert on `ref("r.code")`
when a call may fail. `llms/tests.md` carries the rest, including what `s.api.call` can and
cannot do about authentication.

```ts
workflowTest({
  name: "signup_works",
  tags: ["smoke"],
  // datasource omitted on purpose — "" is an EMPTY datasource, not "no datasource".
  stack: [
    s.function.call({ fn: createUser, input: { email: "a@b.c" }, as: "created" }),
    s.expect.to_be_defined({ expr: ref("created") }),
    s.expect.to_equal({ expr: ref("created.status"), value: c.text("ok") }),
    // A regex assertion takes a PATTERN, so build it with `c.regex(...)`.
    s.expect.to_match({ expr: ref("created.id"), value: c.regex("^usr_[a-z0-9]+$") }),
  ],
});
```

**Saved unit tests are a different thing, and they hang off the object.** A `query`,
`defineFunction` or `middleware` takes a `tests` array — the tests the Xano editor shows.
Each is a named set of inputs run against *that* object, asserted with the top-level
`expect.*` helpers. (`s.expect.*`, above, builds a *statement* for a workflow-test stack;
`expect.*` builds a record stored on a test. They are not interchangeable, and the types
enforce it.) Any statement in the stack can return a **mock** instead of doing its work,
keyed by test name — and only while that named test runs, so a mock changes nothing about
a normal request.

A unit test's `datasource` is the same trap as a workflow test's, with the same default:
`""` is an **empty** datasource, so **no `table({ seed })` rows are visible while a unit
test runs** either. Every `db` read misses, and an assertion on the first row fails against
a deployment whose endpoint returns those rows over HTTP a second later. Create what the
test needs inside the run — a `defineFunction` fixture the stack calls first — or `mock`
the read.

```ts
query({
  name: "score",
  verb: "POST",
  input: { score: input.int({ required: true }) },
  tests: [
    {
      name: "adds one",
      input: { score: c.int(1) },
      // Subject first — argument order is the assertion.
      expect: [expect.to_equal(resp(), c.int(2))],
    },
  ],
  stack: [
    // Returns 2 while "adds one" runs; does nothing on a real request. A name
    // no test declares throws at compile time.
    s.set_var("total", c.expression("$input.score + 1"), {
      mock: { "adds one": c.int(2) },
    }),
  ],
  response: ref("total"),
});
```

A pull brings tests back, along with a query's saved request/response `example`. The one
thing it withholds is a test's auth `token` — that is an expiring credential rather than
authored configuration, so `xanots codegen` reports it as a deliberate omission instead of
writing it into a committed tree.

**Four of the `Run …` statements only run inside a workflow test.** `s.api.call`,
`s.task.call`, `s.trigger.call` and `s.workflow_test.call` are resolved by the engine at
run time, and outside a `workflowTest` stack it cannot reach the target — so one of them in
a query, function or task type-checks, exports, imports and **deploys clean**, then answers
the first real request
with `ERROR_FATAL: <Type> does not exist`. It is not per host kind: the same call fails
identically from a function that a query runs. `xanots export` refuses them outside a
workflow test. `s.function.call`, `s.tool.call`, `s.middleware.call` and `s.addon.call` run
from any stack, as does `s.function.run` — the ordinary way to invoke a function. To share
logic between two endpoints, put it in a `defineFunction` and `s.function.run` it from both.

**`s.expect.to_match` takes a regex PATTERN, not text.** The engine runs it through PHP
`preg_*`, which reads the first character as the delimiter — so a `c.text("^usr_.*$")` there
is a pattern the engine cannot run, and the assertion fails against the very string it was
written for. `c.regex("^usr_.*$")` (or `c.regex(/^usr_.*$/)`) wraps and escapes it; a bare
`c.text` pattern is refused at compile time and pointed here. A `ref`/`inp` pattern, whose
text isn't visible to the check, is passed through untouched.

**Realtime** — the only three-level containment chain in the SDK: `realtimeServer` owns
`realtimeChannel`s, which own `realtimeMessage` handlers (a message is the realtime
analogue of a query — its own typed payload and stack). Pass the **handle**, not a name: a
channel path is unique only within its server. A channel's `input` types its **path** params
(`rooms/{room_id}`); a message's `input` types the message **payload**. A server is off
until `enabled: true`.

```ts
const chat = realtimeServer({ name: "chat", enabled: true });

const room = realtimeChannel({
  name: "rooms/{room_id}",           // `input` types the PATH params
  server: chat,
  input: { room_id: input.int() },
  publish: { who: "authenticated" },
  conversation: { enabled: true, limit: 50 },   // client-visible transcript
});

realtimeMessage({
  name: "send",                      // `input` types the message PAYLOAD
  channel: room,                     // the handle carries the server too
  input: { body: input.text({ required: true }) },
  deliverTo: "channel",              // or "sender" (request/response) / "others"
  stack: [s.debug.log({ value: inp("body") })],
});
```

The client side is derived too, the same way `query().getPath()` works — `chat.getUrl(BASE)`
builds the socket URL (`wss://…/ws/<canonical>`, with a tenant base URL translated into the
socket's `/ws/<tenant>:<canonical>` form) and `room.getChannel({ room_id: 42 })` builds the
path a client joins. Both throw rather than guess. In a **browser bundle**, reach for the
generated manifest's `socketUrl`/`channelPath` instead — same addresses, same checks, without
importing the defs (see
[The payoff: a type-safe frontend, for free](#the-payoff-a-type-safe-frontend-for-free)).

Five traps account for most realtime bugs. The full wire protocol — every server frame,
the presence roster shape, the at-least-once client contract — is in `llms/kinds-realtime.md`.

- **An empty return denies, and so does a crash.** `connect` and `join` are gates: return
  `{ allowed: c.bool(true) }` or any truthy value to admit. A stack that falls through, or a gating
  trigger with no `response`, refuses everyone — and a raise refuses too, because the gate is
  seeded with a deny it keeps when the stack throws. Both failure modes lock the door, so the
  risk to plan for is a self-inflicted lockout, not a breach: guard every drill inside a gate
  with `ref(path, { safe: true })`, since `db.get` binds `null` on a miss. `export()` warns on
  the missing `response`; nothing can warn about the raise. Gating is opt-in — a server with
  no `connect` trigger admits everyone.
- **Only `null` drops a message.** In a `deliver` trigger (per recipient) and in a message
  handler, `false`/`0`/`""` all deliver the message unchanged, and a crash broadcasts the
  sender's original unvalidated payload. Return `null` to suppress. So a redaction check
  written as a boolean sends the very message it was meant to hide. Per-viewer redaction
  also takes **two objects**: the `deliver` trigger *and* `delivery: { perRecipient: true }`
  on its channel. Either half alone delivers the payload unchanged to everyone, so a gate
  whose return semantics are perfect still ships unredacted if the flag is missing — and the
  flag costs a stack per recipient per message, so it is opt-in. `export()` warns on both
  halves.
- **`conversation: { enabled: true }` alone stores nothing.** `limit` defaults to `0`, and
  `0` means retain none. Always pass a `limit`. What a handler broadcasts *is* the stored
  row, so broadcast everything a future joiner needs to render it.
- **An idle socket is reaped after ~10 minutes.** A listen-only client (feed, dashboard,
  presence sidebar) must send `{ action: "ping" }` or any frame periodically, or it silently
  drops and reconnects forever.
- **`s.realtime.publish` is the push direction, and it is fail-soft.** It bypasses the
  channel's `publish.who` (authorization belongs in your stack), does not invoke the named
  message's handler, and swallows a missing or disabled server — a mis-targeted publish is
  silent. Pass the server handle and a filled-in path (`room.getChannel({ room_id: 42 })`),
  never the template — a constant channel still carrying `{param}` throws at author time, and
  a constant server or channel naming nothing this workspace registers warns at export.

**The superseded realtime layer.** Xano has had two realtime generations and they reuse the
same words. `realtimeTrigger(...)` and `s.api.realtime_event(...)` belong to the old
workspace-global layer; they are supported only so `codegen` can bring back a workspace that
holds them, and they are named in `llms/legacy.md` rather than in the authoring catalogs.
Aiming `s.api.realtime_event` at a current-layer channel publishes into the void — use
`s.realtime.publish({ server, channel, data })`, which names the owning server and so can
resolve the channel.

**MCP servers & agents** — both persist under the `toolset` payload key, so an `mcpServer`
and an `agent` **sharing a name collide**. A `tool({...})` is its own kind, referenced by
handle from either.

```ts
// Auth is PER-TOOL and works like a query's: name an auth table({ auth: true }).
mcpServer({ name: "books", tools: [{ tool: searchTool, auth: users }] });

const assistant = agent({
  name: "assistant",
  llm: { type: "xano-free", systemPrompt: "Be helpful.", prompt: "Answer the question." },
  // Pass the handles directly; the `{ tool, enabled?, auth? }` wrapper (above)
  // is only for per-tool auth or `enabled: false`.
  tools: [searchTool],
});

// Agents have NO public endpoint — invoke them in-stack from any host with a stack.
query({
  name: "ask", verb: "POST", apiGroup: api,
  input: { question: input.text({ required: true }) },
  stack: [s.ai.agent.run({ agent: assistant, args: obj({ question: inp("question") }), as: "answer" })],
  response: { text: ref("answer.result") },
});
```

- **The run result is an envelope, not the completion.** The model's text is at **`.result`**
  — `ref("answer")` is the whole metadata object (`finishReason`, `steps`, …). Both are
  typed, so `InferResponse` reflects either.
- **`llm` is a provider-discriminated union** — `anthropic` / `openai` / `google-genai` /
  `xano-free` (which needs no API key) — each with its provider's typed fields.
- **Structured output types the call site.** Author `output: { schema: { … } }` on the agent
  with the `input.*` catalog and `.result` is typed from it wherever the handle is passed —
  no second witness. The type-only `resultShape` is only for overriding that, or for an
  agent referenced by bare name.
- **String settings are Twig-templated at run time.** The `args` you pass to
  `s.ai.agent.run` become `{{ $args }}` (env vars are `{{ $env.NAME }}`), which is how an
  endpoint's inputs reach the prompt. Numeric and boolean fields are not templated. Build a
  dynamic arg with `obj({...})`, not `c.obj`.
- **`mcpServer().getUrl(HOST)`** derives the Streamable-HTTP endpoint from the def, the same
  contract as `query.getPath()`. Resolve once — handing the result back in as a `HOST` throws
  rather than append a second endpoint path. Agents expose only `getCanonical()`.

**Background execution.** `s.function.run` and `s.ai.agent.run` take a `runtime` block
(`{ mode: "async-shared" }`, or `"async-dedicated"` with `cpu`/`memory`/`timeout`/`maxRetry`)
that moves the call off the request path. This is **not** a performance knob: Xano rewrites
an async call to a statement that dispatches and continues, so it does not return the
function's result — don't bind `as` expecting a value. Collect results later with
`s.await({ ids })`.

</details>

<details>
<summary><b>Microservices</b></summary>

A microservice is a container workload deployed alongside the workspace and called from a
stack with `s.microservice.request`. Two mutually exclusive shapes chosen by `kind`: `builtin`
declares containers (image/ports/resources/env/command/args) plus optional `ingresses`, and
`helm` points at a chart and its `values`; passing both throws.

```ts
export const echo = microservice({
  name: "echo",
  deployment: {
    replicas: 2,
    containers: [{
      name: "echo",
      image: "ealen/echo-server:latest",
      ports: [{ servicePort: "8080", containerPort: "80" }],
      resources: { cpu: "50m", ram: "256Mi" },
    }],
  },
});
```

Call it by passing the def itself. `port` folds into the single `"name:port"` host string
the engine reads, and is optional — a microservice exposing exactly one `servicePort`
resolves to it, and one exposing several requires it. A port the microservice doesn't expose
is a type error where the def's ports are known, and a build-time throw otherwise:

```ts
s.microservice.request({ as: "res", host: echo, path: "/health" });
```

Only `host` and `path` are required. `method`, `params`, `headers`, `timeout`, and
`follow_location` default to the engine's own values (`GET`, `{}`, `[]`, `10`, `true`) and are
always written — this statement's schema requires them, so they can't be left off the wire;
you just don't have to type them.

`host` binds by name, not by guid, because that is how the engine resolves it — so renaming
a microservice fixes every call site at once. A plain `"name:port"` string is also accepted
and is the only way to reach an instance-level microservice, which isn't a workspace object;
nothing checks that spelling, so prefer the def wherever there is one.

A container takes time to come up, so `xanots deploy` waits for it: after the import it
reads each microservice and reports whether it is ready, still starting, or failed, then
lists them. Skip the wait with `--no-verify`. The same report is available any time from
`xanots ephemeral get <env>`, `xanots sandbox details`, and `xanots workspace details`.

Two outcomes, and only one of them is a warning:

- **The engine reports the microservice broken** (an image that won't pull, a container that
  won't start) — the deploy **exits 4**. Waiting longer cannot change that answer, and a URL
  and a ✓ printed over a dead workload is not a successful deploy. This is the default; there
  is no flag to turn it off.
- **It simply hasn't reported ready by the end of the wait** — a warning, exit `0`. The
  backend is live and a slow container usually follows moments later. Pass
  **`--require-microservices`** to make that exit 4 too, which is what CI wants: nobody is
  there to find out whether "should come up shortly" happened.

Exit 4 is the microservice sibling of exit 3 (a `--static` upload that failed while the
backend deploy stood): the import committed, and something it deployed is not serving. The
URL and the JSON summary still print either way — the exit code is what carries the
difference.

`tenantDeploy: "manual"` rows are reported but never waited on — nothing starts them for you.
Reach for it when the row should exist without a workload behind it; `examples/sandbox` uses
it so deploying the examples doesn't wait on containers.

Container names are free-form: they need not match the microservice's own name, and nothing
about addressing depends on them. A stack reaches the **microservice** name (plus a
`servicePort`), whichever containers sit behind it, so a multi-container workload names each
one for what it is.

**This surface is early and expected to change**, and every export of a workspace declaring a
microservice prints a notice saying so — the docs are read before writing, which is not where
you are when it matters. `configs` and `volumes` are typed and `@deprecated` but **not
deployable**: the engine rejects an import carrying either, so `export()` fails the build
rather than letting the deploy fatal minutes in, after provisioning has begun. Declare a value
the workload reads as a container `env` entry, and storage as a container `volumes` entry
(`emptyDir`, `persistent`, or `config`). Both fields stay typed so a pulled workspace holding
one still decodes.

Two fields carry secrets into the bundle — and into a pulled tree — verbatim:
`chart.values` and `registryAuth.dockerconfigjson`, because otherwise a pulled microservice
could not be redeployed.

**What "out of band" can and cannot mean here.** Both are stored strings the engine keeps
exactly as given, with no deploy-time indirection — no `env()` form, no template the tenant
resolves. So the spelling that looks safe is the wrong one:

```ts
// WRONG — `process.env` resolves at EXPORT time. The literal credential is written
// into workspace.json, and into git with it.
microservice({ name: "app", registryAuth: { dockerconfigjson: process.env.REGISTRY_JSON! } });
```

Two honest options, both about where the bytes live rather than about hiding them:

1. **Leave `registryAuth` unset** — a public image, or a pull credential attached to the
   microservice outside this workspace. Nothing then carries a credential.
2. **Accept that the tree is secret-bearing** — keep `workspace.json` and any pulled tree out
   of git, or rotate the credential once it lands there.

Export prints a notice naming every microservice whose bundle bytes carry either field, so
this can't happen quietly; `--strict` does **not** promote it, since shipping a
private-registry workload is a legitimate end state. When what you actually need is a secret
your *stack* reads, the mapped surface is `workspaceConfig({ env })` + `env("NAME")` — see
[Middleware, request history &amp; env vars](#middleware-request-history--env-vars).

</details>

<details>
<summary><b>Middleware, request history &amp; env vars</b></summary>

A `middleware({...})` is reusable logic (`input`/`stack`/`response` + `resultStrategy:
"merge"|"replace"` + `exceptionPolicy`). To run one, *attach* it with a host's
`middleware: { pre, post }` field on `query`/`apiGroup`/`defineFunction`/`task`/`tool`
(not triggers). Prefer a def handle over a bare name, the same rule as `auth`/`apiGroup`
references; `{ middleware: mw, active: false }` keeps an entry but disables it.

```ts
query({
  name: "get_user", verb: "GET", apiGroup: blog,
  middleware: { pre: [rateLimit], post: [audit] },
  stack: [/* ... */], response: ref("user"),
});
```

- **`exceptionPolicy` decides whether a guard is a guard.** `"silent"` **is the default**
  and swallows the throw, so a rate limit or auth check authored without an explicit policy
  is **not enforced**. `"rethrow"` aborts the request and surfaces the authored
  `error`/status (a tripped `s.redis.ratelimit` → 429) while still running `post`;
  `"critical"` is the same but skips the `post` chain. That is the only difference.
- **Inheritance is override, not merge.** Providing a phase overrides it; omitting a phase
  inherits the parent tier's chain, resolved at request time **Query → API Group →
  Workspace**. `pre: middleware.clear()` overrides a phase with nothing.
- **Setting `workspaceConfig.middleware` at all emits the whole map.** Any host/phase you
  don't list is emitted empty, which **clears** that tier on deploy. Omit the field entirely
  to leave the workspace's existing middleware untouched. The same wholesale rule applies to
  `datasources`.
- **`auth()` is `null` on a public host**, and a `pre` middleware runs after auth resolution.
  A rate limit keyed by `auth("id")` on a public endpoint collapses every caller into one
  bucket, silently. `export()` warns on direct attachment of an `auth()`-keyed middleware to
  a host where `auth()` may be null.
- A `resultStrategy: "replace"` middleware attached `post` rewrites the response at runtime,
  which `InferResponse` can't see — declare `responseShape` on the endpoint.
- `workspaceConfig` also carries `realtime`, `documentation`, and `swagger`, which are
  server-shaped and carried verbatim rather than authored. `realtime` there is the **legacy**
  workspace-level block, not the realtime primitives you author.

**The canonical rate-limit middleware.** Build the per-user key with the filter chain
(`"prefix" + auth("id")` doesn't exist):

```ts
const writeRl = middleware({
  name: "write_rl",
  exceptionPolicy: "rethrow", // a tripped limit must abort (silent would let it through)
  stack: [
    s.redis.ratelimit({
      key: withFilters(c.text("rl:write:"), fl.concat(auth("id"))), // "rl:write:<id>"
      max: c.int(10), ttl: c.int(30), error: c.text("Too fast."),
    }),
  ],
});

query({ name: "create_post", verb: "POST", apiGroup: blog, auth: users, // authed ⇒ per-user
  middleware: { pre: [writeRl] }, stack: [/* ... */], response: ref("post") });
```

On a **public** endpoint key off the client IP instead — `sys.remoteIp()` — since
`auth("id")` is null there. And note the **shared-bucket rule**: co-attaching one middleware
object to N hosts means all N share the same key and therefore one counter, so `max: 10` is
a global budget across them. Vary the key (fold the host name into the prefix) for an
independent limit per host.

**Reading the request body in a `pre` middleware.** It does receive the host's inputs, via
`s.util.get_all_input({ as: "payload" })` — but the result is **wrapped as `{ type, vars }`**,
so a body field lives at `ref("payload.vars.<field>")`. The un-nested path is the usual cause
of an `Unable to locate var` 500.

**Request history** — the per-object execution trace behind Xano's debugger, authored as a
single scalar `history` field: `false` off, `true` on at the default depth, a **number** =
capture depth (statement executions recorded per record, *not* records retained), `"all"` =
unlimited. **Omitting it inherits**; any value stops inheriting. Inheritance resolves
**object → container → workspace** (a query from its API group, a tool from its
toolset/agent, everything else straight from the workspace). Per-kind defaults when
inheriting: query / task / tool capture **on**; function / trigger / middleware **off**.
`workspaceConfig.history` is wholesale in the same way the middleware map is.

```ts
query({ name: "get_user", verb: "GET", history: 100 });   // capture, depth cap 100
apiGroup({ name: "blog", history: false });               // default for its queries
workspaceConfig({ history: { query: 100, trigger: "all" } });  // name inherited from workspace("…")
```

**Workspace environment variables** — author them as a name→value map on the workspace
object; read them at request time with `env("NAME")`:

```ts
workspaceConfig({
  name: "my-app",
  env: {
    STRIPE_KEY: process.env.STRIPE_KEY!,          // sourced from the deploy environment
    APP_BASE_URL: "https://my-app.example.com",   // a plain config value
  },
});
```

**Values are secrets.** Prefer sourcing them from the deploy environment over committing
literals, and don't commit a compiled bundle holding real ones. Deploying sets the vars you
declare; omit the field to leave the workspace's existing env untouched.

</details>

<details>
<summary><b>Tables &amp; fields</b></summary>

`f.*` covers the full column catalog — scalars, `f.timestamp`, the four file resources, the
six `f.geo.*` types, `f.enum(values)`, `f.vector(size)`, `f.object(children)`. A `json` column
can declare the shape stored inside it with `f.json({ children: [{ name, type }] })` — an
ordered array, unlike the named map `f.object` takes, because the engine persists json
children in the order given. Omit it for an unstructured json column. Foreign keys
are `f.tableRef(table)`, whose link resolves to the target table's guid at export — a
target that isn't registered on the same workspace fails **there**, naming the table and the
schema field, rather than during the import it would otherwise break. Any
scalar becomes a **list column** with `{ array: true }`, surfacing as `string[]` in
`InferRow`. Tables take a named-map schema, filter methods carry args (`"min:8"`), and
`views[]` encode through the shared comparison encoder.

- **A column `default` must stay within the BMP.** A 4-byte character (codepoint > U+FFFF,
  e.g. an emoji) is mangled into invalid UTF-8 by the engine's default pipeline, so it is
  rejected at export rather than 500ing at deploy with Postgres `22021`. Accents, `€`, and
  most CJK are fine; otherwise put the value on an endpoint input, applied at runtime bind.
- **`id` and `created_at` auto-inject** at the head of the schema unless `system: false` or
  you declare them (`idType: "uuid"` for a uuid key). Both are usable wherever a column name
  is expected and both appear in `InferRow`. The standard indexes — `primary(id)`,
  `btree(created_at desc)`, plus `gin(xdo)` when the table stores fields as JSON —
  auto-prepend, de-duped against your own. Declare yours as
  `{ type, fields: [{ name, op? }] }`; `"unique"` is shorthand for `"btree|unique"`.
- **`use_xdo` picks the storage mode** — every field as JSON under the internal `xdo` column,
  or a real Postgres column per field. It is a workspace setting (default `false`) each table
  mirrors, overridable per table with `table({ useXdo })`, resolved at `export()` so the two
  can register in any order.

</details>

<details>
<summary><b>Statements, values &amp; inputs</b></summary>

The `stack` of a function/query/tool is a list of statements, all reachable through one
discoverable, typed namespace — `s`:

```ts
stack: [
  s.set_var("total", c.int(0)),
  s.math.add({ name: "total", value: c.int(5) }),
  s.array.find({ as: "hit", expr: ref("items"), if: expr(ref("$this"), "=", c.int(1)) }),
  s.conditional({ when: expr(ref("total"), ">", c.int(0)), then: [s.return(ref("total"))] }),
  s.function.run({ fn: getUser, as: "u", input: { id: ref("total") } }),
]
```

Tab-complete `s.` to explore. Each declarative statement takes one typed args object;
control-flow specials (`s.set_var`, `s.conditional`, `s.for`, `s.foreach`, `s.while`,
`s.group`, `s.switch`, `s.try_catch`, `s.return`, …) keep their authored signatures. **Every
statement also carries `description` and `disabled`** — inline on the object-arg factories,
a trailing options object on the positional specials. `disabled: true` is Xano's
commented-out state: the step stays in the stack and the engine skips it.

**Filter a statement's result as it binds.** Any statement with an `as` also takes
`asFilters` — the editor's `return as <var> | upper` — applied in order, from the same
`fl.*` catalog as value filters:

```ts
s.security.create_uuid({ as: "token", asFilters: [fl.upper()] })
s.set_var("email", inp("raw"), { asFilters: [fl.trim(), fl.lower()] })
```

It saves a follow-up `s.set_var` for the common "bind it in a different shape" case. A
statement that binds nothing does not offer the option.

**The chain retypes the value.** `InferResponse` folds each filter's declared result, so a
filtered binding reports what it actually holds rather than `unknown`:

```ts
s.db.query({ table: users, as: "rows", asFilters: [fl.count()] })       // rows: number
s.db.query({ table: users, as: "rows", asFilters: [fl.reverse(), fl.first()] })  // rows: Row
withFilters(ref("rows"), fl.count())                                    // number
```

Filters the engine declares as returning `any` — `get`, `set`, `transform`, `json_decode` —
fold to `unknown`, since no declaration could name their shape. Note this models a filter's
OUTPUT, not its input: a filter applied to a value it cannot accept returns `null` at
runtime rather than erroring, and still types as its declared result.

**Fields with a fixed set of values take a bare literal.** Where the engine accepts only
certain spellings, the field's type is that set, so autocomplete offers them and a typo is a
compile error rather than a runtime failure after deploy:

```ts
s.ai.external.mcp.tool.run({ url, tool, connection_type: "stream" }) // ✅ "sse" | "stream"
s.ai.external.mcp.tool.run({ url, tool, connection_type: "streaming" }) // ❌ compile error, and throws
s.ai.external.mcp.tool.run({ url, tool, connection_type: inp("mode") }) // ✅ resolved at runtime
```

`"stream"` and `c.text("stream")` encode identically — use whichever reads better. A value
the SDK can't evaluate (an `inp`/`ref`, or anything with a filter chain) is never checked,
so a computed field stays authorable.

One field is **resolved at encode time** and so is narrower than the rest: `s.db.query`'s
`returnType` picks which `context.return` block gets written, which has to happen before
there is a runtime. It takes the bare literal only — a tagged value is a compile error, and a
dynamic one (`inp`/`ref`, or any filter chain) throws rather than deferring. A spelling
outside the set throws too, in place of quietly falling back to `list`.

**The db family.** Single-record reads and mutations match one field
(`{ fieldName, fieldValue }`, defaulting to `id`) — there is no composite `(a, b)` form; for
a two-column lookup use `s.db.query` with a `where` array. Writes take a partial `row: {…}`,
and an `s.db.edit` writes **only** the columns you list, leaving every unmentioned column at
its stored value. A cell takes a tagged value or a **bare JS literal typed against its
column** — `row: { is_hidden: true, notes: "…" }` is exactly `{ is_hidden: c.bool(true),
notes: c.text("…") }`. The tag comes from the column, so the literal is checked against it:
a string on an `f.bool()` column is a compile error, and an `f.enum()` column keeps its
member union. Only `s.db.query` takes a `where`, and its `where`/`sort`/`paging`/`output`
are applied **by the engine**, not in your stack.

**Bulk writes: `bulk.update` REPLACES the row, `bulk.patch` doesn't.** `s.db.bulk.update`
writes every column an item omits to its zero value (`""`/`0`/`null`), with an HTTP 200 and
no error — `{ id: 7, status: "done" }` blanks the rest of row 7. `s.db.bulk.patch` writes
only the keys each item carries, which is what "update these rows" almost always means.
`export()` warns when a static `items` array omits columns of the bound table (and
`--strict` fails on it), but an `items` built from a `ref`/`inp` can't be inspected. Related:
`s.db.bulk.delete` with no `where` is a truncate, so it refuses to encode unless you say
`allRows: true`.

What each op binds decides your response type: `s.db.get` binds **`null`** on a miss (it does
not throw — null-check it), `s.db.add`/`edit`/`patch` bind the **full written row** including
auto-assigned `id`/`created_at`, `s.db.del` binds `null`, and `edit`/`del` **throw**
`NotFound` (404) when nothing matches. `InferResponse` derives all of that automatically.

`s.db.query` mirrors the whole Xano query builder — `returnType`, `bind` joins, computed
`eval` columns, `aggregate` groups, `distinct`, and the full operator set via
`cmp(left, op, right)` with `and(...)`/`or(...)` for boolean groups. Signatures are in
`llms/statements-data.md`; four behaviors are worth knowing here:

- **A join condition spells its two sides differently.** The joined table's column takes its
  `as` alias; this query's own columns stay bare:
  `bind: [{ table: users, as: "author", join: "left", where: expr(col("author_id"), "=", col("author.id")) }]`.
  Qualifying your own column by the table's name (`col("posts.author_id")`) resolves only if
  the query also sets `tableAlias` — the alias the qualifier is matched against. Unqualified,
  the engine reads the operand as a text literal and fails at runtime with a parse error
  naming the *other* operand, so `db.query` rejects that spelling at export instead.

- **Paging changes the response shape.** Supplying `paging` with metadata on (the default)
  returns a **paging envelope** — `{ items, curPage, nextPage, prevPage, offset, perPage,
  itemsReceived }`, plus totals when `totals: true` — instead of a bare `Row[]`, and
  `InferResponse` reflects that. Pass `metadata: false` to keep the bare array. Read
  `nextPage` (`number | null`) as the typed has-next signal.
- **Don't author `mixed(...)` conditions.** Xano's editor allows a container whose terms
  don't all join the same way, so pulled workspaces contain it and it round-trips — but the
  stored form doesn't record the intended grouping, and the two places it can appear
  disagree: a branch folds terms strictly left to right (`a OR b AND c` = `(a OR b) AND c`)
  while a `db.query` filter inherits SQL's AND-before-OR precedence (`a OR (b AND c)`). Write
  `and(or(a, b), c)` or `or(a, and(b, c))` — each says exactly one thing in every context.
- **`ignoreEmpty` DROPS the clause — it does not match zero rows.** `cmp(col("owner"), "in",
  ids, { ignoreEmpty: true })` with an empty `ids` returns the **unfiltered** table, where the
  same clause without the flag matches nothing. Never put it on a filter that scopes rows to a
  permitted set: an empty permission list then returns everything. It is for an optional search
  filter, where a blank box really does mean "don't filter". `export()` warns when the operand
  is empty in the bundle; a runtime-empty list is yours to reason about.
- **Compose a rule set as siblings, not a folded chain.** `and(...)`/`or(...)` take any number
  of terms and encode flat, so build the array and spread it — `and(...rules)`. Folding one term
  at a time (`rules.reduce((acc, r) => and(acc, r))`) nests a container per rule, and nesting
  costs quadratic bytes: 512 terms are 394 KiB as siblings and 21 MiB folded. Past 128 levels the
  build fails with a message naming the fix. Mixing joins? Group each run: `and(or(...anyOf), ...allOf)`.
- **An aggregate or `eval` `name` is written bare** (`"status"`) and alias-qualified on emit;
  the engine rejects a bare column in either, and an already-dotted joined column passes
  through. The statement also declares the alias it qualified with, so the qualified name
  resolves — nothing to set by hand.
- **`eval` is where vector search lives.** An `eval` filter pipeline compiles to SQL, so it
  resolves a different filter registry than `fl.*` (which runs in the request) — including
  the distance filters an `f.vector` column needs. Compute the distance, then sort by the
  alias it grafts onto the row; the ranking happens in the database, over the column's index:

  ```ts
  s.db.query({
    table: chunk,                                     // index: [{ type: "vector", fields: [{ name: "embedding", op: "vector_cosine_ops" }] }]
    eval: [{ name: "embedding", as: "distance",
             filters: [{ name: "vector_cos_distance", arg: [inp("q")] }] }],
    sort: [{ sortBy: "distance", dir: "asc" }],       // nearest first
    paging: { per_page: 10, metadata: false },
  })
  ```

  Match the filter to the index `op` (`VECTOR_FILTERS`, on `@xanots/core/internal`, lists the
  family). The same filter
  works on a `where` operand to cut off *by distance* rather than by row count.

**Addons** enrich each returned row with related data, attached to the row-returning ops
(`query`/`get`/`add`/`edit`/`patch`). An addon is a single table-bound db query rather than a
statement stack: `addon({ table, where, output, cardinality })`, where `where` binds it to
the parent row and `cardinality` shapes the graft (`"single"` object, the default `"list"`,
`"count"`, `"exists"`, `"aggregate"`).

```ts
export const authorAddon = addon({
  name: "author",
  table: userTable,
  where: expr(col("id"), "=", inp("user_id")),   // bind to the parent row
  output: ["id", "name"],
  cardinality: "single",
  input: { user_id: input.int({ required: true }) },
});

s.db.query({
  table: post,
  addon: [{ addon: authorAddon, as: "_author", input: { user_id: out("author") } }],
  as: "rows",
});
```

Attaching a typed handle merges the graft onto the row shape in `InferResponse` with no cast;
a **bare-name** reference grafts `unknown`. Author `as` relative to a row (`_author`) — when
the query returns a paging envelope the `items[]` offset is added for you. If an alias
**shadows an existing column** the build throws, because the engine would silently overwrite
that column at runtime (Xano convention: prefix with `_`).

**Values** — `c.int/text/bool/decimal/null/obj/array`, `c.now()`, `ref(var)`, `inp(input)`,
`col(name)`, the context refs `auth(path?)`/`env(name)`/`setting(name)`/`sys.*()`,
`out(name)` for a parent-row column in an addon input, and `toolset(path)` for the token
and URL parameters bound while a tool runs under its toolset. `withFilters(value, fl.a(), fl.b())`
attaches the value pipeline from a typed catalog of filters generated from the engine's own
sources.

- **`c.obj`/`c.array` take plain JSON literals only.** A nested tagged value
  (`inp`/`ref`/`auth`/`c.*`) is a compile error. For a computed object — a response, or an
  `api.request` `params` — use a record of values (`{ count: ref("count") }`). For a dynamic
  object argument use `obj({...})`, which builds a checked expression.
- **An `obj({...})` member may carry a filter chain.** That matters most for the null-safe
  drill: `db.get` binds `null` on a miss, so `obj({ city: ref("row.address.city", { safe: true }) })`
  is the normal shape — no per-member `s.set_var` to hoist it out. `c.now()`, `env()` and
  `sys.*()` are members too. `{ safe: true }` is for a base that exists and may be null: on a
  base nothing binds it would hide a typo behind a `null`, which is why `export()` warns
  (with a did-you-mean) when a `ref()`'s base segment names no `as` in that stack.
- **`c.int` takes a string or bigint past `Number.MAX_SAFE_INTEGER`.** The engine stores
  integers as strings and has no 53-bit limit, so `c.int("18446744073709551615")` is exact
  where the number literal for it is already `…616`. A `number` that is not a safe integer
  throws rather than encoding the rounded value.
- **A bare scalar works in any `fl.*` argument.** `fl.get("a.b", 0)` encodes identically to
  `fl.get(c.text("a.b"), c.int(0))`; strings, numbers and booleans are all wrapped for you.
  Objects and arrays still need `c.obj`/`c.array`.
- **A typed `fl.*` call is capped at its declared argument count.** Passing more throws,
  in the type and at runtime — an extra argument used to ride into the filter's arg list for
  the engine to ignore or fail on. `filter("name", …)` is the untyped escape. Seven filters the
  catalog under-declares stay variadic (`concat`, `index_by`, `get`, `array_merge`,
  `array_merge_recursive`, `jwe_encode`, `jwe_decode`). The other 95 filters take **no**
  arguments at all — they are emitted `()`, so `fl.abs(x)` is a compile error rather than an
  argument silently shipped to an engine that does not accept one.
- **`api.request` headers take a `{ "Name": value }` record**, values may be tagged:
  `headers: { "x-api-key": env("KEY") }`. Prefer a header over a `?key=` query param for a
  credential — a URL travels into access logs, proxies and `Referer`. That is not envelope
  safety, though: the `as` envelope's `request` half mirrors `url`, `params` **and**
  `headers`, so never return it raw from a credentialed request; read `response.result`.
- **Some filters require an argument their own docs call optional.** Filter arguments are
  positional, and a short call is refused by the engine before the filter runs — so
  `fl.csv_encode()` and `fl.number_format()` are compile errors here rather than a failure on
  a deployed endpoint. Which filters those are is probed, not declared: `fl.round()` is also
  documented optional and genuinely works. Pass every argument the signature shows without a
  `?`; the runtime guard names the count if you reach it from JavaScript.

  ```ts
  withFilters(ref("rows"), fl.csv_encode(",", '"', "\\")), // not fl.csv_encode()
  ```

- **`fl.csv_encode` writes no header — `fl.csv_create` is the one that does.** They read as
  interchangeable and are not. `csv_encode` emits each row's values in *that row's* key order
  with no normalization across rows, so rows whose keys differ in order or count misalign
  columns silently; nested cells are JSON-encoded, `false` writes empty, and a piped array of
  scalars collapses to a single line. `csv_create` takes the column names as its **piped**
  value and the data as its `rows` argument.
- **Only `fl.fsort({ type: "number" })` sorts numerically.** Every other comparator —
  including a spelling the engine does not recognize — sorts as case-insensitive text,
  silently and with no error, so `[2, 10, 1]` comes back `[1, 10, 2]`. A lexicographic sort
  agrees with a numeric one whenever the values share a digit count, so this looks correct
  on small data and goes wrong on real data: a "top N by score/distance/recency" endpoint
  returns the right rows in the wrong order. The union rejects the two plausible wrong
  spellings (`"decimal"`, `"int"`) outright.

  ```ts
  withFilters(ref("rows"), fl.fsort({ path: "score", type: "number", asc: true })),
  ```
- **`col()` does not resolve to a stored value inside a `db.edit` `row`.** To
  read-modify-write a column — incrementing a counter — `db.get` the row first and pipe its
  bound value through a filter. `col()` evaluates to `null` there, so `fl.add(1)` computes
  `null + 1` and the engine aborts.

  ```ts
  s.db.get({ table, fieldValue: inp("id"), as: "current" }),
  s.db.edit({ table, fieldValue: inp("id"), row: { clicks: withFilters(ref("current.clicks"), fl.add(c.int(1))) } }),
  ```

  That pair is **not atomic** — concurrent writers can lose an increment, and no atomic
  increment statement exists. A genuinely safe counter needs the arithmetic in the database
  via `s.db.direct_query`, which in turn needs the table's *physical* Postgres name; that
  name is assigned at import and is not knowable from a `table()` def, so it has to be
  hardcoded after inspecting the deployed table. A typed path requires an engine change
  ([issue #35](https://github.com/xanots/core/issues/35)).
- **A JavaScript body is written as a function, not a `c.text` string.** The lambda
  statement (`s.lambda`) and eight filters (`fl.map`/`filter`/`some`/`every`/`find`/
  `findIndex`/`reduce`/`lambda`) run JavaScript against a small, closed set of injected
  identifiers — and which ones are in scope depends on the surface. Write the body inline
  and the **surface is implied by where it sits**: the bindings are the function's
  parameters, typed from the position, so your editor supplies them and a wrong name is a
  compile error rather than a wrong value in production.

  ```ts
  // reduce's accumulator is `$result`. Autocomplete says so; `$acc` does not compile.
  withFilters(ref("prices"), fl.reduce({ initial_value: 0, code: ({ $result, $this }) => $result + $this })),

  // A map body, typed as a map body — nothing names the surface.
  withFilters(ref("prices"), fl.map(({ $this, $index }) => $this * ($index + 1))),

  // The statement surface binds ambient state only: `$this` here is a compile error.
  s.lambda({ as: "total", code: ({ $var }) => $var.subtotal * 1.2 }),
  ```

  The parameters are a fiction — only the **body** is sent, and the engine injects the
  bindings as free identifiers — so destructure them. `(b) => b.$this * 2` would emit
  `return b.$this * 2` with `b` undefined at runtime, and the SDK refuses it.

  **`fl.transform` is not one of these.** It sits next to them and reads like one, but it
  takes a Xano *expression* — no `return`, and the piped value binds as `$0` (or `$$`),
  not `$this`. A `$this` there resolves to null and the call still returns HTTP 200, so
  the SDK refuses both spellings at author time and points at `$0`.

  ```ts
  // An expression over the piped value — not a JS body.
  withFilters(ref("prices"), fl.transform("$0 * 1.2")),

  // Parenthesize a pipe inside an object literal, or the filter argument's comma is read
  // as the key separator and every later key silently vanishes.
  withFilters(ref("items"), fl.transform('{ names: ($0|sort|join:","), n: ($0|count) }')),
  ```

  For a body built away from its call site, `lam.*` names the surface explicitly:

  ```ts
  const rate = 0.2;
  // Nothing from the enclosing scope crosses implicitly — declare what the body needs.
  // The capture key must differ from the module binding: the loader renames one of two
  // same-named bindings and the prelude is written under the original name.
  s.lambda({ as: "vat", code: lam.fn(({ $var }, { capturedRate }) => $var.total * capturedRate, { surface: "s.lambda", capture: { capturedRate: rate } }) }),
  ```

  Omit `surface` and the check is deferred to wherever the body lands, which is the thing
  that knows. `lam.file("./lambdas/total.ts")` reads a default-exported function of the
  same shape from its own type-checked module — the deterministic option under a bundler,
  where a function's own source is whatever the bundler emitted. It needs a filesystem, so
  it ships on the Node entry only: `import { lam } from "@xanots/core/node"`, whose `lam`
  carries `fn` and `raw` unchanged. `lam.raw(code)` is the text escape hatch, guarded
  identically, and works on either entry. The full
  binding table per surface is in `llms/lambda.md`.

  Three things to know, all live-verified against a real engine:

  - A body that **throws does not fail the request** — the engine returns its diagnostic
    text as the value with HTTP 200, so the failure arrives as bad data rather than an
    error. That is engine behavior and not interceptable from an SDK; validate before
    consuming a lambda result numerically, and prefer an authored body, which cannot fail
    that way for a binding reason.
  - The body is a **function body, not a module**: it must `return`, and a top-level
    `import` is a syntax error. Reach a dependency through the **preloaded globals** —
    `crypto`, `fetch`, `Buffer`, `axios`, `jose`, `_`, `math`, `moment`, `DateTime`,
    `uuid` and friends, which need no specifier. A dynamic `import("…")` or `require("…")`
    with a **literal specifier is not portable**: some instances bundle the body before
    running it and resolve every literal specifier ahead of time, so `await
    import("node:crypto")` comes back as the text `Could not resolve "node:crypto"` with
    HTTP 200; others resolve it at run time and it works
    ([issue #265](https://github.com/xanots/core/issues/265)).
  - `console` output goes to the **request log**, not stdout.

  A plain `c.text(...)` body is still accepted and gets the same build-time check — the
  guard sits at the call site, not inside `lam.*` — so an unknown `$identifier` fails
  whichever way you write it ([issue #221](https://github.com/xanots/core/issues/221)).
- **`c.expression("…")` is carried through verbatim and NOT validated.** XanoTS does not
  parse it or type-check it; nothing inside participates in `InferResponse`, so a var named
  there is invisible to a rename that updates every typed `ref()`. A malformed expression
  fails at runtime; one that is merely wrong (`$var.tota1`) returns a wrong answer. Reach for
  it only for syntax the typed surfaces can't express — `~` concatenation, inline arithmetic,
  conditionals — and note it is **not** the `expr()` condition builder.
  (`c.expressionLegacy` exists only so `codegen` can return an older stored form.)

**System / request variables (`sys.*`).** Xano's built-in request context reads as
`$env.$remote_ip` in XanoScript — note the **second `$`**: these are settings with a
`$`-prefixed name, the same tag `env()` emits. That prefix is the footgun, because
`env("remote_ip")` reads a workspace env var literally named `remote_ip` (almost always
unset → null) rather than the caller's IP. `sys.*` spells the prefixed names for you:

| accessor | var | | accessor | var |
|---|---|---|---|---|
| `sys.remoteIp()` | `$remote_ip` | | `sys.datasource()` | `$datasource` |
| `sys.requestMethod()` | `$request_method` | | `sys.branch()` | `$branch` |
| `sys.requestUri()` | `$request_uri` | | `sys.tenant()` | `$tenant` |
| `sys.requestQueryString()` | `$request_querystring` | | `sys.release()` | `$release` |
| `sys.httpHeaders()` | `$http_headers` | | `sys.platform()` | `$platform` |
| `sys.requestAuthToken()` | `$request_auth_token` | | `sys.isDebugger()` | `$debugger` |
| `sys.apiBaseUrl()` | `$api_baseurl` | | | |

`setting("$<name>")` covers anything `sys` doesn't. The one that matters most in practice is
`sys.remoteIp()`, the rate-limit key for public endpoints.

**Inputs** — `input.*` mirrors `f.*` exactly: every engine-legal field type is a valid
function/query input, with `input.object(children)` and `input.list(element)` for structured
shapes. Comparisons use `= != > < >= <=`.

**Validate input at the boundary.** Field types don't enforce arbitrary
rules, and `s.precondition` raises a **status-bearing** error a client can detect via
`res.ok` — unlike `s.throw`, which returns 200 with an error body. `error_type` picks the
status: `badrequest`/`inputerror` → 400, `unauthorized` → 401, `accessdenied` → 403,
`notfound` → 404, `toomanyrequests` → 429, `standard` (the default) → 500.

```ts
s.precondition({
  // `fl.regex_test` is PATTERN-piped: the piped value is the regex and the arg is the
  // text tested — the reverse of `istarts_with`. Build the pattern with `c.regex(...)`,
  // which delimiter-wraps it (a bare `c.text("^…")` is an invalid PCRE matching nothing).
  expr: expr(withFilters(c.regex("^https?://", "i"), fl.regex_test(inp("url"))), "=", c.bool(true)),
  error_type: "badrequest",
  error: c.text("url must be an http(s) URL"),
})
```

**Normalize on the input, not in the stack.** `methods` run at bind, before your stack, so
`input.email({ methods: ["lower"] })` makes `inp("email")` read already-normalized. Don't
reroll `trim`/`lower`/`upper` into a var.

**Email/password auth.** The trap: `input.password()` **hashes on bind**, so a password
typed that way is already a hash before your stack runs, and `check_password` then compares
hash against hash — login always fails. Take the password as **plain text** and let the
`f.password` *column* hash it on write; `check_password` compares the plaintext submission
against the stored hash.

```ts
// Signup — plaintext in; the f.password COLUMN hashes on write.
query({ name: "signup", verb: "POST", apiGroup: authApi,
  input: { email: input.email({ required: true }), name: input.text(),
           password: input.text({ required: true, methods: ["min:6"] }) }, // NOT input.password()
  stack: [
    s.db.add({ table: usersTbl,
      row: { email: inp("email"), name: inp("name"), password: inp("password") }, as: "user" }),
    s.security.create_auth_token({ table: usersTbl, id: ref("user.id"), as: "token" }),
  ],
  response: ref("token") });

// Login — plaintext compared against the stored hash.
query({ name: "login", verb: "POST", apiGroup: authApi,
  input: { email: input.email({ required: true }),
           password: input.text({ required: true }) },                     // NOT input.password()
  stack: [
    s.db.get({ table: usersTbl, fieldName: "email", fieldValue: inp("email"),
               output: ["id", "email", "password"], as: "user" }),
    s.precondition({ expr: expr(ref("user"), "!=", c.null()),
      error_type: "accessdenied", error: c.text("Invalid email or password.") }),
    s.security.check_password({ text_password: inp("password"),            // plaintext
      hash_password: ref("user.password"), as: "ok" }),
    s.precondition({ expr: expr(ref("ok"), "=", c.bool(true)),
      error_type: "accessdenied", error: c.text("Invalid email or password.") }),
    s.security.create_auth_token({ table: usersTbl, id: ref("user.id"), as: "token" }),
  ],
  response: ref("token") });
```

Reach for `input.password()` only when you specifically want its bind-time hash **and** are
not also feeding it to `check_password`.

</details>

<details>
<summary><b>CLI</b></summary>

```bash
xanots init my-app                         # scaffold a full project (frontend/ + xano/)
xanots init my-app --framework svelte      # SvelteKit instead of the default React
xanots init my-app --ai claude --no-install  # add CLAUDE.md; skip npm install

xanots marketplace list                    # every published add-on (no login needed)
xanots marketplace search auth             # narrow by keyword
xanots marketplace details @xanots/auth    # what it installs + the registration to copy
xanots marketplace details @xanots/auth --prompt  # …as a prompt for a coding agent
xanots marketplace install @xanots/auth    # add an add-on to the project you're in

xanots export ./xano/index.ts              # bundle to stdout
xanots export ./xano/index.ts --out ws.json
xanots compile ./xano/functions/get-user.ts  # a single function's JSON

xanots export ./xano/index.ts --strict     # CI: fail the build on any warning, don't just print it
xanots export ./xano/index.ts --lock       # opt into xano.lock (created beside the entry)
xanots export ./xano/index.ts --frozen-lock  # CI guard: fail if the export would change the lock
xanots lock rename --entry=xano/index.ts table users members  # move a lock entry after renaming in code
xanots lock prune ./xano/index.ts --yes    # drop lock entries nothing exports anymore
xanots lock prune --no-verify --entry=xano/index.ts --yes dbo:notes  # …or drop named keys without running the workspace
xanots lock adopt live-export.json --entry=xano/index.ts --yes  # seed the lock from a live engine export

xanots login                               # OAuth sign-in (once) — pick the instance + workspace at consent
xanots workspace details                   # which instance/workspace am I bound to, and via which credential?
xanots deploy ./xano/index.ts              # compile + import into a live ephemeral (the dev loop) → URL
xanots deploy ./xano/index.ts --dest sandbox             # …or your throwaway singleton sandbox
xanots deploy ./xano/index.ts --static ./frontend/dist   # also deploy a static frontend (onto the ephemeral)
xanots deploy ./xano/index.ts --static ./frontend/dist --static-env PK=pk_live_1   # + extra public config
xanots deploy --bundle ws.json             # deploy an already-exported bundle
xanots deploy ./xano/index.ts --open       # …and open the deployed URL in your browser
xanots ephemeral list                      # list your ephemeral environments (--all-workspaces spans every workspace)
xanots ephemeral get <tenant>              # base URL, state, and expiry for one (<tenant> = the tenant name, e.g. ewap-8wz9-9e13, NOT the display name — `ephemeral list` shows it in bold)
xanots ephemeral delete <tenant> --yes     # destroy one
xanots ephemeral impersonate <tenant>      # open it in the builder (--guest = read-only; --url-only prints the URL instead)
xanots release ./xano/index.ts --dry-run   # preview what promoting to your main workspace would change
xanots release ./xano/index.ts             # promote: add + update, never delete, never touch table data
xanots release ./xano/index.ts --prune     # …also delete objects this project released and no longer defines (needs xano.lock)
xanots sandbox export                      # export the DEPLOYED sandbox workspace as a JSON bundle → ./sandbox.json
xanots sandbox export --format multidoc --name backend               # …or the deployed sandbox as XanoScript → backend.xs
xanots sandbox export --format multidoc --path -                     # …stream the multidoc to stdout (deploy first)
xanots sandbox details                     # print the sandbox base URL + tenant details (pretty on a TTY, JSON when piped)

xanots workspace details                   # which workspace your token is scoped to (instance, id, name, guid)
xanots workspace export --path ws.json     # your REAL workspace as a JSON bundle (`--path -` streams to stdout)
xanots workspace codegen my-app            # …or as a runnable project (the pull direction)
xanots sandbox codegen my-app              # same, from your sandbox
xanots ephemeral codegen <tenant> my-app   # same, from an ephemeral (tenant first, path second)
xanots codegen ws.json my-app              # …or from a bundle already on disk (offline, no auth)

xanots profile me                          # print the scoped user + instance base URL (pretty on a TTY, JSON when piped)
xanots whoami                              # alias for `profile me`
xanots logout                              # revoke the refresh token + clear the shared cache (--local for the project one)
xanots version                             # print the installed @xanots/core version
xanots help                                # grouped command reference (also the no-arg default)
xanots <command> --help                    # that command's usage, subcommands, and flags (`xanots deploy --help`)
xanots <noun> <verb> --help                # scoped to one verb (`xanots workspace codegen --help`)
xanots <command> --json                    # force JSON on stdout (otherwise: whenever stdout isn't a terminal)
xanots completion zsh                      # shell completion script (also bash, fish) — see below

xanots validate ./xano/index.ts            # import into a live instance, diff each object back
xanots validate ./xano/index.ts --runtime  # also run each deployed function on the engine
xanots validate ./xano/index.ts --capture  # write the fetched JSON as fixture candidates
```

<details>
<summary><b>Bundle key names vs. the names everything else uses</b></summary>

A bundle's `payload` arrays are keyed by the engine's **storage** name, which for five kinds
is not the name the SDK, the CLI, or a release plan uses for the same thing. Scripting against
a bundle means translating:

| You author / the plan reports | `payload` array |
|---|---|
| `table` | `payload.dbo` |
| `api_group` | `payload.app` |
| `agent` | `payload.toolset` |
| `mcp_server` | `payload.toolset` |
| `toolset` | `payload.tool` |
| `realtime_channel` | `payload.channel` |
| `realtime_message` | `payload.message` |

Everything else (`query`, `function`, `task`, `trigger`, `middleware`, `microservice`,
`addon`, `workflow_test`, `workspace`) is keyed the same on both sides.

Two traps worth naming. A release plan reports operations as `{"type": "table", …}` while the
bundle it came from stores that object under `payload.dbo` — so a table that released
correctly looks *missing* if you go looking for `payload.table`. And `agent` and `mcp_server`
both land in `payload.toolset`, while the kind actually named `toolset` lands in
`payload.tool`; matching on the word alone will pick the wrong array.

This is also the format `xanots lock adopt` reads, so both directions are user-facing.

</details>

**Build warnings, and `--strict`.** `export`/`deploy` print a `xanots:` warning for the
shapes that ship clean and then do the wrong thing — a `bulk.update` zero-filling the columns
an item omits, an `ignoreEmpty` on an operand that's already empty, a `ref()` no `as` binds,
a filter name the engine can't resolve. Each stays a warning because each has a legitimate
use. Nothing fails on a message nobody reads, though, so pass `--strict` in CI and in
unattended agent builds: every warning becomes a hard failure and the exit code carries it.
The programmatic equivalents are `emitBundle(app, { strict: true })` and
`app.export({ strict: true })`. The bundle bytes are identical either way.

### Shell completion

`xanots completion <bash|zsh|fish>` prints a completion script covering every command, verb, flag,
and closed value set (`--dest ephemeral|sandbox`, `--format json|multidoc`, `--ai claude|codex|cursor|none`).
It is generated from the CLI's own command table, so it never drifts from what the CLI accepts — but it
is baked at generation time, so re-run it after upgrading.

```bash
# zsh
xanots completion zsh > "${fpath[1]}/_xanots"   # then restart your shell

# bash
xanots completion bash > ~/.xanots-completion.bash
echo 'source ~/.xanots-completion.bash' >> ~/.bashrc

# fish
xanots completion fish > ~/.config/fish/completions/xanots.fish
```

After a command succeeds the CLI checks npm (at most once an hour, cached in
`~/.xanots/update-check.json`) and prints a one-line nudge to **stderr** when a newer
`@xanots/core` is published — never to stdout, so piped bundles stay clean. The suggested
command adapts to how you installed it (`npm i -g …` for a global install, `npm i -D …`
when it's a project dependency). The check is best-effort and bounded (a slow or offline
registry never delays a command), and stays silent under CI or when stderr isn't a terminal.
Opt out with `XANOTS_NO_UPDATE_CHECK=1` (or the conventional `NO_UPDATE_NOTIFIER=1`).

### When something fails

Failures are written for the person reading the terminal. A request the instance refused
prints the server's own sentence on one line (`deploy failed (403 Forbidden): Access denied
for this workspace.`) rather than the whole JSON envelope; a request that never arrived names
what it could not reach and why (`workspace list could not reach https://…: fetch failed
(ECONNRESET)`), and a request that timed out says so separately, because "slow" and
"unreachable" call for different next steps. Set `XANOTS_DEBUG=1` to append the untouched
response body underneath — nothing is discarded, only folded away.

A malformed invocation fails before any work happens: a missing or misspelled entry file, an
argument the command has nowhere to put (`xanots export --lock xano.lock` — that flag takes
its value attached, as `--lock=xano.lock`), or a missing credential, each answered with the
command's usage block instead of a stack trace. `deploy` and `release` check that you are
signed in *before* compiling, so a lapsed session costs you a message, not a build.

Emitters that write to disk (and the programmatic CLI) are Node-only — import them from
`@xanots/core/node`. The string emitters (`emit`, `emitBundle`) stay on the browser-safe
`@xanots/core` entry.

```ts
import { emitBundle } from "@xanots/core";        // pure string — browser-safe
import { writeBundle } from "@xanots/core/node";  // writes a file — Node only
```

**Four entry points, and only the first is the authoring API.** `@xanots/core` is what you
define a workspace with; `@xanots/core/node` adds the filesystem half; `@xanots/core/codegen`
is what a generated tree imports; and `@xanots/core/internal` holds the compiler machinery —
the per-kind encoders, the kind and statement registries, the bundle serializer, the
`xano.lock` model. Nothing on `/internal` is needed to author anything, and it is kept off
the root so an agent scanning the package's exports sees the surface rather than the guts.

All three run the same build-time checks, including seed validation of a literal
`seed: [...]` array. A **deferred** seed (a thunk, or `seedFile()`) needs an await or the
filesystem, so it is materialised and checked only by `xanots export` / `xanots deploy`.

</details>

<details>
<summary><b>Validating against a live instance (<code>xanots validate</code>)</b></summary>

`xanots validate` proves your compiled output against a **real, running Xano
instance** — not a static snapshot. It compiles your workspace, imports it into a
**fresh ephemeral environment created for that run**, exports it back, and diffs it
against what you compiled, so you catch three classes of problem a local build can't:

1. **Import accepts** — the engine actually accepts the bundle (malformed-but-shaped output is rejected here).
2. **Round-trip parity** — the workspace the engine stores, re-exported in the same bundle format, matches your compiled JSON after normalization (full object logic included). Every authored kind is diffed — tables, functions, queries, triggers, tasks, and more — each object matched by identity and reported per kind.
3. **Runtime** (`--runtime`) — each deployed function actually runs on the engine, with logs surfaced on failure.

It talks only to public meta API routes — the **same** archive import `xanots
deploy` uses, plus the workspace export — and never touches XanoScript. There is one
way into an instance, so a transport bug is one `validate` reproduces rather than
routes around.

It is non-destructive: nothing you own is written to. Each run creates its own
ephemeral environment, imports into that, and deletes it afterwards — including when
the import is rejected or a transport error is thrown. The environment carries a
short expiry, so even a killed process leaves nothing permanent behind. A fresh
environment per run is also what makes the diff trustworthy: the objects read back
can only have come from this bundle, never from what a previous run left.

**Setup** — copy `.env.example` to `.env` (gitignored) and fill in a base URL +
token. Switching between a cloud dev instance and a local Docker one is just a
different `XANO_VALIDATE_INSTANCE`:

```bash
# .env
XANO_VALIDATE_INSTANCE=https://your-instance.xano.io   # or http://localhost:8080 for local Docker
XANO_VALIDATE_TOKEN=your-meta-bearer-token
# XANO_VALIDATE_WORKSPACE_ID=…                          # optional; PARENT workspace the run's env is created under (default 1)
```

```bash
xanots validate ./xano/index.ts                      # import + round-trip diff, reports per object (every authored kind)
xanots validate ./xano/index.ts --runtime            # + run each deployed function
xanots validate ./xano/index.ts --capture            # + write fetched JSON to ./validate-out (fixture candidates)
xanots validate ./xano/index.ts --instance http://localhost:8080   # override the target for one run
xanots validate --bundle ws.json                     # validate an already-exported bundle
```

Config comes from the environment (a `.env` is autoloaded; a real env var wins),
`--instance` overrides per run, and the token is env-only — never a flag. This
harness is deliberately separate from the `auth.json` credential the rest of the
CLI uses. A non-zero exit means a check failed; `--verbose` prints full diffs and raw
engine detail instead of a projected summary.

</details>

<details>
<summary><b>Signing in &amp; deploying (in depth)</b></summary>

**Sign in once** with `xanots login`. It runs the standard authorization-code + PKCE
browser flow (powered by the OpenID-certified [`openid-client`](https://github.com/panva/openid-client)):
opens your browser, you approve, and the CLI captures the redirect on a `127.0.0.1`
callback. On first use it dynamically registers its own OAuth client (RFC 7591) so the
authorize step never depends on the server tolerating an arbitrary loopback port; the
registration is cached in `~/.xano/xanots-clients.json`. The instance you're bound to is
read from the token's own `aud` claim.

`login` also **pins the numeric workspace** you consented to into the credential, so every
later command acts on exactly that workspace without looking it up again. There is **no
`--workspace` flag** — a credential addresses exactly one instance and one workspace. Run
`xanots workspace details` to see which.

The credential caches by default in a **shared** `~/.xanots/auth.json`, reusable
from **any** project directory — so a single `xanots login` covers all your projects.
Override the OAuth host with `--origin`/`$XANO_ORIGIN` and the loopback port with `--port`.

Running `login` again when a usable credential is already cached prints what you're
signed in to and stops, rather than spending another browser round trip — pass `--force`
to sign in anyway. That guard is also what keeps `login` from silently replacing a
hand-authored `type: "token"` credential (below).

**Credential formats.** `auth.json` holds one credential, discriminated by `type`:

```jsonc
// type: "oauth" — written by `xanots login`. Do not hand-edit.
{ "type": "oauth", "instance": "https://your-instance.xano.io", "workspace_id": 3, /* …tokens… */ }
```

```jsonc
// type: "token" — WRITE THIS YOURSELF. A meta API bearer token for the same
// meta APIs, for automation. No login flow, no refresh, no rotation.
{
  "type": "token",
  "instance_base_url": "https://your-instance.xano.io",
  "workspace_id": 3,
  "meta_api_token": "your-meta-api-token"
}
```

Both formats work at **either** location (project-local `./.xano/auth.json` or global
`~/.xanots/auth.json`) on the same precedence ladder below, and both determine the same
thing: one instance, one workspace. A `token` credential is never created, refreshed, or
revoked by the CLI — `xanots logout` just deletes the file, and the token itself stays
valid until you revoke it wherever you minted it. Save it with owner-only permissions
(`chmod 600`) and keep it out of git.

> **Upgrading:** this format is a break. An `auth.json` written before it is rejected with a
> message naming the fix — run `xanots login` again.

**Project-local credentials** — pass `--local` to `login` to cache tokens in a
**project-local** `./.xano/auth.json` instead (which `login` **auto-adds to `.gitignore`**),
scoping the sign-in to that directory. Every command that **reads** credentials
(`deploy`/`details`, `profile me`, token refresh) resolves them **project-local first, global
as a fallback**: it uses `./.xano/auth.json` when present, otherwise `~/.xanots/auth.json` —
so a `--local` project keeps working without repeating the flag. `login` and `logout` do
**not** fall back: they target the shared global cache unless you pass `--local`. An explicit
`--config`/`$XANO_CONFIG` always wins over everything.

`deploy <file>` runs the exact same pipeline as `export` (including `xano.lock`
seeding), then create-or-refreshes the target environment and imports the compiled
workspace into it as a full replace. `deploy --bundle <path>` skips the compile and uploads a
bundle a previous `export` wrote (handy in CI). A **projected, secret-free summary** prints to stdout
as JSON — `baseUrl` plus the workspace `id`/`name`, and the static URL (plus a `verified`
boolean reporting whether the frontend was confirmed live) when `--static` is used — while the
human-readable progress (and the live URLs) echoes to stderr. The raw
workspace blob is deliberately never dumped: it carries per-tenant secrets that must not land
in shell history or CI logs.

**Where it goes** — the instance your **token is bound to** (the token's `aud`), never a
flag. `xanots deploy` create-or-refreshes an **ephemeral** (default) or resolves your
throwaway **sandbox** (`--dest sandbox`); production promotion is the separate
`xanots release` path to your main instance workspace, which merges rather than replacing.

**Static host** — `deploy --static <dir>` archives a directory and deploys it to a
static host after the backend import. Its target follows the destination: with `--dest
ephemeral` the frontend lands **on the ephemeral itself** (backend + frontend in one
environment); with `--dest sandbox` it lands on your **own (parent) workspace**, since the
sandbox tenant does not serve static hosting. For the parent-workspace case the target is the
workspace your credential is already pinned to, and the CLI uploads the archive to
`/api:meta/workspace/{id}/static_host/default/build` with your ordinary bearer. That route
auto-creates the `default` host and **auto-deploys to `dev`**, returning the live URL — so
the static step is independent of the backend deploy (the backend still runs first because
it's the primary action).

**Liveness verification** — the build endpoint returning `200` only means the archive was
*accepted*; the edge may still be starting a cold host (which `503`s for tens of seconds) or
briefly routing the previous build. So after the upload the CLI polls the deployed URL until
the static server reports it is serving **this** build — its `X-Xano-Canonical` response header
matches the canonical returned for the build just pushed — then prints `Frontend is live`. It
polls every second for the first 30s, then every two seconds out to 120s. An unconfirmed poll
is a **warning, not a failure** (the build uploaded fine and usually comes online moments
later; the exit code stays `0` and the summary records `"verified": false`). Verification is
skipped when the response carries no canonical to compare against. Pass **`--no-verify`** to
skip the wait entirely — useful for fast iterative deploys or when the deployed URL isn't
reachable from the machine running the CLI.

**Config injection** — before archiving, the deploy rewrites EVERY `.html` document in the
build, inserting an inline `<script>` at the top of `<head>` that assigns each config value to a
`window.<KEY>` global (so it runs before the app bundle). The backend URL is seeded
automatically as `window.XANO_HOST` (from the backend deploy's own response), and
`--static-env KEY=VALUE` (repeatable) merges in extra keys, overriding the seed on a name
clash. A static host has no server runtime — it serves these files verbatim — so injected
values are **public**: base URLs and *publishable* keys only, never secrets (those go in
backend env, read via `env(name)`). Rewriting every document, not just the root, is what a
prerendered build needs: it serves a different document per route, so a root-only injection
leaves every deep link and refresh running with the global unset — and the page still renders.
Injection is skipped (reported as a warning, not a failure) when the archive has no document
with a `<head>` to anchor to, and any individual document that lacks one is named; values are
`<`-escaped so one containing `</script>` can't break out of the element. This is why a
prebuilt `frontend/dist` can retarget any sandbox with no rebuild.

> **Caching — verify with a cache buster.** The static host serves `index.html` with
> `Cache-Control: public, max-age=3600`, so a browser (or CDN) that loaded the page before
> your latest deploy can hold the old HTML — including a *pre-injection* `<script>`-less
> version — for up to an hour. If `window.XANO_HOST` looks missing, it's almost always this:
> hard-reload (Cmd/Ctrl+Shift+R) or open DevTools with "Disable cache" checked. When
> verifying from a script or agent, append a throwaway query param so you never read a cached
> copy — `curl -s "$URL/?nocache=$(date +%s)"` — and check the fetched HTML for the injected
> `window.XANO_HOST` line rather than retrying the same cached URL.

`xanots sandbox details` prints the same **sandbox base URL** (`GET /api:meta/sandbox/me`,
projected to JSON) out of band, for cases where you'd rather bake it in at build time.
(`xanots profile me` prints the *instance* base URL, i.e. the account's origin rather than
the sandbox tenant.) A static failure after a committed backend deploy **does not roll
back**: it exits with code `3` and a resumable message telling you to re-run with `--static`
to retry just that step.

`deploy` reuses cached tokens and **refreshes them automatically** when the access token
expires (Xano rotates the refresh token on every use; the new one is persisted). A rejected
refresh (`invalid_grant`) clears the stale cache and tells you to `xanots login` again.

**CI & agents** run non-interactively. The credential to reach for is the **meta credential
as three environment variables** — the `type: "token"` record above, with no file:

```bash
XANO_INSTANCE_URL=https://your-instance.xano.io \
XANO_WORKSPACE_ID=3 \
XANO_META_TOKEN=your-meta-api-token \
  npx xanots deploy ./xano/index.ts
```

Nothing is read from or written to disk, and nothing rotates, so the same three secrets keep
working run after run — which is what makes this the right shape for a CI job. It **outranks
every other credential**, including an explicit `--config` path and `$XANO_REFRESH_TOKEN`;
whichever it displaces is named on stderr, so it never wins silently.

All three are required **together**. Setting some but not others is a hard error naming the
rest, rather than a quiet fallback to another credential — a workflow with one misspelled
secret must not deploy against whatever happens to be on the runner.

> **Automated agents:** do **not** invoke `xanots login` — it blocks on interactive browser
> consent. Use the three variables above.
>
> The older `$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still works (both copied once
> from `auth.json` after a local `xanots login`; the target instance is read from the refresh
> token's `aud` and the workspace resolved per run), but Xano **rotates refresh tokens on
> use** — a stored one is spent by its first exchange, so a job that runs twice fails the
> second time. Prefer the meta credential.

</details>

<details>
<summary><b>Environment variables</b></summary>

Every variable the CLI and SDK read. All are optional — the defaults in the right column
are what you get when the variable is unset.

**Authentication** (see *Auth* above for the full precedence ladder)

| Variable | What it does |
|---|---|
| `XANO_INSTANCE_URL` | Instance origin for the meta credential (CI, agents), e.g. `https://your-instance.xano.io`. Set with `XANO_WORKSPACE_ID` + `XANO_META_TOKEN` — all three together, or none. |
| `XANO_WORKSPACE_ID` | Numeric workspace the meta credential acts on. |
| `XANO_META_TOKEN` | Meta API bearer token. With the two above it forms a complete credential that outranks every other source, reads no file, and never rotates. |
| `XANO_REFRESH_TOKEN` | OAuth refresh token for non-interactive runs (CI, agents). Paired with `XANO_CLIENT_ID`; the target instance comes from the token's own `aud` claim. Rotates on use — prefer the meta credential above. |
| `XANO_CLIENT_ID` | OAuth client id that goes with `XANO_REFRESH_TOKEN`. Both are copied once out of `auth.json` after a local `xanots login`. |
| `XANO_CONFIG` | Explicit path to the credential file. Wins over `--local` and over both default locations — the same thing `--config <path>` does. |
| `XANO_GLOBAL_CONFIG` | Moves the **shared** credential cache off `~/.xanots/auth.json`. Only changes where the global cache lives; the project-local `./.xano/auth.json` and the `XANO_CONFIG`/`--config` override are unaffected. |
| `XANO_CLIENT_FILE` | Moves the OAuth **client-registration** cache off `~/.xano/xanots-clients.json`. That file holds the `client_id` minted per auth host + redirect URI, not a credential. |
| `XANO_ORIGIN` | OAuth host to sign in against, instead of the default — the same thing `--origin` does. |
| `XANO_NO_BROWSER` | Set to anything non-empty and `xanots login` will **not** launch a browser; it prints the authorize URL to stderr for you to open yourself. The loopback server still runs and still waits for the redirect, so the flow completes once you visit the URL. For a machine that has no browser to launch, or a remote shell. |

**`xanots validate`** — its own target, deliberately separate from the deploy login

| Variable | What it does |
|---|---|
| `XANO_VALIDATE_INSTANCE` | Base URL of the instance to validate against (`https://your-instance.xano.io`, or `http://localhost:8080` for local Docker). Required; `--instance <url>` overrides it. |
| `XANO_VALIDATE_TOKEN` | Meta API bearer token for that instance. Required. |
| `XANO_VALIDATE_WORKSPACE_ID` | Parent workspace the run's throwaway environment is created under. Defaults to `1`. |

**Output & diagnostics**

| Variable | What it does |
|---|---|
| `XANOTS_DEBUG` | Appends the untouched underlying error to a failure message, instead of only the mapped explanation. |
| `NO_COLOR` | Suppresses ANSI color on the stderr progress output ([no-color.org](https://no-color.org)). Color is off by default whenever stderr is not a TTY. |
| `FORCE_COLOR` | Forces color on even when stderr is not a TTY; `FORCE_COLOR=0` forces it off and beats `NO_COLOR`'s absence either way. |

**Update notifier**

| Variable | What it does |
|---|---|
| `XANOTS_NO_UPDATE_CHECK` | Turns the once-a-day "a newer version is out" notice off. |
| `NO_UPDATE_NOTIFIER` | The de-facto convention, honored identically. `CI` being set also silences the notice. |
| `XANOTS_INSTALL_MODE` | `global` or `local` — pins whether the notice suggests `npm i -g` or a project-local upgrade, instead of detecting it. |
| `XANOTS_UPDATE_REGISTRY` | Registry URL the check reads, instead of the npm endpoint for `@xanots/core`. |
| `XANOTS_UPDATE_CACHE` | Moves the check's cache file off `~/.xanots/update-check.json`. |

**Escape hatches**

| Variable | What it does |
|---|---|
| `XANOTS_MARKETPLACE_URL` | Base URL the `xanots marketplace` reads hit, instead of the published catalogue. Repoints the three read verbs without waiting for a release. |
| `XANOTS_PROVE_DIFF` | A file path. Codegen appends one JSON line per statement that fell back to `raw()` — the arm that declined and the key paths where the re-encode disagreed. The decline *reason* is on the report either way; this adds the machine-readable detail. |

> `process.env` read inside a **workspace definition** is a different thing entirely: it
> resolves at export time and bakes the literal into the bundle. For a value the deployed
> stack reads at runtime, use `workspaceConfig({ env })` + `env("NAME")`.

</details>

<details>
<summary><b>Identity &amp; the <code>xano.lock</code> file</b></summary>

Every top-level object carries a stable `guid` — Xano's identity anchor. On a sync import
the engine matches an incoming object to an existing one **by guid** and updates it in
place; no match means a new object. So re-running `export`/`deploy` on the same code maps
cleanly onto the same workspace — **no duplicates**. By default the guid derives from the
object's `name`; set an explicit `guid` to pin identity across a rename, or to adopt an
existing workspace object into code.

The opt-in **`xano.lock`** freezes the whole workspace's identities at once — every
auto-derived guid, plus the `canonical` URL tokens of API groups and toolsets (which the
engine otherwise randomizes, giving the same code different public URLs per environment).
Create it once with `xanots export ./xano/index.ts --lock`; from then on it's read automatically and
updated on every export (written atomically before the bundle). **Commit it next to your
code.** A project from `xanots init` is locked from its first export — both its `xano:export`
and `xano:deploy` scripts pass `--lock`, and `npm run xano:check` is the `--frozen-lock` CI
guard. Adopt it early either way: once identities have drifted, the only way back is
`lock adopt` against the deployed workspace.

Precedence at emit is always **explicit in-code value → lock entry → name derivation**.

**When the lock is actually load-bearing.** Because the default derivation is deterministic
— `md5("<type>:<name>")` — a project that created all of its own objects can regenerate a
byte-identical lock from its own source. Delete that lock, release again, and the same guids
come back: the objects match and update in place. For that project the lock is a *cache*, and
losing it costs nothing.

The lock is load-bearing exactly where a live guid **diverges** from that derivation, which
happens two ways:

- **Adopted** objects — anything built in the Xano UI first and taken over with `lock adopt`.
  The engine assigned those guids randomly; nothing in your code can re-derive them.
- **Renamed** objects — `lock rename` pins the original guid under the new name, so the
  derivation no longer reproduces it.

For those entries the lock is irreplaceable, and losing it means the next release matches
nothing and creates a duplicate of every diverged object. A workspace adopted wholesale from
the UI can be almost entirely divergent, so treat *that* lock as the critical artifact.
Either way, commit it — the cache is worth having, and you generally will not know which
entries have diverged without looking.

**Renames** — with a lock, a rename in code no longer means delete+create on sync. The
export warns about the orphaned entry and names the fix-up:

```bash
# code: defineFunction({ name: "signup" }) → { name: "register" }
xanots export ./xano/index.ts             # stderr: lock entry "function:signup" matches no exported object…
xanots lock rename --entry=xano/index.ts function signup register
xanots export ./xano/index.ts             # emits signup's original guid under "register" → engine renames in place
```

`rename`/`adopt` take no entry file, so on their own they look for `xano.lock` in the
**current directory**. Pass `--entry=<path>` to derive it beside the entry the way
`export`/`deploy`/`prune` do, or `--lock=<path>` to name the file outright. They never
reach for a lock you did not point them at — when they spot one next door they say so and
stop, rather than writing a file you did not name.

**After `--replace`** — a replace rebuilds the workspace with fresh engine identities, so the
lock is stale the moment it finishes and the next ordinary release would match nothing and
try to create everything. `xanots release ./xano/index.ts --replace` now re-pins the lock from the rebuilt
workspace itself and tells you to commit it. If there is no lock to re-pin, it says so —
without one, the next release duplicates every object.

**Adopting a live workspace** — `xanots lock adopt <bundle.json>` seeds the lock from a
real engine `packageExport`, capturing the live workspace's random guids by `(type, name)`
so code takes over an existing workspace and the first sync updates in place instead of
duplicating.

**CI** — `xanots export ./xano/index.ts --frozen-lock` fails instead of changing the lock, so a canonical
minted in a throwaway container can never silently diverge public URLs. Mint locally, commit
the lock.

</details>

<details>
<summary><b>Agent grounding</b></summary>

XanoTS ships two machine-readable descriptions of its whole authoring surface so an agent
can learn the SDK without reading source:

- **`llms.txt`** — the always-read router: the mental model, the deploy contract, control
  flow, the non-obvious rules in `## Gotchas`, and a `## Topic files` list naming each
  **`llms/*.md`** and when to open it. It does not document the CLI — `xanots <command>
  --help` and the `cli` array below do, from the same registry that generates the shell
  completions.
- **`llms/*.md`** — one file per surface (object def shapes, statements, values, fields,
  filters, lambda bodies, and the legacy names a pulled workspace carries), read only when
  that surface is in play. **`llms-full.txt`** is all
  of them concatenated, for a reader that wants one file rather than two.
- **`manifest.json`** — the exhaustive reference tier, reached by targeted lookup (grep or
  `jq` one entry; never read it whole). Every object kind (factory, `Xano.register*` method,
  payload key), every statement surface (the `s.<path>` accessor, stored `mvp:` name, and a
  typed field schema for the 149 declarative statements), the value constructors, the tag
  catalog, the filter catalog, and every CLI command and flag — plus live coverage counts.

Both derive from the SDK's own sources of truth (so they can't drift), regenerate with
`npm run manifest`, and are available at runtime via `buildManifest()` / `renderLlmsTxt()`,
both exported from `@xanots/core/internal`.

</details>

<details>
<summary><b>Coverage &amp; scope</b></summary>

XanoTS emits only (the engine imports/executes). Fidelity is proven by deep-equal against
the real Xano engine golden fixtures, and a coverage report prints on every test run.

| Surface | Coverage |
|---|---|
| Object kinds | **24 / 30** — counted over the engine's catalog, where each trigger type is its own kind |
| Statements (via `s`) | **214 / 214 (100%)** — every engine statement surface has a factory |
| Filters (via `fl`) | **226 / 226 (100%)** — every filter's signature is known: 131 carry arguments, 95 take none |

The six engine kinds you cannot author here are `tablemap`, `run.job`, `run.service`, the
superseded `realtime_channel`, and — correctly, since they are instance state rather than
workspace source — `branch` and `market_item`. `llms.txt` names them with
their reasons. `llms.txt` and `manifest.json` regenerate their counts from the SDK's own
catalogs; this table is hand-written, so a test asserts every figure in it against
`buildManifest()` (`@xanots/core/internal`) — the numbers here and the numbers an agent
reads cannot disagree.

The statement catalog is generated from the engine's own schemas (`npm run codegen`): 149 of
the 214 surfaces are declarative and carry a typed field schema, and the remaining 65 —
control flow, the `db` family, the call family — are hand-authored. **Reachable ≠ byte-verified**: every surface is
authorable, but a structural special without a persisted golden yet emits a shape *modeled*
on the engine schema, to be deep-equal'd against fixtures as they are captured.

**Out of scope** — reimplementing the engine's XanoScript parser, executing objects at
runtime (XanoTS only compiles), and generating engine-side numeric ids/timestamps.
(Object guids and canonicals *are* handled — deterministically derived or frozen via
`xano.lock`.)

**Deferred (by design)** — folder auto-discovery, and the `service` / `vault` / `branch`
payload sections. (Round-trip decompile is no longer deferred: that is
`xanots codegen`, above; nor is `workflow_test` — it is a first-class kind, see
`workflowTest` above.) `InferResponse`
auto-derivation covers the object-literal and single-`db`-variable cases (matching the engine's
static walk), and follows a CALL into the object it invokes (`s.function.call`/`.run`,
`s.tool.call`, `s.api.call` given a def handle bind the target's own `InferResponse`);
a response variable produced inside control flow or `set_var`, and addon/related-field
keys, resolve to `unknown` — declare `responseShape` for those.

</details>

---

<div align="center">

**Write TypeScript. Run `xanots deploy`. See it live.**

[npm](https://www.npmjs.com/package/@xanots/core) ·
[GitHub](https://github.com/xanots/core) ·
[Issues](https://github.com/xanots/core/issues) ·
[`llms.txt`](llms.txt) — the agent-facing router, mapping the `llms/` topic files

XanoTS is the official TypeScript SDK for [Xano](https://xano.com). MIT licensed.

</div>
