Skip to main content
An add-on is an ordinary npm package. It becomes a Xano SDK module by describing itself in a "xanosdk" field in its own package.json, which is the only description of a package that ships with the package. Everything below is that field plus, for a toolchain module, the default export of one file. Read The marketplace first for the user-facing side: what install, reinstall and remove do to a project, and what your module’s settings look like once they land in it.

Which kind are you writing?

The kind field is what routes a package to one loader or the other. It defaults to workspace, because every module published before the toolchain kind existed declares none and defaulting the other way would strand them. A kind the SDK does not recognize is never defaulted to one kind or the other: the failure a default would produce is a module reported as unwired when its only fault is a typo in one string. What that costs you depends on where the typo is read. xanosdk init refuses by name, with the valid kinds listed, because a scaffold that silently drops your module is worse than one that stops. Every other path, the marketplace verbs and the CLI’s own module loading included, warns and reads the package as an ordinary dependency rather than taking a whole command down over one unrecognized string. Either way it is reported: nothing guesses which loader you meant.

Workspace modules

A workspace module exports a register* function that mutates a workspace and is called from the user’s xano/index.ts. It says how in its manifest:
options entries take one of two forms. A literal is written into the generated file as JSON, so nothing is imported for it:
An import names another package’s export, for a module that cannot be registered bare:

Declaring options is all or nothing

The field states what the whole argument is, and is taken at its word without your register function being read. Declare "options": {} and the call is written bare. Omit the key and your module has said nothing either way, so the SDK imports your entry and measures register’s arity. A function that takes a second parameter nobody declared is left out of the generated xano/index.ts rather than called without it, with a commented call and a pointer to xanosdk marketplace details <pkg> --prompt. The call would not compile, and a fresh scaffold that passes npm run typecheck is worth more than one that has your module in it. So: a module that needs an option only the user can answer (a URL into their own frontend, their own role model) omits the key rather than declaring the rest. That is what lets the CLI find the argument and leave the module out honestly. Arity is a signal rather than a proof, because the published artifact is not the source. options?: Opts and a default parameter downleveled to ES5 both report 2, and (xano, ...rest) reports 1. Declaring the field is what settles it; nothing measured from outside your module can.

Registration must be a mutation

The generated file writes registerX(app); as a statement and default-exports app, in any order. That is only correct because registration mutates the workspace it is handed. Write your register function so it registers onto the instance it receives; returns only decides whether the result is worth binding to a name, and is never load-bearing for correctness.

If you declare nothing

A module with no "xanosdk" field still works. Its installed entry is imported and searched for a single export matching /^register[A-Z]/. Real exports are read, not prose, so nothing can mistake a comment for code. Two matching exports is a refusal rather than a guess, and the refusal says the manifest field is what settles it.

Point somewhere useful

Declare a homepage in your package.json. It is where a refusal sends the reader when the CLI cannot wire your module, and the installed manifest is the one place that says where without the CLI knowing your package.

Toolchain modules

A toolchain module declares its kind and the file whose default export is the plugin:
@xano/sdk is a peer, never a dependency. Your hooks are handed real compiled data, so your module is only correct against the SDK versions whose shapes it understands. Write that range out with a floor and a ceiling, as above: the ceiling is the next major, so a module built for 1.x loads on every 1.x SDK and is refused on 2.0. That range is enforced at load, on every command that discovers modules. An SDK outside it is refused by name before your plugin file is imported, naming the range you asked for and the version it got. So the floor is the version whose shapes your hooks understand, not an aspiration — declare it accurately and the SDK will never quietly run your module against a contract it was not built for. Ranges are read as >= / > / <= / < / = comparators, ^, ~, exact pins and || unions of those; a shape outside that subset is not refused, it is simply not checked.

The import erases

@xano/sdk/plugin is a types-only entry. Nothing in it has a runtime representation, so the import erases at compile time and your published bundle neither carries the SDK nor resolves it at runtime. That matters because the SDK is the thing importing your plugin: a plugin that pulled the SDK back in would load a second copy of the compiler into the process already running one. A plugin that wants the precise per-kind payload shapes can depend on @xano/sdk/internal for them.

The plugin

kind: "toolchain" is repeated from the manifest so the two must agree. A plugin whose default export does not say it is refused: the mismatch means one of the two is stale, and guessing which would run code the manifest did not describe. Every hook is optional. A module that only adds a .gitattributes line declares contributes alone. Six hooks, and deliberately no more. The first five were designed from one example (a module that renders the workspace to a committed tree) and cover exactly what it needed. The sixth, routesManifest, was added when a second module, @xano-sdk/zod, brought a second real set of requirements: writing into a file the SDK generates rather than a tree of its own. A hook is added when a module needs it, not in anticipation.

Questions

A module declares questions as data, never as a prompt callback:
Two reasons the callback form does not exist, and both are hard. A callback would let a module hang a command on a TTY read in CI, where there is no one to answer it, and the SDK could not even impose a timeout because it cannot tell a slow prompt from a slow computation. And xanosdk init --web renders the questionnaire as a browser form, which can display a list of choices but cannot run your callback at all. Declaring the question as data means the SDK owns every way of answering it.

Precedence

The middle rank is what makes a re-ask safe. A stored answer is both the prompt’s default and the non-interactive fallback, so re-running the questionnaire never quietly resets a directory the user chose back to what you shipped. The machine output records which rank each answer came from: flag, prompt, config, or default.

The derived flag

Each question gets a long flag, from flag when you name one and from id otherwise. Booleans get a --no- counterpart, because a question whose default is true is otherwise unanswerable without a TTY. A string or choice question is answered with --flag=value and never --flag value: your module is not loaded when the arguments are parsed, so the CLI cannot know your flag takes a value and never consumes the following token. A collision is refused, not absorbed. A flag matching one the CLI already owns (--name, --force, --framework) would never reach you: the parser binds the real flag first, so the SDK acts on a value meant for your module and your module quietly records its default. Two wrong values, no message. Two modules deriving the same flag fail the same way between them. Both are refused by name, naming your module and the command that hit it. Declare an explicit flag when the derived name would read badly across several modules (--reviewable-dir rather than --dir) or would collide.

when, and what happens to a stale answer

A question when excludes is not asked and no answer is recorded for it. An unasked question has no answer, and recording one would make a later deploy act on a choice nobody made. On a re-ask that also deletes a stale key a previous run stored for it, and the deletion is reported:
A stored value the question no longer accepts (a choice you retired in an upgrade) is replaced by your default and reported, rather than offered back as the prompt default. when is evaluated against the answers so far, so a question can depend on one asked before it in your own list.

contributes

contributes is a pure function from answers to contributions. It has no knowledge of which command is running and no access to the filesystem, and it may be called several times in one run and must return the same thing for the same answers. That is what makes the reconcile idempotent. A module is configured whenever its project is reconciled, and xanosdk init is only the first of those moments: installing into a project that already exists, re-answering later, and removing all run the same pass. The correct state is a function of the project’s dependencies and its stored answers, so it can be rebuilt from scratch at any moment, however your module arrived. contributes is synchronous. An async contributes returns a Promise, which is a refusal: a pending Promise has no gitattributes and no config, which would read as the empty contribution that switches your module off.

The two slots

Contributions are additive against named slots the SDK owns. You return lines and a config object; the SDK composes them into the files it maintains. You never hand back a whole file, never rewrite a script string, and never touch the disk yourself. That constraint is what makes two installed modules safe together, and it moves the failure earlier: a malformed contribution is refused where it was written rather than producing a broken file the user meets later. Your lines land in a block naming your package and stamped with your version:
An empty contribution is meaningful. Returning no gitattributes lines removes your block rather than leaving an empty one, which is how a module whose answers turned it off stops applying rules the user declined. Returning nothing at all is the SDK’s signal that your module reported itself switched off, and it is recorded as { "enabled": false }.

What you may not contribute

A .gitattributes rule is refused when it:
  • matches *. That applies to every path in the repository, and a module able to set it from an add-on install could renormalize line endings repo-wide on the next checkout.
  • sets text or eol, including a bare -text. .gitattributes is last-match-wins, so an appended * -text or *.gen eol=crlf silently defeats the * text=auto eol=lf rule the scaffold sets so a byte comparison cannot fail over a contributor’s core.autocrlf.
  • spells a xanosdk block marker. Blocks are located by counting markers, so one forged line leaves a file no run can balance, including another package’s block.
Contribute display rules (linguist-*, diff) for the paths you own instead. Each check runs per physical line, because the composer splits every entry on newlines before writing it: ["docs/*.md linguist-documentation\n* binary"] would otherwise pass and then land * binary on every path in the repository. A config that is not a plain object is refused too. The reconciler writes it into the "xanosdk" block, and the reader refuses any entry that is not an object, so one run would author a manifest that every later reconcile then refuses to read. It must also survive JSON.stringify, so no BigInt and no cycles.

answersFromConfig

The inverse of contributes: read your stored config block back into the answers that produced it. contributes is a module-private transform. Only you know that { enabled: true, dir: "xanoscript" } came from { xanoscript: true, dir: "xanoscript" }. The SDK will not guess, and matching question ids to same-named config keys would be a convention this contract does not state. Declare it whenever your config keys are not your question ids. Without it, a re-ask offers your shipped defaults and, non-interactively, answers with them, silently resetting a directory the user chose. A module that omits it is left as it is rather than re-derived, the run reports it as carried, and reinstall tells the user what happened before the prompts start. It is the one hook that can never take a command down. Keys matching no declared question are ignored, and a throw is reported and degrades to your defaults.

onBundle

Fires on export and every deploy (--to included), after the bundle compiles and before the network call. That placement is deliberate: a tree describes the source, so writing it before the deploy means a failed deploy still leaves a correct tree, and a tree never claims something shipped that did not. Plugins run in sequence rather than concurrently. They write to the user’s tree and to the terminal, and interleaved output from two modules writing adjacent directories is harder to read than it is slow. entry is optional rather than a made-up path so a plugin may decline the --bundle <path> branch, where the input is already-serialized text with no entry and no registry behind it. A tree rendered from a bundle the project did not compile describes something other than the source beside it. Under frozen a plugin must compare and report, never write. It is also the mode in which a plugin failing to load is fatal rather than a warning.

Env values are blanked before you see them

payload.env entries keep their name and carry value: "". A hook’s output is typically committed, and the real values come from a gitignored file, so handing them to a renderer would be handing them to git. Your obvious implementation, rendering the payload faithfully, would put production credentials into a repository and nothing in the contract would have warned you. The declarations survive because they are structure; the secrets do not.

onPreflight

Fires on xanosdk preflight, contributing a comparison to the report. The SDK owns the fetch and nothing else. Every derivation past that point is yours: you have both payloads, so you render both local sides and compute your own exclusions, and no rendering knowledge stays behind in the SDK. engineRendering is tagged because the difference is load-bearing. A missing text route must report as unavailable, never as drift. Treating a failed fetch as an empty rendering would diff every object in the workspace against nothing and report a catastrophic mismatch that is really a network error. Both payloads are blanked identically, so a comparison between them is unaffected. A comparison against engineRendering should exclude env values, which the engine has no reason to blank. A project whose plugins declare no onPreflight never pays for the engine’s rendering at all, so declaring the hook is what turns that fetch on.

routesManifest

Your module’s section of the route manifest, xano/routes.gen.ts. It runs wherever that file is written: xanosdk routes ./xano/index.ts --emit xano/routes.gen.ts (the scaffold’s xano:routes), pull, generate, and the refresh marketplace install, reinstall and remove make, so your block lands with your module. inputs is a validator-neutral description (type RouteInputs in @xano/sdk/plugin, not the generated map of the same name): each input’s name, type, required, nullable, list bounds, enum values, methods, and children. A database link is one dbLink entry carrying the linked table’s expanded columns, to spread into the surrounding object; columns is undefined when the payload does not carry that table, which is not “no columns”. Walk this rather than the payload: it is the same description the SDK renders its own RouteInputs, ChannelInputs and MessageInputs types from, so a validator built from it cannot disagree with them. Where the section lands. The SDK composes the file: the merged imports of every module at the top, then the core sections, then each module’s source in a marked block, blocks ordered by package name:
Your source is in the same file as the core sections, so it names RouteInputs, ChannelInputs, MessageInputs and MessageName directly, without importing them; all four are always emitted, as {} when empty. Anything you export is exported from routes.gen.ts. Your names share one scope with the core sections and every other module’s, so a collision fails the user’s typecheck rather than being renamed. A blank source writes no block, and its imports are dropped with it: that is how a config that turns the section off removes it. Imports are declared, never written. The core sections import nothing, which is what keeps the file free of the SDK runtime in a frontend bundle. Declare yours in imports; never put an import line in source. Each from must be a bare package name: @xano/sdk and its subpaths, a relative or absolute path, and a protocol (node:) are all refused, naming your module. Each name must be a plain identifier, with no as rename. A package you import must resolve from the user’s project, so declare it in your peerDependencies: xanosdk marketplace install adds each peer other than @xano/sdk that the project does not already declare as a direct dependency, with your range, through the project’s package manager. Synchronous and pure. No filesystem, network, clock or randomness: the same context must return the same section every time. Every writer of the file must produce the same bytes for the same workspace and installed modules, or routes --emit --strict passes or fails depending on which command last wrote it. A Promise, or any value that is not { imports?, source }, is refused, and source must not spell a block marker (// xanosdk:begin, // xanosdk:end). A throw is your module failing, not the manifest. The core sections are still refreshed, your previous block is kept exactly as it was, and a warning names your module. On routes --emit --strict it is fatal instead, because a check that did not run must not read as one that passed.

HookResult: failed is not a throw

This distinction is the whole reason the field exists.
  • A thrown error is a broken plugin. It could not do its job. On export and deploy the command warns and carries on, and under --frozen-lock it is fatal: a check that did not run must not read as a check that passed. A throw from onPreflight is never fatal, because preflight is itself a check and its own report is worth printing, but it does fail the gate for the same reason.
  • failed: true is a working plugin reporting that what it checked does not pass. A committed tree that no longer matches the source, say. It is never swallowed and reaches the command’s exit code on every run.
Collapsing the two would break both directions: a bug in a plugin would fail a deploy that is fine, and a real staleness finding would be swallowed as a plugin bug. preflight maps failed onto the same non-zero exit code it uses for its own findings, which is what lets CI keep gating on it. Set failed only when your plugin ran correctly and the answer is no.

Contract skew is refused, never absorbed

Because every hook is optional, a module written against a shape of this contract the running SDK does not have would load, register, and contribute nothing, silently, since “a module with no contributions” is a legitimate outcome. So the SDK detects each known skew structurally and refuses it by name, with the upgrade to make. The peer-range row is the one that catches the skew in the other direction — your module newer than the SDK running it. Nothing about the plugin object can reveal that: the check that would recognize a hook name you have not yet invented would have to ship in the SDK that predates it. Your declared range is the only fact both sides hold, which is why it is read rather than trusted to npm’s install-time warning — that warning is routinely bypassed by --legacy-peer-deps, by pnpm and yarn peer handling, and by an SDK downgraded after the install. It is also the only version check that reaches a reconcile: sdkVersion is handed to onBundle and onPreflight only, so a guard of your own inside a hook first fires on the next export or deploy, well after the marketplace install that quietly wrote nothing. Someone pinned to an older SDK is never locked out by this: "enabled": false is read before the check runs, and marketplace remove uninstalls before it reconciles. The installers ask the same question, so a module of yours that cannot run on the SDK in front of it never gets left on disk waiting to be refused: marketplace install undoes an install it just made, reinstall refuses without touching the module you already had, and init --marketplace reports it and takes it back off. See Peer ranges. The last row in the table above is the pattern: silently dropping a contributed CI step would be a guard that stops running, and a wrong return type would switch a working module off and keep it off while marketplace remove refused it for being deliberately disabled. Neither failure would ever reach you.

What a throwing hook costs

A module that cannot describe its contributions loses its contributions, not the command. contributes is third-party code running after an install has already written to the target directory; letting it take the run down would leave a half-populated project every re-run then refuses. Your module is reported and every byte it owns is left exactly as it was, including its config, whose presence is what tells the next run the questionnaire already completed. A refusal (the rows in the table above) does propagate. Those are deterministic, so re-running cannot fix them, and carrying on would ship a project that does not do what your module says it does.

Testing your module

The SDK’s own suite drives a generated fixture plugin through the real load path rather than mocking it, which is the shape worth copying: write your plugin into a temp project’s node_modules with a real package.json, and run the CLI against it. Things worth proving about your own module:
  • contributes returns the same thing for the same answers, called twice.
  • answersFromConfig(contributes(a).config) round-trips back to a for every answer set.
  • Your onBundle writes nothing when ctx.frozen is true.
  • Your onBundle handles ctx.entry === undefined.
  • Your hooks never read payload.env values, because they are blanked.
  • routesManifest returns the same section for the same context, called twice, and what it writes typechecks beside the core sections of a real routes.gen.ts.

Publishing

Publish to npm like any package. To appear in xanosdk marketplace list and search, the module also needs a catalogue entry, which carries its title, tagline, description, tags, the objects it installs, what the user supplies, the registration snippet, and the agent prompt details --prompt emits. Write that prompt for a machine: it is what init --marketplace and marketplace install point at whenever they cannot wire your module themselves. Nothing about the marketplace is required to use a module. Any package declaring a "xanosdk" field works from a file: path, a tarball, a git spec or a private registry, and the CLI passes the specifier to npm exactly as typed.
Generated from @xano/sdk 1.0.9.