@sidestep/core
v4.1.42
Published
Sidestep — your Xano backend as TypeScript. `sidestep deploy` ships your typed workspace (and an optional static frontend) to a live, auto-expiring ephemeral environment and prints its URL. init → deploy → URL.
Downloads
8,549
Maintainers
Readme
SideStep
Your whole backend, in TypeScript. Live on the internet in one command.
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.
npx sidestep login # 1. sign in (opens your browser)
npx sidestep init my-app && cd my-app # 2. scaffold your backend + frontend
npm run build # 3. build your frontend → frontend/dist
npx sidestep 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 0mFrom an empty folder to a live full-stack app. init sets up your project — a
TypeScript backend and a React 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 · The model · Quickstart · Type-safe frontend · Reference
Why SideStep
Xano gives you a genuinely scalable backend — Postgres, serverless functions, background tasks, realtime, MCP servers, AI agents — without you running a single server. SideStep 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.
sidestep deploycompiles 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, thensidestep releasethe 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 —
deployfigures 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 committedxano.lockrenames stay renames instead of delete-and-recreate.🧩 The types flow to your frontend. Import a
query()def into your React/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:
llms.txtis the lean, canonical tour an agent reads to author the SDK, with the exhaustive per-entry catalog — full field schemas, filter argument lists, engine mappings — a targeted lookup away inmanifest.json. Agents learn the whole SDK without reading source.
Deploy it: backend + frontend, one command
SideStep'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. sidestep 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. SideStep
collapses that into one command — point --static at your built frontend and it archives
and uploads it to the edge-served static host, right after the backend import, in the same run:
# 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 sidestep 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
✓ Config injected into index.html: 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
Expires in 1h 0mOne 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 sidestep ephemeral
<list|get|delete|export>.
Wiring the frontend to the backend. The deploy bakes the environment's backend URL into your build's
index.htmlautomatically, as awindow.XANO_HOSTglobal evaluated before your app bundle. So read it at runtime with a build-time fallback and you never have to know the URL ahead of time:const HOST = (typeof window !== "undefined" && window.XANO_HOST) || import.meta.env.VITE_XANO_HOST;
window.XANO_HOSTis the sandbox tenant URL your deployed APIs answer at (the same valuesidestep sandbox detailsprints asbaseUrl); it is notsidestep profile me, which prints your account's instance origin. Because injection happens at deploy time, a prebuiltfrontend/distretargets 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 aswindow.<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 viaenv(name).Verifying the injection: the served
index.htmlwrites the global in bracket notation —window["XANO_HOST"]="…";— so grep for the bare tokenXANO_HOST, not the exact stringwindow.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 |
|---|---|
| sidestep deploy | A disposable ephemeral environment (default) — create-or-refreshed each run, auto-expiring, with its own URL. --dest sandbox targets your throwaway singleton instead. |
| sidestep release | Your main Xano instance workspace — the production target. (Coming soon; prints a notice for now until record-preserving import lands, so a release never wipes production data.) |
Every deploy is a full replace of the disposable environment — always fresh, no
merge mode, no flags to get wrong. 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:
XANO_REFRESH_TOKEN=… XANO_CLIENT_ID=… npx sidestep 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
releasepath.
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 SideStep 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 sidestep init
scaffolds, with the pulled workspace filling xano/. So a pull deploys:
sidestep 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 URLThe other three sources are the same command with a different origin:
sidestep sandbox codegen my-app # your sandbox
sidestep ephemeral codegen pr-42 my-app # a named ephemeral (tenant first, path second)
sidestep codegen ws.json my-app # a bundle already on disk — offline, no loginInside, 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, and 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). 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.
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 nosidestep 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, anddeployto a disposable ephemeral or sandbox. Workspace env var values ride inline inxano/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 SideStep
compiles the whole thing into Xano's importable bundle.
import { workspace, table, query, apiGroup, f, s, ref, c, expr, col } from "@sidestep/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]);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 215 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:
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.
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:
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. 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, sidestep deploy --static refuses 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 sidestep init, which scaffolds the whole project — a Vite
- React frontend under
frontend/(styled with Tailwind v4 and shadcn/ui), a sidestep backend underxano/, and thexano:export/xano:deployscripts already wired:
npx sidestep init my-app # scaffold; prompts to set up AI instructions
cd my-app
npm run dev # run the frontend right awayinit flags: --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.
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: sidestep deploy --static injects the
backend URL as window.XANO_HOST, which takes precedence.
The frontend ships Button and Card in frontend/src/components/ui/, plus a
pre-configured components.json, so npx shadcn@latest add dialog form input
works immediately — no shadcn init step. shadcn components are copied into your
repo rather than installed, so you own and edit them directly. 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).
Prefer to wire it by hand? The same loop, from scratch:
# 1. Install
npm install @sidestep/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 sidestep login # opens your browser; you pick the instance + workspace
# 4. Deploy to a live ephemeral environment — this is the dev loop
npx sidestep 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 sidestep deploy ./xano/index.ts --static ./distThat'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 (SideStep defs are ESM-only). On Node ≥ 22.6 a
.tsentry loads natively; installtsxfor older Node or multi-file workspaces. Set"type": "module"in the nearestpackage.jsonif 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()/verbjust works. To spot-check from Node, run a real file withtsx <file.ts>from inside the project root — nottsx -e "import …"(its CJS-preparse mis-resolves the packageexportsmap →ERR_PACKAGE_PATH_NOT_EXPORTED) and not barenode file.ts(chokes on the.js-specifier intra-workspace imports the CLI's own loader resolves). Intra-workspace imports use.jsspecifiers (../tables/links.js) undermoduleResolution: 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:
import { listPosts } from "../xano/index.js"; // the same def you deployed
import { post } from "../xano/tables.js";
import type { InferRow } from "@sidestep/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 frozenxano.lock). No hardcoded strings.listPosts.verb→ the HTTP method, straight from the def.sidestep paths ./xano/index.ts(aliasroutes) → list every endpoint's verb and resolvedapi:<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'sinputmap 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 toInferInput: serialize an input map into aURLSearchParams(scalars stringify, arrays repeat the key,null/undefinedare 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 rejectname: "export.zip", it stores the endpoint with an empty name, so it deploys clean and then 404sUnable to locate request.on every request.query()throws instead. Name itexport_ziporexport/zipand set the file extension in the response headers. Same rule forrealtimeChannelandtoolnames;realtimeMessageis 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: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 orquery()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: a value containing/would silently address a different endpoint, whichgetPathrefuses.realtimeChannel()paths work identically.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 fordb.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 foredit/patch, a unique-constraint error foradd, whileadd_or_editupserts and never misses),Row | nullfordb.get(it bindsnullon a miss rather than throwing — handle the not-found path), a row list fordb.query/db.bulk.patch(→Row[]), abooleanfordb.has, anumbercount fordb.bulk.delete, and aget/queryoutput: [...]selection narrows to aPick(still| nullforget). 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, rawdirect_query) — it resolves tounknown; declareresponseShapeto close it.
import { listPosts, getPost } from "../xano/index.js";
import type { InferResponse } from "@sidestep/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 nullFor 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:
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.)
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:
import { getSnippet } from "../xano/index.js";
import { query, type InferInput } from "@sidestep/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 @sidestep/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 @sidestep/core/node entry a frontend never pulls in.
Bundle size & tree-shaking. @sidestep/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: on a trivial Vite app, one def imported for one getPath() measured
37 kB minified against 784 B for a hand-written path string, and splitting modules reduces
the incremental cost of further defs but never the floor.
Generate a route manifest instead. It keeps the derived-not-hardcoded contract at almost no bundle cost:
sidestep routes ./xano/index.ts --emit xano/routes.gen.tsThe emitted file is plain data plus one interpolator and imports nothing at all — the same
app builds to 1.7 kB. Route names and their {param} keys are still checked at compile
time, so a backend rename is a compile error rather than a 404:
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:
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
canonical tour — every signature, plus the engine behavior each one depends on) and
manifest.json (per-entry detail: full field schemas with engine defaults, filter
argument lists, stored-name mappings). Both 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.
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 sidestep 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
sidestep export/deploy read the module's default export.
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.txt; 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.
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.
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: "[email protected]" }, as: "created" }),
s.expect.to_be_defined({ expr: ref("created") }),
s.expect.to_equal({ expr: ref("created.status"), value: c.text("ok") }),
],
});Realtime — the only three-level containment chain in the SDK: realtimeServer owns
realtimeChannels, 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.
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).
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.txt.
- An empty return denies, and so does a crash.
connectandjoinare gates: return{ allowed: true }or any truthy value to admit. A stack that falls through, or a gating trigger with noresponse, 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 withref(path, { safe: true }), sincedb.getbindsnullon a miss.export()warns on the missingresponse; nothing can warn about the raise. Gating is opt-in — a server with noconnecttrigger admits everyone. - Only
nulldrops a message. In adelivertrigger (per recipient) and in a message handler,false/0/""all deliver the message unchanged, and a crash broadcasts the sender's original unvalidated payload. Returnnullto suppress. So a redaction check written as a boolean sends the very message it was meant to hide. conversation: { enabled: true }alone stores nothing.limitdefaults to0, and0means retain none. Always pass alimit. 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.publishis the push direction, and it is fail-soft. It bypasses the channel'spublish.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.
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 under ## Legacy in llms.txt rather than in its 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.
// 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." },
tools: [{ tool: 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, soInferResponsereflects either. llmis 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 theinput.*catalog and.resultis typed from it wherever the handle is passed — no second witness. The type-onlyresultShapeis only for overriding that, or for an agent referenced by bare name. - String settings are Twig-templated at run time. The
argsyou pass tos.ai.agent.runbecome{{ $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 withobj({...}), notc.obj. mcpServer().getUrl(HOST)derives the Streamable-HTTP endpoint from the def, the same contract asquery.getPath(). Agents expose onlygetCanonical().
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 }).
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.
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:
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 sidestep deploy waits for it: after the import it
reads each microservice and reports whether it is ready, still starting, or failed, then
lists them. A microservice that hasn't come up in time is a warning, not a failed deploy: the
backend is already live and the container usually follows moments later. Skip the wait with
--no-verify. The same report is available any time from sidestep ephemeral get <env>,
sidestep sandbox details, and sidestep workspace details.
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.
This surface is early and expected to change. configs and volumes are typed but
unconfirmed against a live engine. And two fields carry secrets into a pulled tree
verbatim — chart.values and registryAuth.dockerconfigjson — because otherwise a pulled
microservice could not be redeployed. Prefer leaving dockerconfigjson unset and supplying
it out of band.
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.
query({
name: "get_user", verb: "GET", apiGroup: blog,
middleware: { pre: [rateLimit], post: [audit] },
stack: [/* ... */], response: ref("user"),
});exceptionPolicydecides 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 authorederror/status (a trippeds.redis.ratelimit→ 429) while still runningpost;"critical"is the same but skips thepostchain. 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.middlewareat 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 todatasources. auth()isnullon a public host, and apremiddleware runs after auth resolution. A rate limit keyed byauth("id")on a public endpoint collapses every caller into one bucket, silently.export()warns on direct attachment of anauth()-keyed middleware to a host whereauth()may be null.- A
resultStrategy: "replace"middleware attachedpostrewrites the response at runtime, whichInferResponsecan't see — declareresponseShapeon the endpoint. workspaceConfigalso carriesrealtime,documentation, andswagger, which are server-shaped and carried verbatim rather than authored.realtimethere 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):
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.
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"):
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.
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). Foreign keys
are f.tableRef(table), whose link resolves to the target table's guid at export. 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
defaultmust 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 Postgres22021. Accents,€, and most CJK are fine; otherwise put the value on an endpoint input, applied at runtime bind. idandcreated_atauto-inject at the head of the schema unlesssystem: falseor you declare them (idType: "uuid"for a uuid key). Both are usable wherever a column name is expected and both appear inInferRow. The standard indexes —primary(id),btree(created_at desc), plusgin(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_xdopicks the storage mode — every field as JSON under the internalxdocolumn, or a real Postgres column per field. It is a workspace setting (defaultfalse) each table mirrors, overridable per table withtable({ useXdo }), resolved atexport()so the two can register in any order.
The stack of a function/query/tool is a list of statements, all reachable through one
discoverable, typed namespace — s:
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:
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:
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()) // numberFilters 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:
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.
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. Only s.db.query takes a where, and its where/sort/paging/output
are applied by the engine, not in your stack.
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.txt; four behaviors are worth knowing here:
A join condition spells its two sides differently. The joined table's column takes its
asalias; 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 setstableAlias— 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, sodb.queryrejects that spelling at export instead.Paging changes the response shape. Supplying
pagingwith metadata on (the default) returns a paging envelope —{ items, curPage, nextPage, prevPage, offset, perPage, itemsReceived }, plus totals whentotals: true— instead of a bareRow[], andInferResponsereflects that. Passmetadata: falseto keep the bare array. ReadnextPage(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 adb.queryfilter inherits SQL's AND-before-OR precedence (a OR (b AND c)). Writeand(or(a, b), c)oror(a, and(b, c))— each says exactly one thing in every context.An aggregate
nameis written bare ("status") and alias-qualified on emit; the engine rejects a bare column in an aggregate, 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.
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").
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.*(), and
out(name) for a parent-row column in an addon input. 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.arraytake plain JSON literals only. A nested tagged value (inp/ref/auth/c.*) is a compile error. For a computed object — a response, or anapi.requestparams— use a record of values ({ count: ref("count") }). For a dynamic object argument useobj({...}), which builds a checked expression.An
obj({...})member may carry a filter chain. That matters most for the null-safe drill:db.getbindsnullon a miss, soobj({ city: ref("row.address.city", { safe: true }) })is the normal shape — no per-members.set_varto hoist it out.c.now(),env()andsys.*()are members too.A bare scalar works in any
fl.*argument.fl.get("a.b", 0)encodes identically tofl.get(c.text("a.b"), c.int(0)); strings, numbers and booleans are all wrapped for you. Objects and arrays still needc.obj/c.array.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()andfl.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.withFilters(ref("rows"), fl.csv_encode(",", '"', "\\")), // not fl.csv_encode()fl.csv_encodewrites no header —fl.csv_createis the one that does. They read as interchangeable and are not.csv_encodeemits 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,falsewrites empty, and a piped array of scalars collapses to a single line.csv_createtakes the column names as its piped value and the data as itsrowsargument.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.withFilters(ref("rows"), fl.fsort({ path: "score", type: "number", asc: true })),col()does not resolve to a stored value inside adb.editrow. To read-modify-write a column — incrementing a counter —db.getthe row first and pipe its bound value through a filter.col()evaluates tonullthere, sofl.add(1)computesnull + 1and the engine aborts.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, whi
