diff --git a/README.md b/README.md
index d49f60f53..49f2e8a18 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,15 @@ NVIDIA NeMo Safe Synthesizer creates private, safe versions of sensitive tabular
## Quick Start
+
+
+
+
+
+
+
+Try it without installing anything: the launchable above deploys a GPU instance with NeMo Safe Synthesizer and the tutorial notebooks preinstalled. Note that the instance bills continuously and cannot be paused -- delete it when you are finished.
+
Read detailed usage below, or jump to the documentation with [Getting Started](https://nvidia-nemo.github.io/Safe-Synthesizer/user-guide/getting-started/) or the [Safe Synthesizer 101](https://nvidia-nemo.github.io/Safe-Synthesizer/tutorials/safe-synthesizer-101/) notebook.
diff --git a/docs/index.md b/docs/index.md
index cc0b36f1a..6880c771d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,6 +17,21 @@ NeMo Safe Synthesizer creates private, safe versions of sensitive tabular datase
- Comprehensive evaluation -- Privacy and quality metrics in an in-depth HTML report
- Flexible interfaces -- CLI for scripting, Python SDK for programmatic workflows, YAML configuration
+## Try It Without Installing
+
+[](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17)
+[](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17)
+
+Deploys an NVIDIA GPU instance with NeMo Safe Synthesizer and the tutorial notebooks
+already installed, so there is no CUDA, driver, or Python setup to do. The
+[Safe Synthesizer 101](tutorials/safe-synthesizer-101.ipynb) notebook runs end to end in
+about 15 minutes.
+
+!!! warning "The instance bills continuously"
+ Most GPU providers on Brev do not support stopping an instance. Billing runs from
+ creation until deletion, so delete the instance when you are finished and download
+ anything you want to keep first.
+
!!! info "System Requirements"
NeMo Safe Synthesizer requires a Linux machine with an NVIDIA GPU (A100 80GB+ recommended) and CUDA 12.9+ to run the training and generation pipeline. macOS, Windows, and Apple Silicon are not supported for pipeline execution. A CPU-only install is available for development and configuration validation -- see [Getting Started](user-guide/getting-started.md#install-the-package).
diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md
index 9d481e756..126354691 100644
--- a/docs/tutorials/index.md
+++ b/docs/tutorials/index.md
@@ -5,6 +5,17 @@
Interactive Jupyter notebook tutorials for NeMo Safe Synthesizer.
+[](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17)
+[](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17)
+
+These notebooks need a Linux machine with an NVIDIA GPU. The launchable above provides
+one with Safe Synthesizer and all three notebooks already installed.
+
+!!! warning "The instance bills continuously"
+ Most GPU providers on Brev do not support stopping an instance. Billing runs from
+ creation until deletion, so delete the instance when you are finished and download
+ anything you want to keep first.
+
## Available Tutorials
- [Safe Synthesizer 101](safe-synthesizer-101.ipynb) -- learn the fundamentals
diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md
index 7fc4aea49..cf900d36b 100644
--- a/docs/user-guide/getting-started.md
+++ b/docs/user-guide/getting-started.md
@@ -26,6 +26,14 @@ does at each stage.
### Install the Package
+!!! tip "Skip installation entirely"
+ [](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17)
+ [](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17)
+
+ Deploys a GPU instance with everything below already done, plus the tutorial
+ notebooks. Useful for evaluating Safe Synthesizer without a local NVIDIA GPU. The
+ instance bills continuously and cannot be paused -- delete it when you are finished.
+
The CUDA and CPU extras depend on packages (PyTorch, FlashInfer) hosted on
indexes outside PyPI. You must pass the extra index URLs shown below.
diff --git a/script/brev/README.md b/script/brev/README.md
new file mode 100644
index 000000000..084df2f88
--- /dev/null
+++ b/script/brev/README.md
@@ -0,0 +1,190 @@
+
+
+
+### NeMo Safe Synthesizer Brev Launchable
+
+This directory holds the provisioning script and recorded console settings for the
+[NVIDIA Brev](https://brev.nvidia.com) Launchable, a one-click GPU environment for trying
+NeMo Safe Synthesizer without setting up CUDA, drivers, or Python locally.
+
+Nothing in this directory is executed by the repo or by CI. A Brev Launchable is
+configured in the Brev web console, and the setup script is pasted into a form field
+there. This directory exists so that configuration is versioned and reviewable rather
+than living only in a browser. When you change `setup.sh`, you must also paste the new
+contents into the console for the change to take effect.
+
+### Files
+
+- `setup.sh`: Pasted into the Launchable's Setup Script field. Installs the CUDA
+ build of Safe Synthesizer into a dedicated venv, registers it as the default Jupyter
+ kernel, and drops the tutorial notebooks in `$HOME`.
+- `welcome.md`: Becomes the customer's `$HOME/README.md`. Fetched at provisioning
+ time from the same tarball as the tutorials, not baked into `setup.sh` -- it would
+ otherwise consume a tenth of the 16 KiB script budget.
+
+### Console configuration
+
+Recreate the Launchable at → Launchables → Create
+Launchable with these settings.
+
+| Section | Field | Value |
+| ------- | ----- | ----- |
+| Software | Runtime mode | VM Mode |
+| Software | Install Jupyter on the host | Enabled |
+| Software | Run a Setup Script | Enabled, contents of `setup.sh` |
+| Software | Image ID | Leave blank |
+| Source | Code source | No code files (`setup.sh` downloads the tutorials itself) |
+| Hardware | GPU | 1× 80 GiB VRAM, single GPU |
+| Hardware | Disk | 200 GiB or more -- not resizable after creation |
+| Network | Ports | 8888, named `jupyter` |
+| Access | Visibility | Anyone with the link |
+
+Multi-GPU instances are pointless here: training is single-GPU only, so a second GPU
+adds cost and no capability.
+
+### Launch parameters
+
+All optional. Per Brev's guidance, do not put credential values in parameter defaults.
+
+| Name | Type | Purpose |
+| ---- | ---- | ------- |
+| `NSS_INFERENCE_KEY` | Text | NVIDIA NIM key for column classification. Without it, classification runs in degraded mode. |
+| `HF_TOKEN` | Text | Only needed for gated Hugging Face models. |
+
+Brev passes these to `setup.sh` as environment variables.
+
+Deliberately kept to two. A dataset-URL parameter and a model-prewarm toggle were both
+tried and removed: a customer can fetch a URL with one `!curl` line in a notebook, and
+prewarming moves the model download earlier without making it shorter, while hiding the
+progress bar the notebook would otherwise show. Every parameter is visible to every
+deployer, so the bar for adding one is high.
+
+### What the customer gets
+
+`$HOME` is the JupyterLab file browser root, so only what a customer needs is visible
+there. Everything operational is a dotfile, which the browser hides by default.
+
+```text
+$HOME/
+ tutorials/ the three tutorial notebooks and their datasets
+ README.md where to start, rendered on double-click
+ (SETUP-IN-PROGRESS.md until setup finishes)
+
+ .nss-venv/ cu129 venv, registered as the default kernel
+ .cache/huggingface/ model cache (Hugging Face's default location)
+ .nss-env.sh PATH and VIRTUAL_ENV, sourced from .bashrc
+ .nss-setup.log full provisioning log
+```
+
+The script deliberately does not create a data folder. Customers put their files
+wherever they like and pass the path to `with_data_source()`. Bring-your-own-data
+options, in rough order of convenience: drag and drop into the JupyterLab file browser;
+`!curl -O ` in a notebook cell; or `brev shell` plus `scp` for large files.
+
+### Implementation notes
+
+These are the non-obvious constraints the script works around. They were each found the
+hard way on a real instance.
+
+- Package indexes are derived at runtime, not hardcoded. Index URLs are
+ install-time configuration rather than wheel metadata, so the installer must supply
+ them -- and they have to match the release being installed, not this repo's `main`.
+ The script resolves the latest version from the PyPI JSON API, fetches that tag's
+ `pyproject.toml`, and reads the CUDA index URLs out of it, then pins the install to
+ that exact version so the two cannot drift. Selection is keyed on the URL containing
+ `cu129`, not on the index name: the flashinfer entry was renamed
+ `flashinfer-jit-cache` → `flashinfer-jit-cache-cu129` between 0.1.8 and 0.1.9, so
+ names are not stable across releases. The parse runs inside a process substitution and
+ therefore cannot fail the script, so the count of discovered indexes is what validates
+ it.
+- uv is installed from a checksum-verified tarball, not `curl | sh`. The
+ `astral.sh/install.sh` path logs `no checksums to verify`, so nothing validated what
+ it downloaded. The script fetches the pinned release tarball, compares it against the
+ published `.sha256`, and installs the binaries -- matching the GPG-verified mise
+ install in `tools/install-mise.sh`.
+- A placeholder file marks the home directory while setup runs. JupyterLab
+ accepts connections well before this script finishes, so a user who opens it early
+ would otherwise see an empty or half-populated file browser and assume the Launchable
+ is broken. `SETUP-IN-PROGRESS.md` is written before any slow work, rewritten by the
+ `ERR` trap if provisioning fails, and replaced by `README.md` on success.
+- The welcome text lives in `welcome.md`, not a heredoc. It is pulled from the
+ same tarball as the tutorials, so the two always match, and it is staged as a dotfile
+ until the final step so it never appears while setup is still running. The fetch is
+ non-fatal: `script/brev/` exists in no released tag, so it resolves only from the
+ `main` fallback until a release includes it.
+- The setup script has a 16 KiB limit. Brev rejects anything larger, which is why
+ the script carries short comments pointing here rather than full explanations. Check
+ `wc -c script/brev/setup.sh` before pasting.
+- The script runs unprivileged. Brev executes it as the instance user, not root, so
+ `/usr/local/bin`, `/var/log`, and `/etc/profile.d` are all unwritable. Everything
+ installs under `$HOME`.
+- The venv is deliberately separate from Brev's. Installing `[cu129,engine]` into
+ `$HOME/.venv` would resolve torch, vllm, and flashinfer alongside jupyterlab's own
+ pins. If that breaks the notebook server, the customer's only interface is gone and
+ there is no error they can act on. A separate venv keeps a failed install recoverable.
+- The instance username varies by provider -- `shadeform` on the Shadeform-brokered
+ offerings, likely `ubuntu` on direct ones. The script never names an account; it uses
+ `$HOME` and `id -un`.
+- The image ships its own `$HOME/.venv` on Python 3.12, which is what the Brev
+ Jupyter server runs in. `uv` discovers it by walking up from the working directory, so
+ the script pins `VIRTUAL_ENV` in both the kernelspec and `.nss-env.sh` to keep
+ `uv pip install` from silently targeting it. Do not delete or upgrade that venv --
+ breaking it breaks the notebook server, which is the customer's only interface.
+- The kernel is registered as `python3`, not a custom name, because the tutorial
+ notebooks declare `kernelspec.name = "python3"`. Any other name would force the
+ customer to switch kernels by hand.
+- Kernel precedence is counterintuitive. `jupyter_core` puts the server's own
+ environment *ahead* of `~/.local/share/jupyter` whenever the server runs inside a
+ virtualenv (`prefer_environment_over_user()`), and Brev's does. Registering only at
+ user scope is silently shadowed by Brev's 3.12 kernel. The script asks the server's
+ interpreter for `jupyter_path("kernels")[0]` and writes there, backing up any existing
+ `kernel.json` to `kernel.json.orig`.
+- The kernelspec holds secrets, so it stays mode 0600 inside `$HOME`. An earlier
+ version also mirrored it to `/usr/local/share/jupyter` at 0644 via sudo, which would
+ have put `NSS_INFERENCE_KEY` and `HF_TOKEN` in a world-readable file. Do not
+ reintroduce that -- it also could not win precedence over a venv-scoped kernel anyway.
+- Most providers do not support stop/start. The instance bills from creation until
+ deletion, with no pause. Nebius was the exception at the time of writing.
+- Tutorials arrive via tarball, not `git clone`. `--strip-components` lands them flat
+ in `tutorials/` rather than the repo's `docs/tutorials/` nesting, and the image is not
+ required to have `git`. The tarball is pinned to the tag matching the installed wheel,
+ falling back to `main`, so the notebooks can never call APIs newer than the package
+ they run against.
+- The tar member is an exact path, not a glob. GNU tar does not enable wildcards for
+ member names by default, and bsdtar rejects `--wildcards` outright, so no single glob
+ form works on both. The archive's top-level directory name also varies by ref
+ (`Safe-Synthesizer-0.1.8` for a tag, `Safe-Synthesizer-main` for a branch), so the
+ script reads it from the archive rather than guessing.
+- Never pipe `tar -t` into `head`. Closing the pipe early makes GNU tar fail with
+ `stdout: write error` on SIGPIPE, and `pipefail` turns that into a failed run. bsdtar
+ exits silently on SIGPIPE, so this passes on macOS and fails on the instance. The
+ script reads the listing into a variable and takes the prefix with `${listing%%/*}`.
+
+### Verification
+
+After deploying, check `$HOME/.nss-setup.log` for the `setup complete` banner, then in a
+notebook confirm the environment resolved correctly:
+
+```python
+import sys
+print(sys.executable) # expect $HOME/.nss-venv/bin/python
+```
+
+That must hold without touching the kernel picker -- the customer should never have
+to switch kernels. If it reports a 3.12 path instead, kernel registration landed in a
+directory the server does not consult; check the `registered kernel at ...` lines in the
+log against `jupyter kernelspec list`.
+
+Then run `tutorials/safe-synthesizer-101.ipynb` end to end -- roughly 15 minutes on
+an A100 or H100.
+
+### Tested configurations
+
+| Date | Provider | GPU | Result |
+| ---- | -------- | --- | ------ |
+| 2026-07-29 | Shadeform-brokered | H100 PCIe 80 GiB | uv install, venv creation, `[cu129,engine]` install, tutorial fetch |
+
+Add a row when you validate another provider or GPU class. The A100-or-larger figure in
+the main docs looks conservative for the default 3B model; if a 48 GiB card proves
+sufficient, record it here and correct the requirement in `README.md`,
+`docs/index.md`, and `docs/user-guide/getting-started.md`.
diff --git a/script/brev/setup.sh b/script/brev/setup.sh
new file mode 100755
index 000000000..a123e5cf7
--- /dev/null
+++ b/script/brev/setup.sh
@@ -0,0 +1,373 @@
+#!/bin/bash
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# setup.sh -- provision a Brev VM-Mode Launchable for NeMo Safe Synthesizer.
+# Paste into the Launchable's Setup Script field. Runs once, unprivileged,
+# after Jupyter is installed; everything lands under $HOME. Idempotent.
+#
+# Launch parameters, passed by Brev as environment variables:
+# NSS_INFERENCE_KEY NIM key for column classification (degraded without it)
+# HF_TOKEN only for gated Hugging Face models
+#
+# script/brev/README.md explains every non-obvious step below. Each one exists
+# because a deploy failed without it -- read it before simplifying anything.
+set -euo pipefail
+
+readonly UV_VERSION="0.9.30"
+readonly PYTHON_VERSION="3.13"
+# No .git suffix: this is only used to build archive URLs, not to clone.
+readonly REPO_URL="https://github.com/NVIDIA-NeMo/Safe-Synthesizer"
+
+: "${HOME:?HOME is not set}"
+
+# $HOME is the file browser root: only tutorials/ and README.md are visible.
+readonly TUTORIALS_DIR="${HOME}/tutorials"
+readonly README_FILE="${HOME}/README.md"
+readonly WAIT_FILE="${HOME}/SETUP-IN-PROGRESS.md"
+readonly WELCOME_STAGED="${HOME}/.nss-welcome.md"
+
+readonly BIN_DIR="${HOME}/.local/bin"
+readonly VENV_DIR="${HOME}/.nss-venv"
+readonly USER_KERNEL_DIR="${HOME}/.local/share/jupyter/kernels/python3"
+readonly ENV_FILE="${HOME}/.nss-env.sh"
+readonly LOG_FILE="${HOME}/.nss-setup.log"
+
+readonly CUDA_EXTRA="cu129"
+
+mkdir -p "${BIN_DIR}"
+exec > >(tee -a "${LOG_FILE}") 2>&1
+
+log() { printf '\n=== [nss-setup] %s\n' "$*"; }
+fail() {
+ printf '\n!!! [nss-setup] FAILED at line %s\n' "$1" >&2
+ # Truthful, rather than leaving the wait notice up forever.
+ cat >"${WAIT_FILE}" <"${WAIT_FILE}" <<'EOF'
+# Setting up -- please wait
+
+NeMo Safe Synthesizer is still installing -- roughly 5-10 minutes from when
+the instance started. Files appear as it progresses, so a partly-filled file
+browser is expected. Nothing here is ready to run yet.
+
+When setup finishes, this file is replaced by README.md. Refresh to check.
+EOF
+
+export PATH="${BIN_DIR}:${PATH}"
+
+log "user=$(id -un) home=${HOME}"
+
+# Preflight: this Launchable only makes sense on an NVIDIA GPU instance.
+
+if command -v nvidia-smi >/dev/null 2>&1; then
+ nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader || true
+else
+ log "WARNING: nvidia-smi not found. Training and generation will not work."
+fi
+
+# uv -- installed into ~/.local/bin, which needs no privileges.
+
+if [[ "$(uv --version 2>/dev/null | awk '{print $2}')" != "${UV_VERSION}" ]]; then
+ log "installing uv ${UV_VERSION} into ${BIN_DIR}"
+ # Verify the published SHA-256; astral's installer verifies nothing.
+ case "$(uname -m)" in
+ x86_64) uv_target="x86_64-unknown-linux-gnu" ;;
+ aarch64 | arm64) uv_target="aarch64-unknown-linux-gnu" ;;
+ *)
+ log "ERROR: unsupported architecture $(uname -m)"
+ exit 1
+ ;;
+ esac
+ uv_dir="$(mktemp -d)"
+ uv_url="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${uv_target}.tar.gz"
+ curl -fsSL "${uv_url}" -o "${uv_dir}/uv.tar.gz"
+ uv_want="$(curl -fsSL "${uv_url}.sha256" | awk '{print $1}')"
+ uv_got="$(sha256sum "${uv_dir}/uv.tar.gz" | awk '{print $1}')"
+ if [[ "${uv_want}" != "${uv_got}" ]]; then
+ log "ERROR: uv checksum mismatch (want ${uv_want}, got ${uv_got})"
+ exit 1
+ fi
+ tar -xzf "${uv_dir}/uv.tar.gz" -C "${uv_dir}" --strip-components=1
+ install -m 0755 "${uv_dir}/uv" "${uv_dir}/uvx" "${BIN_DIR}/"
+ rm -rf "${uv_dir}"
+else
+ log "uv ${UV_VERSION} already present"
+fi
+
+# The cu129 wheels are large and their indexes are not always fast.
+export UV_HTTP_TIMEOUT=300
+
+# Venv kept separate from Jupyter's, so a failed install cannot break it.
+
+if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
+ log "creating venv at ${VENV_DIR} (python ${PYTHON_VERSION})"
+ uv venv --python "${PYTHON_VERSION}" "${VENV_DIR}"
+else
+ log "venv already present at ${VENV_DIR}"
+fi
+
+if "${VENV_DIR}/bin/safe-synthesizer" --version >/dev/null 2>&1; then
+ log "safe-synthesizer already installed; skipping package install"
+else
+ NSS_VERSION="$(curl -fsSL https://pypi.org/pypi/nemo-safe-synthesizer/json \
+ | "${VENV_DIR}/bin/python" -c 'import json, sys; print(json.load(sys.stdin)["info"]["version"])')"
+
+ # Indexes come from the installed release's pyproject. Match both generated
+ # names and URLs because static index names do not enforce the CUDA suffix.
+ pyproject="$(mktemp)"
+ curl -fsSL "${REPO_URL}/raw/v${NSS_VERSION}/pyproject.toml" -o "${pyproject}"
+ index_args=()
+ index_count=0
+ while IFS= read -r url; do
+ [[ -n "${url}" ]] || continue
+ index_args+=(--index "${url}")
+ index_count=$((index_count + 1))
+ log "index: ${url}"
+ done < <(env CUDA_EXTRA="${CUDA_EXTRA}" "${VENV_DIR}/bin/python" - "${pyproject}" <<'PY'
+import os
+import sys
+import tomllib
+
+with open(sys.argv[1], "rb") as handle:
+ indexes = tomllib.load(handle)["tool"]["uv"]["index"]
+
+cuda_extra = os.environ["CUDA_EXTRA"]
+print(
+ "\n".join(
+ index["url"]
+ for index in indexes
+ if index["name"].endswith(f"-{cuda_extra}")
+ or f"/{cuda_extra}" in index["url"]
+ )
+)
+PY
+ )
+ rm -f "${pyproject}"
+
+ # The parse cannot fail the script from a process substitution; count validates.
+ if [[ "${index_count}" -lt 3 ]]; then
+ log "ERROR: expected 3+ ${CUDA_EXTRA} indexes in v${NSS_VERSION}, got ${index_count}"
+ exit 1
+ fi
+
+ log "installing nemo-safe-synthesizer ${NSS_VERSION} -- this takes several minutes"
+ VIRTUAL_ENV="${VENV_DIR}" uv pip install \
+ "nemo-safe-synthesizer[${CUDA_EXTRA},engine]==${NSS_VERSION}" \
+ "${index_args[@]}" \
+ --index-strategy unsafe-best-match
+fi
+
+# Checked separately, or a rerun skips it and the kernel cannot start.
+if ! "${VENV_DIR}/bin/python" -c "import ipykernel, ipywidgets" 2>/dev/null; then
+ log "installing notebook support"
+ VIRTUAL_ENV="${VENV_DIR}" uv pip install ipykernel ipywidgets
+fi
+
+# Tutorials: tarball extract pinned to the installed release. See README.
+
+if [[ -f "${TUTORIALS_DIR}/.fetched" ]]; then
+ log "tutorials already present"
+else
+ # Set above if this run installed; read off the package if the install ran previously.
+ NSS_VERSION="${NSS_VERSION:-$("${VENV_DIR}/bin/python" -c \
+ 'from importlib.metadata import version; print(version("nemo-safe-synthesizer"))')}"
+ log "fetching tutorials for version ${NSS_VERSION}"
+
+ # Staged under $HOME: same-filesystem rename, and never half-visible.
+ tarball_dir="$(mktemp -d "${HOME}/.nss-fetch.XXXXXX")"
+ tarball="${tarball_dir}/src.tar.gz"
+ stage="${tarball_dir}/stage"
+ fetched=0
+
+ for ref in "refs/tags/v${NSS_VERSION}" "refs/heads/main"; do
+ if ! curl -fsSL "${REPO_URL}/archive/${ref}.tar.gz" -o "${tarball}"; then
+ log "WARNING: ${ref} not downloadable; trying next ref"
+ continue
+ fi
+
+ # Exact member path, and no pipe to head: GNU/BSD tar traps. See README.
+ listing="$(tar -tzf "${tarball}")"
+ top="${listing%%/*}"
+ rm -rf "${stage}"
+ mkdir -p "${stage}"
+ if tar -xzf "${tarball}" -C "${stage}" --strip-components=3 \
+ "${top}/docs/tutorials"; then
+ rm -rf "${TUTORIALS_DIR}"
+ mv "${stage}" "${TUTORIALS_DIR}"
+ # Written last; the guard keys on this, so partial runs are redone.
+ : >"${TUTORIALS_DIR}/.fetched"
+ log "tutorials extracted from ${ref}"
+ # Same tarball as the tutorials. Non-fatal -- see README.
+ if tar -xzf "${tarball}" -C "${tarball_dir}" --strip-components=3 \
+ "${top}/script/brev/welcome.md" 2>/dev/null; then
+ mv "${tarball_dir}/welcome.md" "${WELCOME_STAGED}"
+ else
+ log "WARNING: welcome.md not present in ${ref}"
+ fi
+ fetched=1
+ break
+ fi
+ log "WARNING: no tutorials in ${ref}; trying next ref"
+ done
+
+ rm -rf "${tarball_dir}"
+ if [[ "${fetched}" -ne 1 ]]; then
+ log "ERROR: could not fetch tutorial notebooks"
+ exit 1
+ fi
+fi
+
+# Environment for terminals; the kernelspec below covers notebooks.
+
+log "writing ${ENV_FILE}"
+{
+ echo "# Managed by setup.sh -- do not edit."
+ echo "export PATH=\"${VENV_DIR}/bin:${BIN_DIR}:\${PATH}\""
+ # Pin VIRTUAL_ENV or uv walks up and finds Brev's own ~/.venv instead.
+ echo "export VIRTUAL_ENV=\"${VENV_DIR}\""
+ if [[ -n "${NSS_INFERENCE_KEY:-}" ]]; then
+ printf 'export NSS_INFERENCE_KEY=%q\n' "${NSS_INFERENCE_KEY}"
+ fi
+ if [[ -n "${HF_TOKEN:-}" ]]; then
+ printf 'export HF_TOKEN=%q\n' "${HF_TOKEN}"
+ printf 'export HUGGING_FACE_HUB_TOKEN=%q\n' "${HF_TOKEN}"
+ fi
+} >"${ENV_FILE}"
+chmod 0600 "${ENV_FILE}"
+
+if ! grep -qF "${ENV_FILE}" "${HOME}/.bashrc" 2>/dev/null; then
+ printf '\n[ -f %s ] && . %s\n' "${ENV_FILE}" "${ENV_FILE}" >>"${HOME}/.bashrc"
+fi
+
+# Kernel registered as `python3` (the name the notebooks declare), in the
+# directory the server actually consults -- not the user one. See README.
+
+log "building kernelspec"
+KERNEL_JSON="$(mktemp)"
+# `env`, because bash rejects prefix assignments to readonly variables.
+env VENV_DIR="${VENV_DIR}" BIN_DIR="${BIN_DIR}" \
+ "${VENV_DIR}/bin/python" - "${KERNEL_JSON}" <<'PY'
+import json
+import os
+import sys
+
+venv = os.environ["VENV_DIR"]
+env = {
+ "PATH": f"{venv}/bin:{os.environ['BIN_DIR']}:/usr/local/bin:/usr/bin:/bin",
+ # Keeps `!uv pip install ...` in a notebook from resolving to the Brev
+ # image's own ~/.venv, which uv would otherwise discover by walking up.
+ "VIRTUAL_ENV": venv,
+}
+# Secrets live in the kernelspec because the Jupyter server is not launched
+# from a login shell. The VM is single-tenant and the file is mode 0600.
+for key in ("NSS_INFERENCE_KEY", "HF_TOKEN"):
+ value = os.environ.get(key)
+ if value:
+ env[key] = value
+if "HF_TOKEN" in env:
+ env["HUGGING_FACE_HUB_TOKEN"] = env["HF_TOKEN"]
+
+spec = {
+ "argv": [f"{venv}/bin/python", "-m", "ipykernel_launcher", "-f", "{connection_file}"],
+ "display_name": "Safe Synthesizer",
+ "language": "python",
+ "env": env,
+}
+with open(sys.argv[1], "w", encoding="utf-8") as handle:
+ json.dump(spec, handle, indent=2)
+PY
+
+# Find the interpreter the Jupyter server runs on, so we can ask it -- rather
+# than guess -- where its highest-precedence kernels directory is.
+JUPYTER_PY=""
+if command -v jupyter >/dev/null 2>&1; then
+ candidate="$(dirname "$(command -v jupyter)")/python"
+ if [[ -x "${candidate}" ]]; then
+ JUPYTER_PY="${candidate}"
+ fi
+fi
+if [[ -z "${JUPYTER_PY}" && -x "${HOME}/.venv/bin/python" ]]; then
+ JUPYTER_PY="${HOME}/.venv/bin/python"
+fi
+
+kernel_targets=("${USER_KERNEL_DIR}")
+if [[ -n "${JUPYTER_PY}" ]]; then
+ primary="$("${JUPYTER_PY}" -c \
+ 'from jupyter_core.paths import jupyter_path; print(jupyter_path("kernels")[0])' \
+ 2>/dev/null || true)"
+ if [[ -n "${primary}" ]]; then
+ kernel_targets=("${primary}/python3" "${kernel_targets[@]}")
+ fi
+else
+ log "WARNING: could not locate the Jupyter interpreter; kernel may not be picked up"
+fi
+
+# First writable target wins. Writing both would leave a duplicate python3
+# kernelspec that never gets consulted and only confuses `kernelspec list`.
+registered=0
+for target in "${kernel_targets[@]}"; do
+ # `mkdir -p` succeeds on an existing directory even without write permission,
+ # so test writability separately -- otherwise an unwritable first target
+ # reaches `cp`, and `set -e` aborts the run instead of trying the next one.
+ mkdir -p "${target}" 2>/dev/null || { log "WARNING: cannot create ${target}"; continue; }
+ if [[ ! -w "${target}" ]]; then
+ log "WARNING: ${target} is not writable"
+ continue
+ fi
+ # Keep whatever was there, once, so the original kernel stays recoverable.
+ if [[ -f "${target}/kernel.json" && ! -f "${target}/kernel.json.orig" ]]; then
+ cp "${target}/kernel.json" "${target}/kernel.json.orig" 2>/dev/null || true
+ fi
+ cp "${KERNEL_JSON}" "${target}/kernel.json" 2>/dev/null || {
+ log "WARNING: cannot write ${target}/kernel.json"
+ continue
+ }
+ chmod 0600 "${target}/kernel.json"
+ log "registered kernel at ${target}"
+ registered=1
+ break
+done
+rm -f "${KERNEL_JSON}"
+if [[ "${registered}" -ne 1 ]]; then
+ log "WARNING: kernel not registered; notebooks may open on the wrong Python"
+fi
+
+# Smoke check -- fail provisioning loudly rather than handing over a broken VM.
+
+log "verifying install"
+"${VENV_DIR}/bin/safe-synthesizer" --version
+"${VENV_DIR}/bin/python" \
+ -c "import torch; print('cuda available:', torch.cuda.is_available())"
+
+# Hand over: swap the "please wait" file for the welcome text.
+
+if [[ -f "${WELCOME_STAGED}" ]]; then
+ mv "${WELCOME_STAGED}" "${README_FILE}"
+else
+ log "WARNING: no welcome.md staged; skipping ${README_FILE}"
+fi
+rm -f "${WAIT_FILE}"
+
+trap - ERR
+log "setup complete"
+cat <
+
+
+# NeMo Safe Synthesizer
+
+Create private, safe versions of sensitive tabular data -- entirely synthetic
+records with no one-to-one mapping back to your originals.
+
+Everything is installed and ready. Nothing to set up.
+
+## Start here
+
+Open `tutorials/safe-synthesizer-101.ipynb` and run the cells top to bottom.
+It takes about 15 minutes and walks through the full pipeline on a sample
+dataset.
+
+The other two notebooks go deeper:
+
+- `tutorials/differential-privacy.ipynb` -- formal privacy guarantees (~1 hour)
+- `tutorials/time-series-financial-transactions.ipynb` -- sequential data (~20 minutes)
+
+## Using your own data
+
+Upload a CSV anywhere you like -- drag and drop into the file browser on the
+left -- and point the notebook at it:
+
+```python
+from nemo_safe_synthesizer import SafeSynthesizer
+
+results = SafeSynthesizer().with_data_source("my-data.csv").run()
+```
+
+There is no required location for your files. Put them wherever suits you.
+
+## Good to know
+
+- Notebooks already run on the right Python. You should never need to change
+ the kernel; if you do switch it, pick "Safe Synthesizer".
+- Model weights download on first use, so the first run is slower than later
+ ones.
+- This instance bills continuously and cannot be paused. Delete it when you are
+ finished, and download anything you want to keep first.
+- Provisioning log: `.nss-setup.log` (hidden files are off by default in the
+ file browser).
+
+## Learn more
+
+- Documentation:
+- Source: