Installing spark
Start at section 1 without a machine, at section 2 with one. The rest is the runbook, in the order you will need it.
1. A machine from zero
About ten minutes, most of it one 4.7 GB download; sudo once.
Debian:
- Get Debian 13 (trixie), the small installer image, from https://www.debian.org/CD/netinst/ (
amd64for a PC,arm64for an ARM board). Write it to a USB stick and boot it. - In the installer, leave the root password empty: that puts your user in the
sudogroup. At *Software selection* keep SSH server and standard system utilities; a desktop is optional. - Log in and install what spark needs:
sudo apt-get update && sudo apt-get install -y git curl python3
- Continue at section 2.
Arch:
- Get the ISO from https://archlinux.org/download/, write it to a USB stick and boot it.
- Run
archinstall. The answers that matter: the *Minimal* profile; a user account marked superuser (that is thesudo); *systemd-boot* as the bootloader (GRUB works too);git curl python opensshunder additional packages; "copy the ISO's" network configuration to keep the Wi-Fi you joined withiwctl; one HTTPS mirror as a custom server (https://geo.mirror.pkgbuild.com/$repo/os/$arch), because a router that inspects HTTP turns a mirror intoinvalid or corrupted database (PGP signature). A box that will not join the Wi-Fi:docs/TROUBLESHOOTING.md(one Wi-Fi daemon per card, then the logs). - Reboot, log in,
sudo pacman -Syuonce, then section 2.
macOS: any Mac Apple still updates. xcode-select --install brings git, curl and Apple's python3 (3.9 is enough). Continue at section 2.
Windows: spark runs in WSL 2, where Ubuntu is Linux to it. In PowerShell, wsl --install -d Ubuntu-24.04, reboot when asked, open Ubuntu, then Debian's step 3 and section 2.
2. Install spark
- Check the four things spark needs:
sudoonce for the package manager,git,curl,python33.9 or newer.for c in sudo git curl python3; do command -v "$c" >/dev/null || echo "missing: $c" done python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' \ || echo "python3 is older than 3.9"Silence means you are ready. Otherwise:- Debian 13 / Ubuntu 24.04 or newer: `sudo apt-get install -y git
. Nosudoat all:su -c 'apt-get install -y sudo && usermod -aG sudo YOURNAME'`, then log out and in.- Arch Linux, or what says
ID_LIKE=arch: `sudo pacman -S --needed
. Neverpacman -Syalone:sudo pacman -Syu` first when a package cannot be found.- macOS:
xcode-select --install. - Your login shell must be bash 4+ or zsh: the prompt widget lives in
- One line:
curl -fsSL https://github.com/forgewright-ai/spark/releases/latest/download/get | sh
It clones spark to~/.spark, lands on the newest release and runsspark setup. To read it first:curl -fsSLO https://github.com/forgewright-ai/spark/releases/latest/download/get; sh get. Withwget:wget -qO- URL | sh. By hand, the same two steps:git clone https://github.com/forgewright-ai/spark.git ~/.spark ~/.spark/bin/spark setup
- When it finishes:
exec $SHELL # the prompt widget goes live spark check # every row green
What setup does. It asks three things and never more: this machine's name (the short hostname by default), yours (your login), and the model (the row this machine earns is marked *; section 4 says how). Then:
- writes
~/.config/spark/site.env(0600); - on Linux,
sudo -vonce when a package is missing (libgomp1on Debian, plus the Mesa Vulkan packages with a GPU; Arch has the library inbase, so a bare Arch asks only with a GPU); nothing on macOS; - runs
bootstrap.sh: the engine (one pinned llama.cpp tarball for this OS, sha256-verified), the model with curl's progress bar, the token, the prompt widget, one rc line, the units (the server, the page, a 5-minute check timer); - brings the server up and waits for it;
- asks
? how big is this dirfor you and prints the tok/s it measured; - prints the three things to try.
It paints nothing: the machine looks as it did. It is re-runnable. Flags: --yes takes every default (implied when stdin is not a terminal); --model NAME|auto|none, --name, --user, --theme, --no-serve pre-answer, as do SITE_NAME, SITE_USER, SITE_AI_MODEL and SITE_THEME in the environment. get itself checks the ground first (the command line tools on macOS, apt-get or pacman, git, python3 >= 3.9) and refuses with the install line when one is missing; it never runs sudo. SPARK_HOME moves the clone, SPARK_URL points it elsewhere, SPARK_REF=main follows development; sh get --clone-only stops after the clone.
The rc line. bootstrap appends exactly one line to your login shell's rc file (~/.bashrc for bash, ~/.zshrc for zsh), only when it is not there yet:
[ -r ~/.config/spark/hook.bash ] && . ~/.config/spark/hook.bash # spark: the AI at the prompt [[ -r ~/.config/spark/hook.zsh ]] && source ~/.config/spark/hook.zsh # spark: the AI at the prompt
The hook puts ~/.local/bin first on PATH, sources the widget, keeps a blank row above the prompt for the hint, and sources TAB completion (the verbs, then each verb's words, offline). It goes last, after fzf, because the widget wraps Enter. If the rc row says todo, the login shell cannot host the widget (another shell, or macOS's bash 3.2): chsh -s /bin/zsh, then spark setup again. A bare zsh needs your own autoload -Uz compinit && compinit in ~/.zshrc for completion.
3. Use it
- At the prompt:
? wordsorwords?, Enter, and the command lands in your line with a hint above it; Enter again runs it. A command that deletes comes back marked!-- a recursivermalso says how many files and bytes it clears.?? wordsfollows up on the last answer;Esc sasks about the line you are on;cmd 2>&1 | explainsays what went wrong.spark offgives Enter back and quiets the failure line;spark onrestores both. TAB completes the verbs and their names, offline. - When a command fails, one line appears above the next prompt:
* failed (1) -- press Esc s to ask why.Esc son the empty line puts the command back, already piped toexplain, and nothing runs until you press Enter. A secondEsc safter that proposes the corrected command in your line. When a command is not found (exit 127),Esc soffers the line that installs it -- a tool spark itself installs is named with no model call, the rest through the model. A command that deletes or destroys (rm,dd,mkfs...) is never offered a re-run -- the line says so, and? wordsstill answers about it. After the fix works,Esc soffers to keep what happened as aspark memory addfact -- edit the line, then Enter. Only a command typed on one line is offered; Ctrl-C and a no-match fromgrepordiffstay quiet. The offer lives in the one pane it happened in and is gone with it. Esc ris intent search: type what a command did in your own words, pressEsc r, and the line that ran lands in your prompt --Esc ragain cycles through the matches. Your shell hands its own history to spark; nothing reads a history file, and every candidate is one that is actually in it.Ctrl-Ris left to the shell (and to fzf). On bash,Esc r(M-r) replaces the defaultrevert-line.spark chatis a conversation at achat>prompt./helplists its verbs:/newa fresh thread,/resume [N]an older one,/clearthe screen,/lastthe last turn with its tok/s,/modelwhich one answers,/q(or Ctrl-D) ends. Ctrl-C cancels a reply and keeps the chat.spark chat --thread N [words]continues an older thread from thespark historylist (1 = newest).spark <words>streams one answer;spark @FILE wordssends a text file's first 4 kB and last 12 kB with the question. Quote words the shell would glob (a trailing?, parentheses).spark do <words>proposes one command at a time: Enter runs it,eedits it first,sskips,qquits; a step that can destroy data (asudostep counts) runs only when you typeyes. After a step, its proof -- one read-only check that it worked -- is offered the same way, and only its exit code goes back to the model, never what it printed. Each step's output (last 4 kB) goes back to the model until it says done, or after 8 steps; every step is recorded as it ran.spark askreads a plan, a draft or a decision on stdin and answers with questions about it -- at most three, one per line, and nothing else. Every line of the output is a question: a line that is not one, a question quoting words the text does not contain, and a question that could be asked of any plan are dropped before you see them, so the reply cannot state a fact the text does not hold. When nothing survives, you get one line saying so and nothing more -- a reader with nothing to ask says nothing.spark ask < plan.md spark ask what am I deciding here < plan.md
Answer one of them and it stops coming back:spark ask --answered --name plan.mdwith the question on stdin;spark ask --ledger --name plan.mdlists what you have answered,--ledger cleardrops it.spark ask -hsays the rest.spark readanswers a question from a source on stdin -- a page, a message, a document -- and from nowhere else. Every line of the answer quotes the source and the quote is checked: a line whose quotes are not in the source, or that quotes nothing, is dropped before you see it. When the source does not answer, the reply is one line showing the source's own opening words, never a guess. A source past 16000 characters is read one part at a time (--part N; the answer's first line names the part).w3m -dump https://example.com | spark read "what is this page for" spark read when does it open < page.txt
--name page.txtrecords the question asked (never sent anywhere);spark read --ledger --name page.txtlists what has been asked,--ledger cleardrops it.spark read -hsays the rest.spark drillturns a source on stdin into practice: it asks a question, you try it, then it shows the source's own words so you can say whether you had it. Both the question and the answer are spans of the source -- an answer the model invents is dropped before it is ever asked -- and a source too thin to drill is one line, never padded from the model's own knowledge.spark drill < notes.md w3m -dump https://example.com | spark drill --name page
--name NAMEkeeps a schedule: a missed item comes back on a widening interval (1, 3, 7, 21, 60 days) until you have had it right twice in a row;spark drill --ledger --name NAMEshows the schedule,--ledger cleardrops it. Without--name, a session is practice kept nowhere.spark drill -hsays the rest.spark watchreads a live stream on stdin -- a log tail, a build, a long migration -- and says nothing until a line matches what you asked for, then one line quoting it. The quote is checked against the stream, so it cannot report what is not there, and silence is the normal, healthy state. The stream never leaves this machine.tail -f app.log | spark watch "a 500 appears" journalctl -f | spark watch "anything about the disk"
spark watch -hsays the rest.spark soul editwrites the paragraph that tells the model who it is (~/.config/spark/soul, at most 4000 characters;spark soulshows which is in use,spark soul resetgoes back to the default). The default:You are spark, the AI on this machine. You run here, on hardware the user owns; nothing you are told leaves it. You are here to answer, to explain, to write, and to hand the user a command when one is what they need. Speak plainly, in the user's language. Say when you do not know. Never invent a flag, a path, or a command.
spark memory add <words>adds a fact it keeps (spark memory forget Ndrops one,spark memorylists them,spark memory offstops sending them; 40 facts of 200 characters). Soul and facts ride on every conversation, so a fact costs tokens every time: keep the ones that change answers. The model never writes them.spark history clearempties the turns and threads and never touches a fact.
4. Models
Two files:
| file | what |
|---|---|
models.env | every model spark can serve: 26 models, each with its license; line marks a row proven on the prompt line |
~/.config/spark/models.env | your own rows (spark model add URL --license), 0600; marked u |
spark model list shows every row: file size, the RAM it needs against this machine's budget (SITE_AI_BUDGET, default 60 percent of RAM plus GPU memory), the license, the proof column (line when tested on the line; a kept/run score once the grounding audition measured how faithfully it quotes a source -- those rows win an auto tie, and the earlier row of the list wins among equals), downloaded or serving, and its speed here (~N tok/s is an estimate until spark bench or a real turn measures it; too big when it does not fit). The tested rows:
| name | file | RAM |
|---|---|---|
qwen3-1-7b | 1.0 GB | 3 GB |
qwen3-4b | 2.3 GB | 5 GB |
qwen3-8b | 4.7 GB | 7 GB |
qwen3-14b | 8.4 GB | 11 GB |
qwen3-30b-a3b | 17.4 GB | 21 GB |
granite-4-2-8b | 5.0 GB | 7 GB |
The untested rows (Qwen3 4B-Thinking and Coder-30B-A3B, Qwen2.5 7B / 14B / Coder-7B, Mistral 7B and Nemo 12B, Phi-4 mini and 14B, DeepSeek-R1 distills 7B / 14B, SmolLM2 1.7B, gpt-oss-20b, all Apache-2.0 or MIT; Llama 3.2 1B / 3B, Llama 3.1 8B and Gemma 3 1B / 4B / 12B / 27B under their own terms) are yours by name. A row under a license that is not Apache-2.0 or MIT prints its license and asks download it? yes/NO: first. The page lists them all: https://spark.forgewright.ai/models/
How auto picks: every tested open-license row whose RAM fits the budget, then the largest of those whose file is under this build's speed cap (3 GB on cpu, 6 GB on vulkan, 20 GB on metal: the sizes that keep about 8 tok/s). When the cap held a bigger row back the table's header says so, and spark model NAME takes that row anyway. Nothing under the cap fits: the smallest row that fits.
spark model NAMEchooses a model: downloads and verifies it (size and sha256 from its row) and restarts the server.spark model autogoes back to the rule above;spark model rm NAMEdeletes a file not in use.spark model budget N(10-95) sets the percent and prints the table.- A
.ggufof your own in~/.local/share/spark/modelsis served withSPARK_MODEL=<file>inspark.env. spark ember NAMEadds a second, bigger model for conversations: the prompt line stays with the small one (context 4096, reasoning off, so a thinking model answers fast) and every conversation (spark <words>,chat,do, the page, any/v1client naming no model) goes to the second. One server, one port, one token; the request'smodelfield picks.spark ember autopairs the smallest tested row with the largest that fits beside it;spark ember none(the default) runs one model in both roles.spark model add URLadds your own row: a huggingface.co.../resolve/<rev>/<file>URL is verified from its redirect headers, any other URL needs--sha256 HEX;--license "NAME URL"is required. The row lands in~/.config/spark/models.env, then it is downloaded and served like any row.spark model verifyre-hashes every downloaded file, printsokorbad -- spark model rm NAME; spark model NAMEper file, and exits 1 on a mismatch; nothing is deleted for you.spark check'smodelsrow is the daily, cached version of the same check.
Speed: spark bench measures with llama-bench (pp512 / tg128) and keeps the result as the file's baseline; the throughput check row warns when real turns fall below 70 percent of it. spark bench tune tries GPU layers, flash attention, KV cache types and thread counts; spark bench tune apply writes the winner to spark.env and restarts. spark stats [--week] sums up what real turns measured. The server keeps no prompt cache in RAM (--cache-ram 0: llama-server would otherwise keep up to 8 GiB of replaced prompts in host memory); SPARK_EXTRA_ARGS=--cache-ram N in spark.env sets a budget in MiB.
5. Other machines and your phone
spark serves the same AI, with its soul, memory and threads, on one LAN address (http://<host>:8081). The admin token stays on this machine; everyone else is a named user with a token of their own.
Another machine of yours:
- Here:
spark user add NAMEmints an account; its token is shown once and never stored. - There, with spark installed:
spark client URL(the URL fromspark forge --print-clienthere), thenspark user login NAMEwith that token. spark brainthere says which server answers.
A client runs nothing of its own: no engine, no model, no units, and no account of its own -- the login is the token minted here, and until spark user login NAME there, answers are given but no thread is kept. spark check there reads na on those rows and the peer row says whether this machine answers and accepts that login. spark model there prints this machine's table; choosing a model there is refused. spark client off gives it a model of its own again.
Another OS user on this same machine (a shared workstation): the owner runs spark share on once (a spark group, one engine for everyone -- Linux). Then add the user to the group (sudo gpasswd -a NAME spark; they log in again), and they set up in userspace -- no sudo, no download:
curl -fsSL https://github.com/forgewright-ai/spark/releases/latest/download/get | sh # clones spark's code only spark setup # detects the shared engine, offers to join
spark setup sees the shared engine and joins it instead of the first-run: no model to download, no root, their own soul and memory in their own home, answering from the one engine. spark share off ends it.
Every non-human caller -- a script, an app, a CI job -- gets its own user with spark user add NAME and its own token; the admin token is this machine's and is never shared. Any program, with the OpenAI shape (a request naming no model gets the conversation model with the identity; model: spark the bare prompt model):
curl -sN http://<host>:8081/v1/chat/completions \
-H "Authorization: Bearer $YOUR_SPARK_USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"what is this machine for?"}],"stream":true}'
The page, in any browser on the LAN:
spark forge --print-urlprintshttp://<host>:8081/loginand, at a terminal, the admin token and a QR code. Scan the QR with a phone's camera and the page signs in by itself -- the token rides the link after#, which stays in the browser and never reaches the server. The QR is the token drawn as squares: show it (or a screenshot of it) only to the person it is for.- Or type a token once; the browser keeps a cookie (90 days; logging out or a server restart asks again, the admin's too). Everyone lands in the chat. A user's token is a chat app: their own threads and memory, plus their account behind the one menu button. The admin token also opens the whole machine (activity,
do, the settings, the log). Give each of the household their ownspark user add NAME-- it prints their token and their own QR to scan, once. - On a phone, add it to the home screen (iOS: share > add to home screen, and it becomes an app; Android keeps a shortcut that opens in a browser tab). The page needs this machine reachable when it opens: there is no offline copy.
spark forge alone is the status (URL, health, model, unit, users); spark forge on|off enables or disables it. spark forge token --new rotates the admin token; spark user token --new rotates a user's; that principal logs in again. spark forge audit lists the newest admin actions -- a command run from the page (its sha256 prefix and exit code, never its text), a verb run, a user added, removed or rotated -- sealed in this machine's own store.
Sealed stores: each user's threads, memory and chat history are encrypted (ChaCha20-Poly1305, written from RFC 8439) under a key wrapped by that user's token. The machine keeps a sha256 verifier and the wrap, never the token: nobody, the admin included, holds a key to another user's messages, and a lost token is lost history. There is no TLS on the LAN: the trust model is your LAN.
Headless. On the machine that stays on, spark headless on:
- Linux: the units run from boot without a login (linger), the GPU is reachable without a seat (the
rendergroup), sleep, suspend and hibernate are masked, the lid is ignored.offreverses all but linger and the group. Over a plainssh box spark model NAMEthe units are reached the same way: spark brings the user bus itself. - macOS: the three agents move to
/Library/LaunchDaemons(no auto-login; FileVault's login screen is untouched) andpmsetkeeps the machine awake. Restart and stop then needsudo launchctl(the verbs print the line).offputs the login agents back. - WSL 2 stops with its last window:
spark headless onrefuses there.
Then point every laptop (spark client URL) and every phone at it: one address, one identity, the same answers everywhere. The headless check row names any piece that is missing.
6. Per-OS notes
macOS:
- macOS asks once whether
python3may find devices on the local network: that is spark binding your LAN address. Allow it; deny it and the page answers only this machine. - The engine is the pinned llama.cpp tarball for arm64 or x64 (Metal inside), under
~/.local/share/spark/engine/; bootstrap clears its quarantine flag. If Gatekeeper still objects:xattr -dr com.apple.quarantine ~/.local/share/spark/engine. - Nothing from Homebrew.
spark font FACE SIZEsets Terminal.app's font (spark font listshows the monospace faces installed). - Units:
launchctl print gui/$UID/spark.serve(.forge,.checklikewise);launchctl kickstart -k gui/$UID/spark.serverestarts one. There is no root-free GPU counter, sostatsand thegpurow say so. Alt-sis Option-s;Escthens, quickly, is the same keys.
Linux:
- The engine is the pinned tarball for x86_64 or arm64: the Vulkan build when a GPU reports its memory in
/sys/class/drm(SITE_AI_BUILD=auto;vulkanandcpuchoose outright). The Vulkan build brings the Mesa Vulkan packages with the same sudo. Thegpurow names the build this machine gets; when the extracted engine is the other build, theenginerow says so and./bootstrap.shreplaces it. An architecture without a pin: allama-serveralready on this machine (on$PATH, or in/usr/local/binor/usr/bin) is found and used -- theenginerow reads(your build)-- or pointSPARK_ENGINE_DIRat a build of your own. Where a pin exists, the pinned engine is still installed and used: your build never silently replaces the pin. - Integrated GPUs: the BIOS decides how much RAM the iGPU owns ("UMA frame buffer size"). If the
gpurow says the model is larger than VRAM, raise it there (8 GB for a 4B-8B model), thenspark benchagain. - The
rendergroup grants the GPU without a logind seat: bootstrap adds you on a vulkan build; log out of every session and in again for the units to see it. spark font Terminus 16x32gives the text console a readable font (spark font listshows the faces and sizes this box has, width by height). The console cannot draw the check and arrow glyphs; spark notices (TERM=linux) and prints ASCII;SPARK_ASCII=1forces it.spark theme NAMEreaches the text console: the palette is sent to the console you type on and set at boot for every VT (thespark-consoleunit,setvtrgb). GUI terminals stay yours: applytheme.envin their settings by hand. Nothing is painted until you ask.- Units:
systemctl --user status spark-serve spark-forge spark-check.timer;journalctl --user -u spark-serve -n 50. Without a user systemd session (a container) theservicesrow readsna; runspark serveandspark forgeby hand.
Arch:
- Linux to spark: the same one-liner, the same rows. The engine is the pinned
ubuntu-*tarball (a glibc build; Arch's glibc is newer). CI proves the one-liner in an Arch container; the console, the units and the GPU there are proven by hand. - Packages come through
pacman -S --needed, never-Syalone; when a name cannot be found thepackagesrow sayssudo pacman -Syufirst.gcc-libsis inbase: without a GPU nothing is installed. - No console-setup:
spark fontrefuses to set and thefontrow says so; the console font is/etc/vconsole.conf's (FONT=ter-132nwithterminus-font;sudo systemctl restart systemd-vconsole-setup). spark quiet login onworks;spark quiet bootrefuses (noupdate-grub). By hand with systemd-boot:timeout 0in/boot/loader/loader.confandquiet loglevel=3 systemd.show_status=false udev.log_level=3 vt.global_cursor_default=0 fbcon=nodeferon the entry'soptionsline under/boot/loader/entries/. With GRUB: the same words inGRUB_CMDLINE_LINUX_DEFAULT,GRUB_TIMEOUT=0,GRUB_TIMEOUT_STYLE=hidden, thensudo grub-mkconfig -o /boot/grub/grub.cfg.
Windows (Ubuntu 24.04 on WSL 2):
- Linux to spark: the same one-liner, the same rows;
spark checkand the status line sayWSL 2. Not proven by CI (no WSL runner): the one-liner end to end there is on you, for now. - The engine is the CPU build: WSL 2 exposes the GPU as
/dev/dxg, not as a DRM card, soautolands oncpuand picks under the 3 GB cap (qwen3-4b on most machines).SITE_AI_BUILD=vulkanthrough Mesa is yours to try, untested. - No console: the font is Windows Terminal's (
spark fontrefuses; thefontrow readsna). No GRUB:spark quiet bootrefuses;spark quiet loginworks. - Units: if the
servicesrow readsna, put[boot] systemd=truein/etc/wsl.conf,wsl --shutdownfrom PowerShell, reopen,./bootstrap.sh. - Not a server for the LAN: the distro stops with its last window, so
spark headless onrefuses. Reaching the page from the LAN needsnetworkingMode=mirroredin.wslconfig(Windows 11), untested here.
7. Keep it
Update:
spark update
A clone get made moves to the newest release tag; a developer clone on a branch pulls --ff-only. Either way it converges (bootstrap.sh runs, spark check re-reads); --dry-run says what it would do. By hand: git -C ~/.spark pull --ff-only && ~/.spark/bootstrap.sh.
A release tag is signed, and spark update moves only to a tag signed by a key in the tree's allowed-signers; any other tag is refused in one line -- spark update -- v1.36 is not signed by a known key: refused -- and nothing moves (get keeps the same rule, and the signed row of spark check names the key that signed the tag you are on).
Uninstall:
spark uninstall
- It prints the plan, one row per thing, then asks for the word
yes. - Everything spark made goes: the units, the rc line, the console palette and font (VGA again), the shell layer's files back from their
.bak(docs/SHELL.md),~/.local/bin/spark, the engine and every model,~/.config/spark,~/.local/state/spark, and the clone at~/.sparkwhen it is the onegetmade and clean. Headless and the quiet login and boot are undone first (sudo). - What stays, on purpose: your soul, your memory, the sealed users' stores with their keys, your
models.env, your themes andprivacy-terms;--purgetakes those too. The shell layer's packages are a question (--packages/--keep-packagesanswer up front).--dry-runshows the plan;--yesskips the question for a script. - Named at the end with the line that puts it back: a hostname it set, macOS's
pmsetvalues, a console font set before v1.12. A root step whose sudo refuses becomes atodorow, never a failure.
The keys. Everything in ~/.config/spark/site.env beyond the three setup asks is optional and has a verb; editing the file and running ./bootstrap.sh does the same.
| key | values | default | |
|---|---|---|---|
SITE_NAME | this machine's display name | short hostname | |
SITE_USER | your display name | your login | |
SITE_SET_HOSTNAME | yes: the OS hostname follows SITE_NAME (sudo) | no | |
SITE_AI_MODEL | auto, none, or a name -- spark model NAME; none beside a peer URL is a client | auto | |
SITE_EMBER_MODEL | none, auto, or a name: the second model for conversations -- spark ember NAME | none | |
SITE_AI_BUDGET | 10..95: percent of RAM+GPU memory auto may use -- spark model budget N | 60 | |
SITE_AI_BUILD | auto, cpu or vulkan: the Linux engine build (macOS ignores it; WSL 2 lands on cpu) | auto | |
SITE_PEER_AI_URL | another machine's URL (spark forge --print-client there) -- spark client URL | unset | |
SITE_HEADLESS | yes: up from boot, never asleep -- `spark headless on\ | off` | no |
SITE_THEME | none, or a palette from themes/ or ~/.config/spark/themes/ -- spark theme NAME; painted only when you ask | none | |
SITE_FONT_FACE / SITE_FONT_SIZE | Linux console: a face and size from spark font list (Terminus 16x32); macOS: Terminal.app's font and points -- spark font FACE SIZE. Refused on WSL 2 and Arch (no console-setup) | unset / 16x32 (Linux), the Nerd Font / 13 (macOS) | |
SITE_QUIET_LOGIN | Linux: yes bares the login (motd, /etc/issue; originals kept) -- spark quiet login on | no | |
SITE_QUIET_BOOT | Linux: yes makes the boot silent (one GRUB drop-in) -- spark quiet boot on; refused on WSL 2 and Arch | no | |
SITE_QUIET_START | yes: no banner, one-line serve, forge and bare spark -- spark quiet start on | no | |
SITE_QUIET_AUDIO | yes: no sound from spark -- spark quiet audio on | no |
Runtime knobs live in ~/.config/spark/spark.env (spark.env.example lists them all); the ones with a verb:
| key | values | default | |
|---|---|---|---|
SPARK_MEMORY | on/off: send the remembered facts -- `spark memory on\ | off` | on |
SPARK_FORGE | auto/on/off: serve the page and the API -- `spark forge on\ | off` | auto |
SPARK_FORGE_HOST / SPARK_FORGE_PORT | the address and port (never 0.0.0.0) | the LAN address / 8081 | |
SPARK_HISTORY | days of turns and threads kept; off keeps none | 30 | |
SPARK_NGL SPARK_FLASH_ATTN SPARK_KV SPARK_THREADS | the engine's tuning -- spark bench tune apply | auto | |
SPARK_API_KEY_FILE | a token file you already have | ~/.local/state/spark/api-token |
What needs root. bootstrap.sh --dry-run lists exactly which of these it would do and never calls sudo:
- always: the package manager for the
packagesrow (libgomp1on Debian, the Vulkan libraries with a GPU) and the hostname whenSITE_SET_HOSTNAME=yes; macOS the hostname only; spark shell on: the shell tools, the console font and palette, the quiet login and boot, each only when its key says so;spark headless on: linger, therendergroup, the sleep targets, the lid; macOS the LaunchDaemons andpmset.
spark uninstall uses sudo for the mirror image. Passwordless sudo is yours to decide (echo 'you ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/you, fine for a test bench).
When something stops working:
spark checknames the row and the remedy (long output pages through$PAGER, plain when piped)../bootstrap.sh --dry-run: what a rebuild would change.spark: which server answers, which shells have the widget.- A stale server after a DHCP move shows as
movedon theserverow:spark serve off; spark serve on. Theforgerow likewise:spark forge off; spark forge on. spark forge: is the page up, at which address; one line per request in~/.local/state/spark/forge.log, never a body.- The
emberrow: the pair over budget, the file not downloaded (./bootstrap.sh), or not warm (spark servewarms it). - A GPU new servers cannot see (the
gpurow warns): on Linux the serving user must be in therendergroup; log out of every session and in again. SPARK_DEBUG=1 spark ...and~/.local/state/spark/debug.log.the ledger does not open -- spark user login again(or the memory): the key this machine holds is not the one that sealed the file -- a login by another token, or a byte that changed on disk. Nothing is written over it;spark user login NAMEwith your token puts the right key back.- For an issue:
spark check --reportprints a block safe to paste -- version, OS, backend, model stems and every row's status, never a value, a path or a name (it runs the privacy word lists over its own output besides).
8. What an attacker can and cannot do
The trust boundary is your LAN: spark serves plain HTTP to the addresses you gave it and nothing else.
- On your LAN, an attacker can read the HTTP traffic (there is no TLS), and a cookie or token they capture works until it is rotated or its session is logged out -- logging out revokes the session on the server, not only in the browser. They cannot log in by guessing: a wrong token costs a second, ten wrong tokens a minute lock the address out, and the login sleep is bounded so a burst cannot pin the server's threads. The remedy is rotation:
spark user token --newfor your own token (it re-keys your sessions on the spot),spark forge token --newfor the admin's. - With the disk, an attacker reads the box account's own store -- its key sits beside it so the box can work -- plus the soul (plain config). Every NAMED user's store is ciphertext: the key is wrapped by that user's token, the admin holds no copy, and a lost token is lost history. There is no reset.
- With a stolen phone that was logged in, they hold that one user's chat and settings -- never another user's store, never the box beyond it -- until
spark user token --newfrom any logged-in session, or the page's log out, ends it. - What spark depends on is one command:
spark ver --sbomprints a software bill of materials -- the list of every component this tree pins, with versions and sha256s (the engine per flavour, every model with its license, the distro packages, the python floor, the pinned GitHub Actions), as CycloneDX 1.5 JSON -- and every release carries it assbom.cdx.json. Thependingrow ofspark checkcounts the security upgrades your package manager holds back (Debian's-securitysources;arch-auditon Arch) and warns while any waits.
What leaves is counted, never read: every request's size and destination ride its turn record (a number and a host), spark stats --sends prints them by destination and day for the last week, and the sends row of spark check warns the day any bytes went to a host other than the server you chose.
Appendix: how it fits together
your shell this machine the LAN
---------- ------------ -------
? words ---- widget -------> spark line ---+
an app's key - spark-<app> -> spark edit --+
spark chat | do | explain -> spark <verb> -+-> spark's server :8081 --> another
| soul, memory, threads; machine's
| /v1 and /api; the page spark, a
| browser,
+-> llama-server :8080 <---- a program
one model, or two:
the pinned engine and a
GGUF from models.env
get -> spark setup -> bootstrap.sh (apply) -> install.sh (links, renders)
the engine, the model, the token, the units, one rc
line; spark-shell adds spark's own shell; a spark app
is its own repository (spark-<app>)
spark check 39 rows: every promise the machine makes, fixture-tested
spark update the newest signed tag, or main on a developer clone; converge
what leaves the machine: pinned downloads in, your questions to the
server you chose, nothing else -- no telemetry, no account, one LAN address.source: INSTALL.md on GitHub -- this page is that file at spark v1.37