"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 aregister* 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:
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 writesregisterX(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 ahomepage 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
flag, prompt, config, or default.
The derived flag
Each question gets a long flag, fromflag 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:
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:
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
textoreol, including a bare-text..gitattributesis last-match-wins, so an appended* -textor*.gen eol=crlfsilently defeats the* text=auto eol=lfrule the scaffold sets so a byte comparison cannot fail over a contributor’score.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.
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
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
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
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
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:
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
- A thrown error is a broken plugin. It could not do its job. On
exportanddeploythe command warns and carries on, and under--frozen-lockit is fatal: a check that did not run must not read as a check that passed. A throw fromonPreflightis never fatal, becausepreflightis itself a check and its own report is worth printing, but it does fail the gate for the same reason. failed: trueis 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.
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’snode_modules with a real package.json, and run the CLI against it.
Things worth proving about your own module:
contributesreturns the same thing for the same answers, called twice.answersFromConfig(contributes(a).config)round-trips back toafor every answer set.- Your
onBundlewrites nothing whenctx.frozenis true. - Your
onBundlehandlesctx.entry === undefined. - Your hooks never read
payload.envvalues, because they are blanked. routesManifestreturns the same section for the same context, called twice, and what it writes typechecks beside the core sections of a realroutes.gen.ts.
Publishing
Publish to npm like any package. To appear inxanosdk 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.