---
title: Build from source
path: guides/build-from-source
status: published
---

# Building ScaiFlux as a standalone binary

ScaiFlux ships as a single PyInstaller-bundled executable so end
users don't need Python, pip, or a virtualenv on their machine — just
a compatible libc and they can run `./scaiflux`. This document
describes the toolchain that builds that binary from source.

For end users who *do* have Python, prefer `pip install` / `pipx
install` of the wheel — it's smaller, faster to install, and easier
to upgrade. The standalone binary is for the no-Python case.

## TL;DR

```bash
# One-time prereq (Debian/Ubuntu — see "Prerequisites" below for
# other distros). Needed because PyInstaller bundles the Python
# shared library and many distros ship Python without it.
sudo apt install libpython3.13

# Build:
./scripts/build_dist.sh

# Output:
ls dist/
#   scaiflux                            ← raw build output
#   scaiflux-0.1.0-linux-x86_64         ← versioned + platform-tagged copy
#   scaiflux-0.1.0-linux-x86_64.sha256  ← integrity check

# Use:
./dist/scaiflux --version
./dist/scaiflux repl
```

## Prerequisites

* **Python 3.11+** on the build host.
* **The Python shared library** (`libpython3.X.so.1.0`). Distros vary:

  | Distro | Install command |
  |--------|-----------------|
  | Debian / Ubuntu | `sudo apt install libpython3.13` (substitute your version) |
  | Fedora / RHEL | `sudo dnf install python3-libs` |
  | Arch | included by default in the `python` package |
  | macOS | use the python.org installer, or `pyenv install <ver>` with `PYTHON_CONFIGURE_OPTS="--enable-shared"` |

  The build script detects when this is missing and prints the
  same hint instead of letting PyInstaller fail 80 seconds in.

* **`curl`** and **`tar`** (for one-time `patchelf` download — see
  "Portability via staticx" below). Both are present on virtually
  every Linux install.

* No other prereqs. The script creates an isolated build venv at
  `.build-venv/`, installs ScaiFlux + PyInstaller + staticx into it,
  caches a static `patchelf` binary at `.build-tools/`, and never
  touches the user's main environment. No `sudo`, no Docker.

## What the build script does

`scripts/build_dist.sh`:

1. Optionally cleans `build/`, `dist/`, `.build-venv/`, `.build-tools/`
   (`--clean`).
2. Creates an isolated build venv (skip with `--no-venv`).
3. `pip install`s the project (minus dev extras), PyInstaller, and
   staticx into the venv.
4. Checks for the Python shared library and aborts with a clear hint
   if missing.
5. One-time: downloads `patchelf 0.18+` to `.build-tools/` (needed by
   staticx; the pip-packaged `patchelf` ships 0.17 which has a bug
   that fires on modern-glibc PyInstaller binaries).
6. Runs `pyinstaller --clean --noconfirm packaging/scaiflux.spec`.
7. **Runs `staticx` on the PyInstaller output** to bundle every
   shared library dependency (libpython, libc, libm, …) into the
   binary itself — eliminates glibc-version sensitivity. See
   **Portability via staticx** below. Skip with `--no-portable` if
   you want a dynamic-only binary for some reason.
8. Smoke-tests the resulting binary:
   ```bash
   ./dist/scaiflux --version
   ./dist/scaiflux --help
   ./dist/scaiflux {setup,serve,doctor} --help
   ```
9. Stamps a versioned + platform-tagged copy, gzips it, and writes a
   `.sha256` next to both raw and gzipped artifacts.

A typical successful build takes 60–120 seconds on a warm cache,
4–6 minutes from cold. The resulting binary lives in
`dist/scaiflux-<version>-<os>-<arch>` and is in the 25–30 MB range
(stripped + statically linked).

## Portability via staticx

Without staticx, a PyInstaller binary built on Debian 13 (glibc 2.41)
fails on Debian 12 (glibc 2.36) with:

```text
Failed to load Python shared library '/tmp/_MEI.../libpython3.13.so.1.0':
/lib/x86_64-linux-gnu/libm.so.6: version 'GLIBC_2.38' not found
```

Symbol versions in glibc go forward, not backward — a binary built
against newer glibc can't load on older glibc.

[staticx](https://github.com/JonathonReinhart/staticx) solves this by
running over the finished PyInstaller binary and:

* Reading its dynamic dependencies via `ldd`.
* Bundling every `.so` (including `libpython`, `libm`, `libc`,
  `libpthread`, etc.) into a custom archive appended to the
  executable.
* Patching the ELF (via `patchelf`) so the binary uses an embedded
  loader from staticx's own runtime instead of the system's
  `/lib64/ld-linux-x86-64.so.2`.

The result is verified via `ldd dist/scaiflux` reporting
`not a dynamic executable`. The binary runs on **any** Linux x86_64
regardless of distro vintage.

Cost: about 1 MB of binary growth and a few seconds of build time.

If for some reason you want the dynamic build (smaller, but
host-glibc-locked):

```bash
./scripts/build_dist.sh --no-portable
```

The raw PyInstaller output is preserved as `dist/scaiflux.dynamic`
regardless of `--no-portable`, so you can always compare.

## What's in the spec file

`packaging/scaiflux.spec` deserves a quick tour because PyInstaller
isn't drop-in for projects that use dynamic imports.

* **Entry script**: `packaging/scaiflux_entrypoint.py` — a tiny shim
  that calls `scaiflux.cli:main()`. PyInstaller can't analyse a
  `[project.scripts]` entry point declaration, so we hand it a real
  `.py` file.

* **`hiddenimports`**: ScaiFlux loads tools, slash commands, and
  CLI subcommands by name. PyInstaller's static analyser can't
  follow those, so each module is enumerated explicitly. The spec
  uses `collect_submodules()` for big trees (`api`, `auth`,
  `runtime`, `telemetry`, `plugins`, `agents`, `skills`) and a hand
  list for `tools` / `commands` / `subcommands` (small enough to keep
  visible; safer than catching too much).

* **`datas`**: bundles the two non-Python resources ScaiFlux reads
  at runtime — `scaiflux/schemas/scaiflux.schema.json` (the
  `.scaiflux.json` JSON-schema) and `scaiflux/cli/working_verbs.txt`
  (the indicator's verb pool). Both loaders resolve adjacent to
  `__file__`, which PyInstaller arranges correctly inside the bundle.

* **`collect_all` for `pydantic_core`, `h2`, `hpack`, `hyperframe`,
  `httpcore`** — these have C extensions or non-Python data files
  that PyInstaller needs help finding.

* **`excludes`**: `mcp`, `fastapi`, `uvicorn`, `pytest`, etc. —
  optional / dev-only deps that would otherwise bloat the binary
  meaningfully. Users who need the MCP integration can install the
  wheel instead; the standalone binary's tool catalog is the
  built-in surface only.

* **`onefile=True`**: produces a single executable rather than a
  directory. Costs a few hundred ms of self-extraction on first run
  per process; almost always worth it for distribution simplicity.

* **`upx=False`**: UPX shaves ~30 % off the binary size at the cost
  of measurably slower startup. For an interactive CLI we'd rather
  pay the disk than the perceived latency.

## Limits of the bundled binary

The frozen binary is **not** a one-to-one substitute for `pip install
scaiflux` in every dimension:

* **No entry-point plugin discovery.** Plugins registered via
  `[project.entry-points."scaiflux.tools"]` (or `commands` / `plugins`)
  on the *target* machine can't be picked up — there's no `pip
  install` path inside the binary. The frozen surface is whatever
  was compiled into `scaiflux.spec`. Plugin authors should ship the
  wheel instead.

* **No MCP client.** `mcp` is excluded to keep the binary slim; if
  you need MCP, install the wheel with `pip install scaiflux[mcp]`.

* **OS / arch lock-in.** A Linux x86_64 binary won't run on ARM64,
  macOS, or Windows. Build per-arch / per-OS. (Within Linux x86_64
  the staticx wrapping above makes the build host's glibc irrelevant.)

* **First-run extraction.** PyInstaller's one-file mode extracts
  the bundle to `$TMPDIR` on first invocation, which adds 200–500 ms
  to startup. Subsequent invocations reuse the extraction.

## Re-building after source changes

The build script doesn't watch for changes — re-run it after edits.
Use `--clean` to wipe caches if PyInstaller misbehaves; otherwise
incremental builds are fine and meaningfully faster (~30 s vs 90 s).

## CI considerations

To build in CI:

```yaml
# Example for GitHub Actions / Linux x86_64
- run: sudo apt-get update && sudo apt-get install -y libpython3.13
- run: ./scripts/build_dist.sh
- uses: actions/upload-artifact@v4
  with:
    name: scaiflux-${{ runner.os }}-${{ runner.arch }}
    path: dist/scaiflux-*
```

For cross-OS distribution, run the script in a matrix job — one per
target OS. PyInstaller cannot cross-compile; macOS binaries must be
built on macOS, Windows on Windows, etc. Note: staticx is
Linux-only — the macOS / Windows paths produce a regular dynamic
binary and may need their own portability story (macOS binaries are
generally less version-sensitive thanks to system-wide framework
loading; Windows binaries usually need MSVC redistributables on the
target).
