Skip to main content
An add-on is an ordinary npm package that declares itself to Xano SDK in its own package.json. The marketplace is the public catalogue of them, plus the six xanosdk marketplace verbs that find one and change what your project has installed.
The split is deliberate. The three read verbs hit a public catalogue: no token, no credential file, and they work signed out, so discovery is a first-class verb rather than something you do in a browser before you can use the CLI. The three writing verbs touch no network of their own and shell out to npm, so changing what a project has installed keeps working when the catalogue does not.

The two kinds of module

Every add-on is one of two kinds, and which one it is decides everything about how you use it. The kind comes from the module’s own package.json:
A workspace module (@xano-sdk/auth, @xano-sdk/vector, @xano-sdk/chatbot) adds tables and endpoints to the bundle you deploy. You install it, import it in xano/index.ts, and call its register function. What it contributes ships to your instance. This is the default: a module that declares no kind is a workspace module, which is why every add-on published before the toolchain kind existed still works. A toolchain module (@xano-sdk/xanoscript) extends the CLI instead. It adds nothing to the workspace, and registering it in xano/index.ts would be meaningless because there is nothing to register. What it does is participate in commands: it declares questions the CLI asks you, contributes lines to files your project owns, and runs hooks when a bundle is compiled or a workspace is checked. install tells you which one arrived, because the next step differs:
A module whose kind is a string the CLI does not recognize is reported and read as an ordinary package rather than defaulted to one kind or the other. That is a typo in the module’s own manifest, and guessing which loader it meant would run code the manifest did not describe.

Finding an add-on

list prints the whole catalogue, newest first. search matches a keyword across title, slug, package name, tagline and description, and extra terms are joined into one phrase rather than dropped, so search ai agent searches for “ai agent”. Both print a formatted summary to a terminal and stable JSON to a pipe. There is no flag to discover: an agent gets the machine shape by doing what an agent already does.
Each JSON row is the whole record details returns — description, includes, requirements, register_snippet, agent_prompt — so one list fills a catalogue without a details call per module. A row also carries kind (workspace or toolchain) when the catalogue records it; a row without it says nothing about the kind until the package is installed. The headline field in a listing is npm_package, because that is the string you retype into install. The slug names the module’s web page and is deliberately absent from the terminal rendering: printing it beside an install line invites someone to install it. details is the one to reach for when wiring an add-on. It prints the long description, the objects the module puts on your workspace, what you have to supply yourself, the xano/index.ts registration to copy, and the install line: The rendering has a fixed shape, filled from the module’s own catalogue record:
The Installs rows carry one of table, endpoint, function, task, trigger, agent, mcp, middleware. A toolchain module lists none of them, because it puts nothing on the workspace. --prompt emits the publisher’s own wiring instructions, raw and alone, written to be piped straight into a coding agent. It belongs to details and is refused on every other verb: a flag that parses cleanly and then does nothing is worse than one that fails. details takes an npm package name or a slug. A miss prints the catalogue’s own sentence plus a pointer at search, because a miss is usually a half-remembered name. A module that has been removed from the marketplace (listed: false) keeps its name reserved: details still answers it, with a warning before every output mode, --prompt included, on stderr so a piped prompt is unaffected. It is dropped from list and search entirely, and install refuses it.

Pointing the reads somewhere else

XANOSDK_MARKETPLACE_URL repoints all three read verbs. The catalogue host is otherwise hardcoded in a published package, so a move would break every installed copy of the CLI at once and only a release would fix it. A base with a path works too, so an ephemeral or a tenant (https://<instance>/tenant/<name>) can stand in for it. See Environment & identity.

Installing

The argument is resolved through the marketplace the way details resolves it, before npm runs: a module’s npm name or its slug (xanosdk marketplace install auth installs @xano-sdk/auth), keeping any version or tag (auth@next). The install is then npm install of that module’s package. A name the marketplace does not list is refused before npm runs, whatever its scope, with the not-found exit 8 details uses (xanosdk marketplace search <name> finds the real one) — install adds marketplace modules, not arbitrary npm packages. When the catalogue cannot be reached, an @xano-sdk/ name still installs as typed and any other name is refused. file: paths, tarball URLs and git specs are installed as given, without a lookup. What the command adds over typing npm install yourself is three things npm will not do:
  1. Add-ons are on the CLI’s map. xanosdk --help says they exist.
  2. It refuses before npm runs when you are not in a project. This is the oldest reason the command exists: run npm install from a parent directory and npm cheerfully creates or mutates the wrong package.json and tells you it succeeded.
  3. It reconciles afterwards. A toolchain module that arrived is asked its questions, and the project records the answers.
The reconcile reads every dependency rather than the name you just typed. So a module that arrived some other way is configured by this run too: a plain npm install, a merged pull request, a git pull that changed package.json. It also means nothing has to recover a package name out of a file: or tarball specifier in order to configure it. A toolchain module gets two more steps, and reinstall runs both as well. Its peers other than @xano/sdk (zod, say) are added as direct dependencies through the project’s package manager: yarn never installs peers, and pnpm does not link them where routes.gen.ts can import them. Then, when the project keeps a routes.gen.ts beside its entry, the file is regenerated, so the module’s section lands with the module rather than at the next xano:routes. Under --json, peers lists the specs that were added (["zod@^4.0.0"], or []), and routes says what happened to the file: remove reports routes the same way, for the file it regenerates without the removed module. init takes the same names and resolves them the same way — --marketplace auth is @xano-sdk/auth, and an unlisted name exits 8 before anything is written — so a project can be scaffolded with its add-ons already installed and registered:
reinstall and remove take those names too: xanosdk marketplace remove auth removes the @xano-sdk/auth that install auth added. A name the project already depends on is taken as typed, with no catalogue lookup, so both keep working offline. A name that is neither in the project nor in the catalogue exits 8, as install does. remove is idempotent: removing a marketplace module the project does not have succeeds (exit 0, alreadyGone: true under --json), so a retried script does not fail. See The scaffolded project for what that writes and when a module is left unregistered.

Peer ranges

Every module published today peers on @xano/sdk with a range that names no prerelease, and npm excludes prereleases from those. An install that npm refuses over a peer range is retried once with --legacy-peer-deps, and the retry is reported out loud even when it succeeds: that is a tree npm deliberately declined, in a project you have been running. That retry is why the SDK asks the question again itself, after npm. It reads the module’s declared range against the running version with a comparison that knows the difference — a prerelease CLI and a range shape it cannot parse are both “no opinion”, never a mismatch — so only a version you are definitively outside of is refused. What happens then depends on whether the module predates the command: A different installed module that is out of range does not stop the verb. install, reinstall and remove skip it with a warning naming its range and the fix, and leave its settings and its routes.gen.ts block exactly as they were. export, deploy and preflight still refuse it. The point of undoing the install is that the alternative is worse: a module whose hooks were built against a contract this SDK does not have registers and applies nothing, so Installed would be followed by a project where every command that loads modules refuses it.

Configuration lives in your package.json

A toolchain module’s per-project settings live in the consuming project’s package.json, under the "xanosdk" key, namespaced by package name:
That block is written from your answers to the module’s questions and read back on every later run, which is what makes a choice made once survive every run after it. It is also the only memory a module has. Absent config means enabled. A package in dependencies with no block is enabled with no settings: the next deploy fires its hooks with an empty config and it runs on its own fallbacks with nothing you chose. export and deploy report that state on every run, because the likeliest way a module reaches it is a plain npm install or a merged PR, neither of which runs a xanosdk verb:
Running install on a package that is already installed reads as a no-op until you know it reconciles. It is the fix. "enabled": false switches a module off. It is then never imported at all, which is why reading config first matters: a disabled module costs nothing on every compile, export, deploy and preflight that does not use it. A module also contributes lines to your .gitattributes, in its own marked block:
Each module’s lines land in a block naming it and stamped with the version that wrote them, so re-answering one module’s questions rewrites exactly that span and nothing around it. Two installed modules cannot clobber each other’s lines, because neither module writes the file.

Answering questions without a terminal

Each question a module declares derives a long flag that answers it, so a non-interactive run does not have to accept defaults:
A string or choice question takes --flag=value, not --flag value. The CLI cannot know a contributed flag takes a value, because the module is not loaded when the arguments are parsed, so it never consumes the following token. A boolean question takes the bare --flag, or --no-flag to decline one whose default is true. A flag no question claims is refused by name, naming the verb you actually typed. --json makes the run non-interactive, and so does a stdin or a stderr that is not a TTY: prompts are drawn on stderr, so a run whose stderr is redirected has nowhere to ask even when stdin is a terminal. That is said once, up front, rather than question by question:
The machine output names where every answer came from (flag, prompt, config, default) and which flag answers each question, so an agent reading it can change one setting on the next run.

Re-asking: reinstall

reinstall re-runs npm, then re-asks one module its questions and reconciles, offering your project’s current settings as the defaults rather than the ones the module shipped. Stored answers outrank declared defaults, so a repeated run never quietly resets a directory you chose. It says four things before any of that runs, because each is a different mistake with a different way out: The last one would otherwise be a silent no-op, because a disabled module is never imported and a reconcile would find nothing to ask. Re-asking a module its questions is asking whether you want it, so the only coherent reading is “turn it back on and ask”. Answer no to its own enabling question and it goes straight back off. npm runs before every check but the first, so reinstall also repairs a half-installed module rather than refusing to work on one. If a module does not map its stored settings back to answers, the re-ask starts from the defaults it ships, and the command says so rather than letting you discover it by accepting every prompt and finding your chosen directory reverted.

Removing

remove uninstalls the package and then drops the settings and the contributed lines together. The order is the whole design: the reconciler derives a removal from dependencies, so the block can only go once the dependency has. A plain npm uninstall is not the same thing, and the leftover is not cosmetic. The package goes; the "xanosdk" block and the contributed lines do not. Nothing reports that leftover, either: the loader only visits packages the project still declares, so once npm has taken the name out of dependencies, the module is invisible and its check is simply not run. A --frozen-lock run in that state exits 0 having verified less than it did yesterday, and says nothing. Only the next install, reinstall or remove drops the stale block. The state that does fail every --frozen-lock run is the other one: a module still declared in dependencies but missing from node_modules, which is what npm uninstall --no-save, a deleted node_modules/<pkg> or a drifted lockfile leaves behind. There the project still says the module is expected, so its absence is reported rather than assumed away:
That half-removed project is a first-class input to this verb, not a refusal. A package that is no longer a dependency but is still configured skips npm (there is nothing to uninstall) and the reconcile does the repair. These states are refused before npm touches anything: A failed uninstall costs nothing. The dependency is still there, so a reconcile would derive no removal, and the project is byte-for-byte what it was. Fix what npm reported and re-run. remove does not offer to delete a directory the module generated. That is the module’s data, the contract names no such directory, and a removal that deletes rendered source is not one you can undo.

What a reconcile guarantees

Every one of these verbs ends in the same idempotent pass. The correct on-disk state is a pure function of your dependencies plus your stored "xanosdk" block, so it can be rebuilt from scratch at any moment, however a module arrived.
  • Nothing is written until every target has been checked. An unparseable package.json, a "xanosdk" key that is not an object, an unbalanced managed block, a contribution the contract refuses: each aborts before any write, so a refusal never leaves one of two files patched.
  • An unchanged file is not rewritten. Mtimes included. A tool that rewrites two version-controlled files on every install fills a repository with no-op diffs.
  • A module whose hook throws loses its contributions, not your command. Every byte it owns is left exactly as it was, and it is reported.
  • A module that cannot be recomputed faithfully is carried forward untouched rather than re-derived from shipped defaults. The run reports it as carried.
The reconciler re-reads both files at the moment of writing and replays its decisions over whatever is there now. The questionnaire blocks on a human, so the window between reading and writing is unbounded: long enough for another verb, an npm install or a hand edit to land in it. Every decision is keyed by package, so the replay touches only the packages that run settled.

Under --frozen-lock

A frozen run is a guard, and a guard that silently stops running is worse than one that fails loudly. So under --frozen-lock a toolchain module that fails to load is fatal rather than a warning, and so is a hook that throws. On a normal run both are reported and skipped, matching every other optional side effect in the CLI. The asymmetry exists because if a plugin fails to resolve in CI (a partial install, a stale plugin path after a version bump) then its hook never fires, nothing checks what it checks, and xanosdk export ./xano/index.ts --frozen-lock exits 0 against a tree that is wrong. A red guard gets fixed; a green one gets trusted. A module’s own check therefore rides on the same --frozen-lock run as xano.lock, so a stale generated tree fails CI beside a stale lock. See The scaffolded project for the workflow init writes.

Writing your own

Both kinds of module are documented in The module contract: the manifest fields, the @xano/sdk/plugin types, every hook, and what the SDK refuses.
Generated from @xano/sdk 1.0.9.