@xanots/core
xanots
Author an entire Xano workspace in TypeScript and compile it into Xano's stored JSON — an AI-first SDK where an agent (or a developer) emits well-typed TS that deterministically produces a valid, importable Xano workspace bundle.
You build a xano/ folder of typed objects, register them on a central Xano
instance, and call export() to get one aggregate packageExport bundle.
Install
npm install @xanots/core
The model
import { Xano, defineFunction, table, trigger, input, setVar, ref, c } from "@xanots/core";
const getUser = defineFunction({
name: "get_user",
input: { id: input.int({ required: true }) },
stack: [setVar("x1", ref("id"))],
response: ref("x1"),
});
const users = table({
name: "user",
auth: true,
// `id` + `created_at` are auto-injected system columns; declare only your own.
schema: [{ name: "email", type: "email", required: true, methods: ["trim", "lower"] }],
index: [{ type: "primary", fields: [{ name: "id" }] }],
});
const xano = new Xano()
.registerWorkspace({ name: "my-app" })
.registerFunctions([getUser])
.registerTables([users]);
export default xano; // `xanots export` reads this default export
workspace("my-app") is a convenience for new Xano().registerWorkspace({ name: "my-app" }) — the natural entry point that returns the same chainable registry:
import { workspace } from "@xanots/core";
export default workspace("my-app").registerFunctions([getUser]).registerTables([users]);
Authoring is declarative def-objects passed to factories — there is no
callback/chaining builder (no workspace(w => w.table(...))). See llms.txt's
Gotchas section for the handful of intuitions that commonly miss (foreign keys
are f.tableRef, not ref; system columns auto-inject; self-refs use the
bare-name form).
For a fuller example, see the test fixture workspace
(test/fixtures/workspace/) and
@xanots/auth — the canonical
extension package (auth tables + queries + a function, shipped as reusable
npm-versioned defs) — whose
docs/extending-xanots.md
documents the extension-package pattern.
xano.export() returns the importable bundle:
{ "app": "xano", "version": "1.03", "type": "workspace",
"payload": { "function": [...], "dbo": [...], "trigger": [...], "workspace": {...}, ... },
"sig": "<base64url sha1>" }
Project structure (explicit registration)
Lay objects out however you like and register them explicitly — there is no folder auto-discovery magic (deliberately):
xano/
├── functions/ get-user.ts export default defineFunction({...})
├── tables/ user.ts export default table({...})
├── triggers/ on-insert.ts export default trigger.table({...})
├── toolsets/ assistant.ts export default agent({...})
└── index.ts new Xano().registerFunctions([...]).registerTables([...])...
Object kinds
Every top-level Xano object is a registered kind with a factory and a
Xano.register* method. Payload keys use the engine's singular names.
| Author with | Xano method |
Payload key |
|---|---|---|
defineFunction({...}) |
registerFunctions |
function |
table({ schema, index, ... }) |
registerTables |
dbo |
query({ verb, apiGroup, ... }) |
registerQueries |
query |
apiGroup({ canonical, cors, ... }) |
registerApiGroups |
app |
trigger.{table,realtime,mcpServer,agent,workspace,error}(...) |
registerTriggers |
trigger |
tool({...}) |
registerTools |
tool |
toolset.mcp({...}) / agent({...}) |
registerToolsets |
toolset |
task({ schedule, ... }) |
registerTasks |
task |
middleware({ resultStrategy, ... }) |
registerMiddleware |
middleware |
addon({...}) |
registerAddons |
addon |
workspaceConfig({...}) |
registerWorkspace |
workspace |
Triggers
All six trigger types share one envelope, discriminated by obj_type + a
per-type meta: trigger.table (db), trigger.realtime, trigger.mcpServer,
trigger.agent, trigger.workspace, trigger.error.
Toolsets — AI vs MCP
- MCP toolset —
toolset.mcp({ name, tools: [{ tool: myTool }] }): a collection of tools exposed via the MCP protocol. Each entry'stoolis atool()def handle (or its name) — it resolves to the tool's guid at export, the same cross-object mechanism the call family uses. (A raw numericidis an escape hatch for adopting an existing engine-side toolset.) - AI/agent toolset —
agent({ name, agentSettings: { type: "anthropic", model, system_prompt }, tools: [{ tool: myTool }] }): an LLM orchestrator (stored as a toolset withtype:"agent"+agent_settings). - A tool (
tool({...})) is its own kind — a function-like operation a toolset references.
Tables
- Field types — a typed catalog
f.*(src/fields/catalog.ts) covers the full type surface: scalars (f.text/f.int/f.decimal/f.bool/f.uuid/f.date/f.email/f.password/f.json),f.timestamp(→epochms), the four file resources (f.image→blob_img,f.video→blob_video,f.audio→blob_audio,f.attachment→blob), the sixf.geo.*types,f.enum(values),f.vector(size), andf.object(children)(→obj). Foreign keys aref.tableRef(table)— anint(or{ type: "uuid" }) column whose persisted link is a trailing@method that resolves to the target table's guid via the same cross-object resolver used everywhere else. Tables accept a named-map schema ({ id: f.int({ required: true }) }), filter methods carry args ("min:8"), and tableviews[](expression/sort/hiddenCols) encode via the shared comparison encoder. The authoring→stored name mapping mirrors the engine'sSchema::TYPE_MAP; byte-exact vs the full corpus (schema:table-all/fancy/fancy2/sensitive/view). - System columns & indexes —
id(int primary key; setidType:"uuid"for a uuid key) andcreated_at(epochms,default:"now",access:"private") auto-inject at the head of the schema unlesssystem:falseor the author already declared them;db.add/db.editrow expansion sees the injected columns too. The matching standard indexes —primary(id),btree(created_at desc), plusgin(xdo)only when the table stores fields as JSON (seeuse_xdobelow) — are likewise auto-prepended (de-duped by type + covered fields, so author-declared ones aren't doubled); declare extras (unique/composite) and the standard set rides along. Byte-exact vsschema:table-sensitiveauthored without the explicit meta head or the explicit index list. use_xdostorage mode — a table either stores every field as JSON under the internalxdocolumn (use_xdo:true, and the engine adds thegin(xdo)index) or gives each field a real Postgres column (use_xdo:false, nogin); both read identically sincexdois hidden. It's a workspace setting (registerWorkspace({ use_xdo }), defaultfalse) that each table mirrors as its own source of truth. Atable({ useXdo })overrides per-table; otherwise a table inherits the workspace default — resolved atexport(), so the workspace and tables can be registered in any order.
Statements
The function/query/tool/etc. stack is a list of statements. The whole catalog
is reachable through one discoverable, typed namespace — s:
import { s, c, ref, expr } from "@xanots/core";
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.storage.delete_file({ pathname: c.text("tmp/x") }),
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: s.math.*, s.array.*, s.text.*, s.object.*,
s.expect.*, s.storage.*, s.security.*, s.api.*, s.cloud.*, … Each
declarative statement takes one typed args object (field names match the Xano
engine); the control-flow specials (s.set_var, s.conditional, s.for,
s.foreach, s.while, s.group, s.switch, s.try_catch, s.return,
s.foreach_break, …) keep their authored signatures. The call family
(s.function.run/s.function.call, s.api.call, s.task.call,
s.tool.call, s.trigger.call, s.middleware.call, s.addon.call) invokes
another workspace object — pass the target's def handle (or name) and xanots
resolves the cross-object reference at export (see below). The db family
(s.db.add/s.db.edit/s.db.add_or_edit/s.db.get/s.db.del/s.db.has/
s.db.patch/s.db.truncate/s.db.schema) reads and mutates records — pass the
target table the same way; s.db.direct_query runs raw SQL (no table ref).
The single-record reads/mutations match one field, not a where-expr:
s.db.get/s.db.edit/s.db.del/s.db.has/s.db.patch take
{ fieldName, fieldValue } (fieldName defaults to the primary key id), while
writes (s.db.add/s.db.edit/s.db.add_or_edit) take a partial row: { … }
(or explicit data entries). Only s.db.query takes a where/additionalWhere
comparison (or an array of them, ANDed) built with expr(...) —
where: expr(col("status"), "=", c.text("published")) — encoded into the
engine's operand-based search expression; a raw Value remains the escape hatch.
Recommended style: reach statements through the s namespace; the familiar
flat factories (dbAdd, dbQuery, setVar, mathAdd, objectKeys, …) remain
exported and emit identically, but prefer s.* in new code for one consistent,
discoverable surface.
Statements without a typed factory yet are still reachable by name via the
registry escape hatch: getStatementFactory("mvp:<name>")({ … }).
Values: c.int/text/bool/decimal/null/obj/array, ref(var), inp(input),
col(name), plus the context references auth(path?) (the authenticated
identity — auth("id") → $auth.id), env(name) (an environment variable),
and setting(name) (a workspace setting). filter(name, ...args) /
withFilters(value, ...filters) (filters spread or as an array) attach the
value pipeline; it has a typed catalog fl.* — withFilters(inp("email"), fl.trim(), fl.lower()) — 377 filters generated from the engine's own sources
(npm run codegen:filters; 161 carry named, typed args + JSDoc, the rest are
reachable and variadic by name).
Inputs: input.* fully mirrors the f.* catalog — every engine-legal field type
is also a valid function/query input. Scalars
(input.text/int/decimal/bool/email/password/uuid/date/timestamp/json,
input.enum(values)), files (input.image/video/audio/attachment), input.geo.*,
input.vector(size), and input.tableRef(table) all return a typed descriptor for
the input map (e.g. input.email({ required: true })). For structured inputs use
input.object(children) and input.list(element) (an array of any element
constructor) — e.g. input.list(input.text()) or
input.list(input.object({ id: f.int() })). Prefer these typed forms over
input.json() when the shape is known.
Comparisons (expr/s.conditional/s.while) use the engine operators
= != > < >= <=; the JS forms == === !== are accepted and normalized.
Consuming a query as a contract
A query() def is the single source of truth for an endpoint's path and its
inputs — so the code that calls the API can reuse it instead of re-typing the
URL and the request body. Import the query def and use it directly:
import { loginQuery } from "@xanots/auth"; // a xanots query() def
import type { InferInput } from "@xanots/core";
const BASE = "https://x8ki-letl-twmt.n7.xano.io"; // your instance host
async function login(email: string, password: string) {
const payload = { email, password } satisfies InferInput<typeof loginQuery>;
return fetch(BASE + loginQuery.getPath(), { // "/api:<canonical>/auth/login"
method: loginQuery.verb, // "POST"
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
loginQuery.getPath()returns the group-relative path/api:<canonical>/<name>. You prepend the instance host (xanots stays out of environment concerns). The canonical resolves in order: an explicitgetPath({ canonical })override, then the boundapiGroup()handle's in-codecanonical, then the value minted intoxano.lock. So you have two supported setups:- Set it in code —
apiGroup({ canonical: "auth" }). Stable, shareable, and resolvable everywhere including a browser bundle. - Let
export --lockmint it — runxanots export --lockonce (it generates a unique canonical and freezes it inxano.lock), then seed that lock before reading paths: the CLI does this automatically, and a build/codegen script callsseedLockOverrides(readLockFile(path))first.getPath()then returns the minted value.
getPath()never mints its own token — canonicals must be unique per Xano instance across all workspaces, so a call-time value could collide or not match the deployed endpoint. With no in-code canonical and no seeded lock it throws (passgetPath({ canonical })as a last resort). For a browser bundle that reads a lock-minted canonical, resolve it at build time in Node (seed the lock, callgetPath()) and emit the resulting string into your generated client — the browser has no lock of its own.- Set it in code —
InferInput<typeof loginQuery>is the request-payload type, derived from the query'sinputmap at compile time — no codegen, always in sync. Required inputs are required keys;nullableadds| null;input.list(...)becomesT[];input.enum([...])a literal union;input.object({...})a nested type. The same utility works on adefineFunctiondef (functions share the input system).
Browser-safe. The @xanots/core entry has no Node built-in dependencies, so
importing a query def (and its transitive workspace graph) into an Angular/React
bundle just works — getPath()/verb/InferInput are all you need on the client.
The node:fs-backed emitters (writeBundle, writeArtifact, readLockFile,
writeLockFile) and the programmatic CLI live in the separate @xanots/core/node
entry, which a frontend bundle never pulls in.
Emit & CLI
Emitters that write to disk (and the programmatic CLI) are Node-only — import them
from @xanots/core/node. The string emitters (emit, emitBundle,
serializeBundle) stay on the browser-safe @xanots/core entry.
import { emitBundle } from "@xanots/core"; // pure string — browser-safe
import { writeBundle } from "@xanots/core/node"; // writes a file — Node only
emitBundle(xano); // pretty-printed JSON string
writeBundle(xano, "workspace.json");
xanots export ./xano/index.js # bundle to stdout
xanots export ./xano/index.js --out ws.json
xanots compile ./xano/functions/get-user.js # a single function's JSON
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 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 adopt live-export.json --yes # seed the lock from a live engine export
xanots push ./xano/index.ts # compile + upload to your sandbox (RESETS it)
xanots push --bundle ws.json # upload an already-exported bundle
The CLI imports the module's default export, and the entry must be an ES
module — xanots defs are ESM-only. On Node ≥ 22.6 a .ts entry loads directly
via native type-stripping, so xanots export ./xano/index.ts just works when the
entry is ESM. Make it ESM by either setting "type": "module" in the nearest
package.json or naming the entry .mts. (npm init -y writes
"type": "commonjs", which pulls a .ts entry into the CommonJS graph and fails
with a "must be ES modules" error — set "type": "module" to fix it.)
Install tsx (npm i -D tsx) for two cases the native loader
doesn't cover: older Node with no .ts support, and a multi-file workspace whose
entry imports its own .js specifiers that resolve to .ts sources. tsx
respects the package type, so it is not a substitute for making the entry
ESM.
When a xano.lock sits beside the entry file, export (and compile) read it
automatically — see Identity & the lock file. The
lock path can be overridden with --lock=<path> (the = form only), the escape
hatch for a directory with multiple workspace entries.
Uploading to a sandbox
xanots push bakes the sandbox upload straight into the CLI, so a starter no
longer needs its own upload script:
xanots push ./xano/index.ts # compile the workspace + upload it
xanots push --bundle workspace.json # upload a bundle exported earlier
xanots push ./xano/index.ts --profile staging # pick a credentials profile
xanots push ./xano/index.ts --config ./creds.yaml # use a non-default creds file
push <file> runs the exact same pipeline as export (including xano.lock
seeding and merge), then POSTs the bundle to your instance's sandbox import
endpoint (/api:meta/sandbox/bundle). push --bundle <path> skips the compile
and uploads a bundle a previous export wrote — handy in CI where the two steps
are separate. The endpoint's response prints to stdout; progress goes to stderr,
so xanots push --bundle ws.json > result.json captures just the response.
Credentials come from the @xano/cli
login file, ~/.xano/credentials.yaml — run xano auth login first. The profile
is chosen as --profile → $XANO_PROFILE → the file's default; the file path
is --config → $XANO_CONFIG → the default location.
pushis destructive and non-permanent. The sandbox import resets the target sandbox before loading your bundle, and it writes to your live instance using your real CLI credentials. It's a fast dev-loop convenience, not a production deploy. Automated agents must ask for explicit confirmation before runningxanots push— never run it unattended.
Agent grounding
xanots ships two machine-readable descriptions of its whole authoring surface so an agent can learn the SDK without reading the source:
manifest.json— every object kind (factory,Xano.register*method, payload key), every statement surface (thes.<path>accessor, storedmvp:name, and a typed field schema for the 154 declarative statements), the value constructors, the tag catalog, and the filter catalog — plus live coverage counts.llms.txt— the same surface rendered as a concise plaintext grounding doc.
Both are derived from the SDK's own sources of truth (so they can't drift) and
regenerated with npm run manifest; a test fails if the committed files fall out
of sync. The same data is available at runtime:
import { buildManifest, renderLlmsTxt } from "@xanots/core";
const manifest = buildManifest(); // the structured surface description
const grounding = renderLlmsTxt(manifest); // the llms.txt string
Coverage & scope
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 progress on every test run.
Status:
| Surface | Coverage |
|---|---|
| Object kinds | 11 / 24 — function, table, query, api_group, all 6 triggers, tool, toolset/agent, task, middleware, addon, workspace |
Statements (reachable via s) |
214 / 214 (100%) — every engine statement surface has a factory; 154 codegen'd declarative + the hand-authored control-flow / call / db / ai / cloud / misc specials |
The statement catalog is generated from the engine's own schema YAMLs by a
schema-driven codegen pipeline (npm run codegen → src/statements/generated/),
with the non-declarative remainder hand-authored. Per-statement fidelity that
can't be derived from the schema (the output flag, the envelope tier) is
pinned from the golden fixtures; statements without a fixture are reachable
but not yet byte-verified.
Reachable ≠ byte-verified. All 214 surfaces are authorable, but the
specials that have no persisted golden yet (the structural specials —
db.bulk*/db.query/external SQL, ai.agent.run, cloud.job*,
array.map/union, action.call, service.function.run,
workflow_test.call, realtime/auth/raw-input, …) emit a shape modeled on the
engine schema, to be deep-equal'd against fixtures as they're vendored.
db.transaction and util.post_process are byte-verified (parser-minimal,
incl. their run-stack children); see test/statements/substacks.test.ts. The
214/214 counts authoring surfaces; the deep-equal-proven subset is smaller (see
the per-statement notes in src/statements/special/).
Identity & sync. Every top-level object carries a stable guid — the field
Xano uses as its 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 on the same code maps cleanly onto the same
workspace (no duplicates). By default the guid is derived from the object's
name; set an explicit guid on any def to pin identity across a rename, or
to match an object in an existing Xano workspace you're adopting into code
(the guid column is plain text, so any stable string works):
defineFunction({ guid: "fn_get_user", name: "get_user", ... });
// rename `name` later — the guid (identity) stays, so the sync still updates it
Cross-object references (a function.run/db.get/query→apiGroup/a
trigger.table({ table })/… pointing at another object) ride the same
mechanism: the reference resolves to the target's guid — its explicit guid if
set, else the name-derived one — so the import remaps both sides together. Pass
the target's def handle (it carries the guid) rather than a bare name when
the target sets an explicit guid (src/refs/guid.ts).
Because the name-derived guid ignores fields like a query's verb, two objects
of the same kind with the same name (e.g. a GET /posts and a POST /posts
both named posts) would resolve to the same guid. export() detects any
such collision and throws — name same-path endpoints distinctly (list_posts,
create_post) or pin an explicit guid.
Identity & the lock file
Per-object explicit guids pin identity one def at a time. 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 at creation, giving the same code
different public URLs per environment). Create it once with
xanots export --lock; from then on it's read automatically and updated on
every export (new objects appended, written atomically before the bundle).
Commit it next to your code.
Precedence at emit is always explicit in-code value → lock entry → name
derivation. The lock records explicit values too, so deleting an explicit
guid from code later resolves through the lock to the same identity instead
of silently reverting to the name derivation.
Renames. With a lock, a rename in code no longer has to mean delete+create on sync. The export warns about the orphaned entry and names the fix-up:
mv code: defineFunction({ name: "signup" }) → { name: "register" }
xanots export ./xano/index.ts # stderr: lock entry "function:signup" matches no exported object…
xanots lock rename function signup register
xanots export ./xano/index.ts # emits signup's original guid under "register" → engine renames in place
Orphaned entries are never auto-matched to new objects — xanots lock prune
removes them once you confirm the object is really gone (a pruned canonical's
public URL is unrecoverable, so prune asks for --yes).
Adopting a live workspace. xanots lock adopt <bundle.json> seeds the lock
from a real engine packageExport — capturing the live workspace's random
guids (and canonicals, when present) by (type, name) so code can take over an
existing workspace and the first sync updates in place instead of duplicating.
Two caveats: the engine's standard partial export strips canonicals (adopt
warns when none are found — existing URLs are still safe, because guid-matched
updates keep the server-side canonical), and a live bundle can contain vault
entries (secrets) — don't commit the bundle file; adopt warns about that too.
CI. xanots export --frozen-lock fails instead of changing the lock — use
it in CI so a canonical minted there (which would be discarded with the
container) can never silently diverge public URLs. Mint locally, commit the
lock.
Scope of the promise: the lock freezes the existing identity scheme — it does
not add finer-grained identity (a query's identity still ignores its verb),
and its guarantees apply to the partial/sync import path (full imports
regenerate guids engine-side). Canonical parity holds across instances or
non-colliding workspaces; canonicals are unique per instance, so importing
one lock into two workspaces on the same instance keeps the URL on the first
and the engine self-heals the second with a regenerated token.
The programmatic surface is exported from the package index (readLockFile,
seedLockOverrides, createLockContext, mergeObserved, renameLockEntry,
adoptFromBundle, …). The one contract that matters: seed before any def
module is evaluated, once per process — references bake guids at authoring
time, so seeding after your defs have loaded is a silent no-op (the CLI always
seeds correctly; resetLockOverrides exists for tests).
What's left
- Byte-verify the structural specials — every statement surface is now
authorable, but the specials shipped without a golden (listed above) carry a
modeled shape. As fixtures are vendored, promote each to a deep-equal test
and correct any shape drift (context-vs-input placement, envelope tier, the
api.call
headers/auth/verbblocks). sigbyte-exactness — implemented (base64url(sha1(...))); verified at the first real engine import (no signed-bundle fixture exists to deep-equal yet).
Deferred (follow-up, by design)
Folder auto-discovery (scan xano/** without explicit registration),
round-trip/decompile (bundle JSON → TS), live-instance push/deploy via the
metadata API, and the workflow_test / service / vault / branch payload
sections (emitted as empty arrays until authored kinds exist).
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; see Identity & the lock file.)