> ## Documentation Index
> Fetch the complete documentation index at: https://xanots.docs.xano.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Signing in & deploying

> Authentication, ephemeral environments, static hosting, releasing to production, and validating against a live instance.

## Signing in & deploying (in depth)

**Sign in once** with `xanots login`. It runs the standard authorization-code + PKCE
browser flow (powered by the OpenID-certified [`openid-client`](https://github.com/panva/openid-client)):
opens your browser, you approve, and the CLI captures the redirect on a `127.0.0.1`
callback. On first use it dynamically registers its own OAuth client (RFC 7591) so the
authorize step never depends on the server tolerating an arbitrary loopback port; the
registration is cached in `~/.xano/xanots-clients.json`. The instance you're bound to is
read from the token's own `aud` claim.

`login` also **pins the numeric workspace** you consented to into the credential, so every
later command acts on exactly that workspace without looking it up again. There is **no
`--workspace` flag** — a credential addresses exactly one instance and one workspace. Run
`xanots workspace details` to see which.

The credential caches by default in a **shared** `~/.xanots/auth.json`, reusable
from **any** project directory — so a single `xanots login` covers all your projects.
Override the OAuth host with `--origin`/`$XANO_ORIGIN` and the loopback port with `--port`.

**When the browser can't reach this machine** — a remote shell, a container, a
Codespace — the `127.0.0.1` redirect lands nowhere and `login` waits five minutes for a
callback that will never arrive. Run `xanots login --paste` instead. It binds nothing:
it prints the authorize URL, you finish signing in in whatever browser you have, and
your browser then fails to load the `127.0.0.1` callback — which is expected. Paste that
URL back from the address bar (or just the `code=` value out of it) and the sign-in
completes. Everything else is identical, including the instance and workspace you pick
at consent.

Two things follow from how the code is bound. The paste has to happen in the same run
that printed the URL, so `--paste` needs a terminal — for automation use the environment
credential instead (see [Environment variables](/reference/environment)). And `--port` still
matters under `--paste`, not because anything binds it, but because it is part of the
redirect URL's identity.

Note this is **not** what `XANO_NO_BROWSER` does: that only suppresses the browser
launch, and still needs the redirect to arrive here.

Running `login` again when a usable credential is already cached prints what you're
signed in to and stops, rather than spending another browser round trip — pass `--force`
to sign in anyway. That guard is also what keeps `login` from silently replacing a
hand-authored `type: "token"` credential (below).

**Credential formats.** `auth.json` holds one credential, discriminated by `type`:

```jsonc theme={null}
// type: "oauth" — written by `xanots login`. Do not hand-edit.
{ "type": "oauth", "instance": "https://your-instance.xano.io", "workspace_id": 3, /* …tokens… */ }
```

```jsonc theme={null}
// type: "token" — WRITE THIS YOURSELF. A meta API bearer token for the same
// meta APIs, for automation. No login flow, no refresh, no rotation.
{
  "type": "token",
  "instance_base_url": "https://your-instance.xano.io",
  "workspace_id": 3,
  "meta_api_token": "your-meta-api-token"
}
```

Both formats work at **either** location (project-local `./.xano/auth.json` or global
`~/.xanots/auth.json`) on the same precedence ladder below, and both determine the same
thing: one instance, one workspace. A `token` credential is never created, refreshed, or
revoked by the CLI — `xanots logout` just deletes the file, and the token itself stays
valid until you revoke it wherever you minted it. Save it with owner-only permissions
(`chmod 600`) and keep it out of git.

> **Upgrading:** this format is a break. An `auth.json` written before it is rejected with a
> message naming the fix — run `xanots login` again.

**Project-local credentials** — pass `--local` to `login` to cache tokens in a
**project-local** `./.xano/auth.json` instead (which `login` **auto-adds to `.gitignore`**),
scoping the sign-in to that directory. Every command that **reads** credentials
(`deploy`/`details`, `whoami`, token refresh) resolves them **project-local first, global
as a fallback**: it uses `./.xano/auth.json` when present, otherwise `~/.xanots/auth.json` —
so a `--local` project keeps working without repeating the flag. `login` and `logout` do
**not** fall back: they target the shared global cache unless you pass `--local`. An explicit
`--config`/`$XANO_CONFIG` always wins over everything.

`deploy <file>` runs the exact same pipeline as `export` (including `xano.lock`
seeding), then create-or-refreshes the target environment and imports the compiled
workspace into it as a full replace. `deploy --bundle <path>` skips the compile and uploads a
bundle a previous `export` wrote (handy in CI). A **projected, secret-free summary** prints to stdout
as JSON — `baseUrl` plus the workspace `id`/`name`, and the static URL (plus a `verified`
boolean reporting whether the frontend was confirmed live) when `--static` is used — while the
human-readable progress (and the live URLs) echoes to stderr. The raw
workspace blob is deliberately never dumped: it carries per-tenant secrets that must not land
in shell history or CI logs.

**Where it goes** — the instance your **token is bound to** (the token's `aud`), never a
flag. `xanots deploy` create-or-refreshes an **ephemeral**, which is the only environment it
writes to; production promotion is the separate `xanots release` path to your main instance
workspace, which merges rather than replacing.

**Static host** — `deploy --static <dir>` archives a directory and deploys it to a
static host after the backend import. The frontend lands **on the ephemeral itself**, so the
backend and the frontend live in one environment. The CLI uploads the archive to
`/api:meta/workspace/{id}/static_host/default/build` with your ordinary bearer. That route
auto-creates the `default` host and **auto-deploys to `dev`**, returning the live URL — so
the static step is independent of the backend deploy (the backend still runs first because
it's the primary action).

**Liveness verification** — the build endpoint returning `200` only means the archive was
*accepted*; the edge may still be starting a cold host (which `503`s for tens of seconds) or
briefly routing the previous build. So after the upload the CLI polls the deployed URL until
the static server reports it is serving **this** build — its `X-Xano-Canonical` response header
matches the canonical returned for the build just pushed — then prints `Frontend is live`. It
polls every second for the first 30s, then every two seconds out to 120s. An unconfirmed poll
is a **warning, not a failure** (the build uploaded fine and usually comes online moments
later; the exit code stays `0` and the summary records `"verified": false`). Verification is
skipped when the response carries no canonical to compare against. Pass **`--skip-liveness`** to
skip the wait entirely — useful for fast iterative deploys or when the deployed URL isn't
reachable from the machine running the CLI.

**Config injection** — before archiving, the deploy rewrites EVERY `.html` document in the
build, inserting an inline `<script>` at the top of `<head>` that assigns each config value to a
`window.<KEY>` global (so it runs before the app bundle). The backend URL is seeded
automatically as `window.XANO_HOST` (from the backend deploy's own response), and
`--static-env KEY=VALUE` (repeatable) merges in extra keys, overriding the seed on a name
clash. A static host has no server runtime — it serves these files verbatim — so injected
values are **public**: base URLs and *publishable* keys only, never secrets (those go in
backend env, read via `env(name)`). Rewriting every document, not just the root, is what a
prerendered build needs: it serves a different document per route, so a root-only injection
leaves every deep link and refresh running with the global unset — and the page still renders.
Injection is skipped (reported as a warning, not a failure) when the archive has no document
with a `<head>` to anchor to, and any individual document that lacks one is named; values are
`<`-escaped so one containing `</script>` can't break out of the element. This is why a
prebuilt `frontend/dist` can retarget any environment with no rebuild.

> **Caching — verify with a cache buster.** The static host serves `index.html` with
> `Cache-Control: public, max-age=3600`, so a browser (or CDN) that loaded the page before
> your latest deploy can hold the old HTML — including a *pre-injection* `<script>`-less
> version — for up to an hour. If `window.XANO_HOST` looks missing, it's almost always this:
> hard-reload (Cmd/Ctrl+Shift+R) or open DevTools with "Disable cache" checked. When
> verifying from a script or agent, append a throwaway query param so you never read a cached
> copy — `curl -s "$URL/?nocache=$(date +%s)"` — and check the fetched HTML for the injected
> `window.XANO_HOST` line rather than retrying the same cached URL.

`xanots status` prints the same **environment base URL** out of band, for cases where you'd
rather bake it in at build time — along with the environment's name and expiry, without your
having to know either. (`xanots whoami` prints the *instance* base URL, i.e. the account's
origin rather than the environment.) A static failure after a committed backend deploy **does not roll
back**: it exits with code `3` and a resumable message telling you to re-run with `--static`
to retry just that step.

`deploy` reuses cached tokens and **refreshes them automatically** when the access token
expires (Xano rotates the refresh token on every use; the new one is persisted). A rejected
refresh (`invalid_grant`) clears the stale cache and tells you to `xanots login` again.

**CI & agents** run non-interactively. The credential to reach for is the **meta credential
as three environment variables** — the `type: "token"` record above, with no file:

```bash theme={null}
XANO_INSTANCE_URL=https://your-instance.xano.io \
XANO_WORKSPACE_ID=3 \
XANO_META_TOKEN=your-meta-api-token \
  npx xanots deploy ./xano/index.ts
```

Nothing is read from or written to disk, and nothing rotates, so the same three secrets keep
working run after run — which is what makes this the right shape for a CI job. It **outranks
every other credential**, including an explicit `--config` path and `$XANO_REFRESH_TOKEN`;
whichever it displaces is named on stderr, so it never wins silently.

All three are required **together**. Setting some but not others is a hard error naming the
rest, rather than a quiet fallback to another credential — a workflow with one misspelled
secret must not deploy against whatever happens to be on the runner.

> **Automated agents:** do **not** invoke `xanots login` — it blocks on interactive browser
> consent. Use the three variables above.
>
> The older `$XANO_REFRESH_TOKEN` + `$XANO_CLIENT_ID` pair still works (both copied once
> from `auth.json` after a local `xanots login`; the target instance is read from the refresh
> token's `aud` and the workspace resolved per run), but Xano **rotates refresh tokens on
> use** — a stored one is spent by its first exchange, so a job that runs twice fails the
> second time. Prefer the meta credential.

## Checking against a live instance (xanots preflight)

`xanots preflight` proves your compiled output against a **real, running Xano
instance** — not a static snapshot. It compiles your workspace, imports it into a
**fresh ephemeral environment created for that run**, exports it back, and diffs it
against what you compiled, so you catch three classes of problem a local build can't:

1. **Import accepts** — the engine actually accepts the bundle (malformed-but-shaped output is rejected here).
2. **Round-trip parity** — the workspace the engine stores, re-exported in the same bundle format, matches your compiled JSON after normalization (full object logic included). Every authored kind is diffed — tables, functions, queries, triggers, tasks, and more — each object matched by identity and reported per kind.
3. **Runtime** (`--runtime`) — each deployed function actually runs on the engine, with logs surfaced on failure.

It talks only to public meta API routes — the **same** archive import `xanots
deploy` uses, plus the workspace export — and never touches XanoScript. There is one
way into an instance, so a transport bug is one `preflight` reproduces rather than
routes around.

It is non-destructive: nothing you own is written to. Each run creates its own
ephemeral environment, imports into that, and deletes it afterwards — including when
the import is rejected or a transport error is thrown. The environment carries a
short expiry, so even a killed process leaves nothing permanent behind. A fresh
environment per run is also what makes the diff trustworthy: the objects read back
can only have come from this bundle, never from what a previous run left.

**Setup** — copy `.env.example` to `.env` (gitignored) and fill in a base URL +
token. Switching between a cloud dev instance and a local Docker one is just a
different `XANO_VALIDATE_INSTANCE`:

```bash theme={null}
# .env
XANO_VALIDATE_INSTANCE=https://your-instance.xano.io   # or http://localhost:8080 for local Docker
XANO_VALIDATE_TOKEN=your-meta-bearer-token
# XANO_VALIDATE_WORKSPACE_ID=…                          # optional; PARENT workspace the run's env is created under (default 1)
```

```bash theme={null}
xanots preflight ./xano/index.ts                      # import + round-trip diff, reports per object (every authored kind)
xanots preflight ./xano/index.ts --runtime            # + run each deployed function
xanots preflight ./xano/index.ts --capture            # + write fetched JSON to ./validate-out (fixture candidates)
xanots preflight ./xano/index.ts --instance http://localhost:8080   # override the target for one run
xanots preflight --bundle ws.json                     # check an already-exported bundle
```

Config comes from the environment (a `.env` is autoloaded; a real env var wins),
`--instance` overrides per run, and the token is env-only — never a flag. This
harness is deliberately separate from the `auth.json` credential the rest of the
CLI uses. A non-zero exit means a check failed; `--verbose` prints full diffs and raw
engine detail instead of a projected summary.

## Wiring the frontend to the backend

**Wiring the frontend to the backend.** The deploy bakes the environment's backend URL into
every HTML document in your build automatically, as a `window.XANO_HOST` global evaluated
*before* your app bundle — every document, so a prerendered build's deep links and refreshes
boot with the same backend the root does. So read it at runtime with a build-time fallback and you never have to
know the URL ahead of time:

```ts theme={null}
const HOST = (typeof window !== "undefined" && window.XANO_HOST) || import.meta.env.VITE_XANO_HOST;
```

`window.XANO_HOST` is the **environment** URL your deployed APIs answer at (the same value
`xanots status` prints as the environment's URL); it is *not* `xanots whoami`, which prints
your account's instance origin. Because injection happens at deploy time, a prebuilt
`frontend/dist` retargets any environment 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 as `window.<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 via `env(name)`.

**Showing a stored file.** A file column comes back as `{ path, name, type, size, meta,
access, url }`. Don't use its `url`: on a tenant-scoped environment that field addresses the
instance host *without* the `/tenant/<name>` segment and 404s — as a broken `<img>`, while
every assertion about the response still passes. Build the URL from `path` and the host you
already have:

```ts theme={null}
import { fileUrl } from "@xanots/sdk";

<img src={fileUrl(row.avatar, HOST) ?? ""} />   // null for an absent file
```

**Verifying the injection:** the served `index.html` writes the global in **bracket
notation** — `window["XANO_HOST"]="…";` — so grep for the bare token `XANO_HOST`, not the
exact string `window.XANO_HOST` (the dot form is valid to *read* the global in your app,

## Deploy targets, and what a release changes

**Two targets**, so the dev loop and the production step stay distinct:

| Command          | Where it goes                                                                                                                                                                     |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `xanots deploy`  | A disposable **ephemeral** environment — create-or-refreshed each run, auto-expiring, with its own URL. There is no other destination and no flag to choose one.                  |
| `xanots release` | Your **main Xano instance** workspace — the production target. **Merges** by default: objects are updated in place or added, and your table data is never touched unless you ask. |
| `xanots test`    | Nothing — it only reads. Runs the tests an already-deployed environment carries; `--env` picks which one, `workspace` included.                                                   |

Not every instance has ephemeral environments enabled. Where they are off, `deploy` says so
and names who can turn them on — there is no second destination to fall back to. `xanots
export` and `xanots preflight` still work meanwhile.

Every `deploy` is a **full replace** of the disposable environment — always fresh, no
merge mode, no flags to get wrong. A `release` is the opposite by design: it changes what
your code defines and leaves the rest of the workspace — including every row in every
table — alone.

| `release` flag | What it does to your workspace                                                                          |
| -------------- | ------------------------------------------------------------------------------------------------------- |
| *(none)*       | Adds new objects, updates existing ones. Nothing is deleted, no rows are written.                       |
| `--dry-run`    | Prints the plan and exits without sending anything.                                                     |
| `--prune`      | Also deletes objects **this project released** and no longer defines. Requires `xano.lock` — see below. |
| `--reset-data` | Empties every table the bundle carries.                                                                 |
| `--seed`       | Writes the bundle's table rows. Combine with `--reset-data` to reset **and** re-seed.                   |
| `--replace`    | The disposable-environment behavior: wipe the workspace and import in its place.                        |

**An unchanged project is a no-op.** Before importing, the release compares the bundle it is
about to send against the workspace it is about to send it to, object by object. When every
object is already there, nothing is sent: the plan prints `no changes`, the JSON summary
reports `"upToDate": true` with `"operations": 0`, and no `updated_at` moves. That makes
`release` usable as a reconcile step — safe to run on a schedule, in CI on every merge, or
behind a "make production match main" button.

The comparison is deliberately one-sided: a false "changed" costs one import that was
happening anyway, while a false "unchanged" would silently skip a real release. So anything
it cannot prove equal counts as changed, and it is skipped entirely for `--replace` (which
mints fresh identities) and for `--seed`/`--reset-data` (which write table rows, about which
the comparison knows nothing). Under `--prune`, an object the workspace holds and the project
no longer defines is work to do, so that is not a no-op either.

Anything destructive is **previewed first** — the CLI fetches the plan, prints what would
change, and asks. `--yes` skips the prompt for CI but never skips the preview. Start with
`xanots release ./xano/index.ts --dry-run` to see the plan without committing to it.

> ⚠️ `--prune` removes tables this project released and no longer defines, and a removed
> table takes its rows with it — no flag prevents that. The preview reports it explicitly;
> read it before confirming.

**`--prune` is scoped to what this project released**, and `xano.lock` is what defines that
scope: it holds an entry for every object the project has ever exported. A planned deletion
with no lock entry is an object the project never created — a table built in the UI, another
team's API group — and the release is refused rather than deleting it. A prune with no lock
at all is refused too: without one there is no record of what belongs to the project, so
every deletion would be a guess. To delete something that is genuinely yours to remove,
import its identities first (`xanots lock import`). Releasing a pre-exported `--bundle` carries no entry
file to find a lock beside, so name it with `--lock=<path>`.

**A dropped column is destructive, and does not need a flag to happen.** Removing a column
from a table schema and releasing destroys the column and every value in it. The server's
plan calls that a routine in-place update, so the release compares your schema against the
live workspace, names each column that would be dropped, and asks before doing it — on an
ordinary release, with no destructive flag passed.

**Environment variables are add-only on a release.** A merge creates keys that do not exist
yet and leaves existing ones as they are, so changing a value in code and releasing will not
change it on the workspace. The preview names any key it will decline to update. To change
one, set it on the workspace directly, or use `--replace` (which rebuilds the workspace).

A merge matches objects by the stable identity your project assigns them, so it only
recognizes a workspace it has released to before. Releasing into one built by hand — or
populated by `--replace`, which assigns its own — matches nothing: every object is a
create, and with `--prune` the workspace is emptied and rebuilt rather than updated. The
preview says so in as many words when it happens. To adopt objects that are already there,
pin their `guid` on the matching defs first. 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:

```bash theme={null}
XANO_REFRESH_TOKEN=… XANO_CLIENT_ID=… npx @xanots/sdk 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 — but anything
