A control plane for a small homelab AI setup: a few machines running Ollama, llama-swap, TabbyAPI, or infinity-emb, fronted by a Bifrost OpenAI-compatible router, with a FastAPI backend and a React dashboard on top. It manages models, agents, tool-calling, and persona sync across whatever machines you've registered, over SSH.
This isn't a generic multi-tenant product, it's built for one operator's homelab (Tailscale-only, single admin token) and the code assumes that.
Machine registry. You register machines (name, IP, OS, SSH user/key, which inference framework it runs) and stackctl talks to them over SSH (backend/machines_ssh.py, via asyncssh). Each machine has a framework of llama-swap, tabbyapi, ollama, infinity-emb, or none. Per machine you can read and write its framework's YAML config with automatic backups (backend/config_backups.py), restore a prior backup, restart the framework process remotely, and warm or unload specific models. Ollama machines get a narrow command allowlist (pull, rm, list, ps, show, stop, start) instead of raw shell access.
Model catalog. GET /api/models/catalog walks every registered machine and builds a unified model list across frameworks. For llama-swap it parses the -m <path> and context flags out of the launch command in the config YAML, estimates VRAM from the quant string and parameter count when it can't stat the file directly, and cross-references what Bifrost currently exposes as available model IDs. For TabbyAPI it runs a remote du -sb on the model directory. For Ollama it hits /api/show and digs the size out of whatever shape the response happens to come back in.
Bifrost proxy. backend/routers/bifrost.py proxies to a Bifrost instance for provider, key, and model management plus health checks, and pulls /metrics from it. The Prometheus text-exposition parsing is hand-rolled, a small state machine over label strings and sample lines, instead of pulling in a client library, since all it needs is scraping a handful of gauges.
Agents. Stored in SQLite. Each agent has a system prompt, a base model, and a list of enabled tools. Tools wired up so far: web_search (against a SearXNG instance), http_request (domain-allowlisted GET/POST), boolab_rag (queries boolab's per-DAW RAG endpoint), caldav_read (currently a stub that just echoes config back, no real CalDAV client yet), and file_read / run_powershell (both run over SSH against sam-desktop, gated by allowed-path and allowed-command-prefix whitelists). Runs stream over SSE and get saved so you can review the tool-call trace after the fact. Agents export as an n8n workflow JSON or push into boolab as a DAW-compatible system prompt.
Flows. A visual node graph (input, LLM call, tool, condition, output) saved as JSON and executed by walking edges from the input node (backend/flow_nodes/runner.py). Same n8n export path as agents.
Personas. A thin CRUD proxy in front of boolab's own persona API, including icon upload and "set as default." Persona data lives in boolab's Postgres, not stackctl's SQLite. stackctl doesn't own this data, it just gives you a UI for it.
| Layer | Tech |
|---|---|
| Backend | FastAPI, Python 3.12, aiosqlite, asyncssh, httpx, pyyaml |
| Frontend | React 18, Vite, Tailwind, Radix UI primitives, React Flow, TanStack Query |
| State | SQLite (stackctl.db): machines, agents, flows, run history, framework config backups |
| Auth | Single bearer token checked against BOOLAB_OWNER_TOKEN, an owner JWT minted by boolab, or STACKCTL_SKIP_AUTH=1 for local dev |
| External services | Bifrost (OpenAI-compatible router), boolab API (personas, RAG), Prometheus (metrics), SearXNG (web search tool), a CalDAV server (planned, not wired up yet) |
There's no real multi-user auth model here. auth_deps.require_admin checks one bearer token against one env var. That's fine for a single-operator homelab tool, but don't expose this past Tailscale.
cp .env.example .env # fill in OLLAMA_URL, BOOLAB_API_URL, BOOLAB_OWNER_TOKEN, etc.
docker compose up --build -dTwo containers: stackctl-api (uvicorn, port 8700) and stackctl-ui (nginx serving the Vite build, port 8701). Both bind to a specific Tailscale IP in docker-compose.yml, not 0.0.0.0, so change that if you're deploying somewhere else.
For local development without Docker:
cd backend && pip install -r requirements.txt && uvicorn main:app --reload --port 8700
cd frontend && npm install && npm run devSet STACKCTL_SKIP_AUTH=1 in your .env to skip the bearer-token check while you're poking at it locally.
See .env.example for the full list. The ones that matter most:
BOOLAB_OWNER_TOKENis required in production. Every admin route returns 503 without it configured, 401 or 403 without a valid bearer token.BIFROST_URLis the Bifrost instance the router proxy talks to. Bifrost routes 503 without it set.PROMETHEUS_URLdefaults to a specific homelab address if unset, override it for your own setup.SAMDESKTOP_*andGPU_*are per-machine SSH connection details and llama-swap config paths for the two example machines this was originally built against. They're examples of the machine-registry shape, not hardcoded requirements. Machines you register through the UI go into SQLite, not env vars.
backend/
main.py # FastAPI app, router wiring, CORS, lifespan (DB init + shared HTTP client)
routers/
machines.py # machine registry, SSH-based framework control, config backup/restore
model_catalog.py # cross-machine model listing with VRAM estimates
bifrost.py # Bifrost proxy + hand-rolled Prometheus metrics parsing
agents.py # agent CRUD, SSE run loop, n8n/DAW export
flows.py # flow CRUD, SSE run loop, n8n export
personas.py # thin proxy to boolab's persona API
ollama_proxy.py # raw reverse proxy to whichever machine's Ollama is targeted
machines_ssh.py # asyncssh connection handling per machine
tools/registry.py # agent tool implementations (web_search, http_request, file_read, ...)
flow_nodes/runner.py # flow graph execution
frontend/
src/pages/ # one page per major feature: Machines, Models, Bifrost, Agents, Flows, Personas
The env file has CHROMA_URL, SEARXNG_URL, and CALDAV_URL placeholders, but there's no document-upload or vector-store router in the backend right now, and caldav_read is a stub. If you came looking for a full RAG pipeline, it isn't built, only the plumbing for one tool to eventually call boolab's existing RAG endpoint.