A stacklet is the unit of deployment. The stack is a composition of stacklets.
The stack CLI is the runtime. It discovers stacklets by walking the filesystem, reads their manifests,
and manages their lifecycle. No central registry — if it's a directory under
stacklets/ with a stacklet.toml, it exists.
It is convention over configuration to "sew" services together. Think Spring Boot for self-hosted services on a Mac.
The distinction is infrastructure vs feature. Core hosts cross-cutting
infrastructure that every running stack needs: Watchtower (auto-updates),
the bot runtime, the API socket, the LLM tools server. Vertical stacklets
host features a user might rationally decline: photos, documents, backup,
the AI stack. The test: could a user legitimately want this off? If yes,
it belongs in its own stacklet, even when multiple other stacklets opt
into it via manifest contracts (e.g. [[backup.archive]] declared by
photos and docs, consumed by the backup stacklet).
A stacklet is a directory containing at minimum a stacklet.toml
manifest. Everything else is optional — include only what you need.
Two directories are searched, in this order:
| Location | What belongs there |
|---|---|
stacklets/ in the repo |
The stacklets a release ships and supports. |
an extension dir ([core] extension_dirs, default ["~/<product>-extensions"]) |
Everything else: a private stacklet, a community one, one still being designed. Outside the repo, so an upgrade cannot take it, and each can be its own git repo. |
extension_dirs is a list, searched in the order given. The first tree
to claim an id keeps it, so the repo wins a clash with an extension and
an earlier extension dir wins a clash with a later one. Nothing creates
these directories; one that is not there is skipped.
The runtime resolves a stacklet by id, never by path, so stack up <id>,
hooks, CLI plugins, secrets and template variables like {photos_url}
behave identically wherever it lives. stack list and stack status
group extensions in their own section, and every stacklet in the JSON
output carries source (repo or extension) and path.
One asymmetry: a compose file cannot iterate a list, so the bot runner
bind-mounts the first extension dir only. A stacklet that ships a bot
belongs there, and stack up says so when it is somewhere else. While
that directory does not exist, the mount gets an empty stand-in under
.stack/, because a bind mount needs a source and an instance without
extensions should not grow a directory in the admin's home. Any
stack up re-renders core's env, so the real one takes over by itself.
stacklets/photos/
stacklet.toml ← manifest (required)
docker-compose.yml ← container definitions
caddy.snippet ← reverse proxy route (domain mode)
hooks/
on_configure.py ← first run: interactive prompts
on_install.py ← first run: create dirs, install deps
on_install_success.py← first run: obtain tokens, seed data
on_start.py ← every up: validate config, start native services (before containers)
on_start_ready.py ← every up: seed data, sync accounts (after health checks)
on_stop.py ← every down: stop native services
on_destroy.py ← teardown: remove native services
cli/
seed.py ← CLI subcommand: stack photos seed
Every file is a convention. The runtime looks for it by name. If it's
there, it's used. If it's not, it's skipped. Hooks can be .py
(preferred) or .sh — the runtime checks for Python first.
| File | When it runs | Purpose |
|---|---|---|
stacklet.toml |
Always read | Identity, config, dependencies |
docker-compose.yml |
stack up / stack down |
Container definitions |
caddy.snippet |
Caddyfile assembly (domain mode) | Reverse proxy route |
hooks/on_configure |
Once on first stack up |
Interactive prompts (API keys, server names) |
hooks/on_install |
Once on first stack up |
Create directories, install native deps |
hooks/on_install_success |
Once after first healthy start | Obtain tokens, seed data |
hooks/on_start |
Every stack up, before containers |
Validate config, start native services |
hooks/on_start_ready |
Every stack up, after health checks |
Seed data, sync accounts (service is healthy) |
hooks/on_stop |
Every stack down |
Stop native services |
hooks/on_destroy |
On stack destroy |
Remove native services |
cli/*.py |
On demand via stack <id> <command> |
Stacklet-specific CLI commands |
The manifest declares what the stacklet is and what it needs. The runtime reads it — the stacklet never reads it itself.
id = "photos"
name = "Photos"
description = "Family photo library and mobile backup (Immich)"
version = "0.1.0"
category = "media"| Field | Type | Description |
|---|---|---|
id |
string | Unique identifier. Lowercase, no spaces. Used in CLI commands, file paths, container names, and secret namespacing. |
name |
string | Human-readable display name. Shown in stack list and status output. |
description |
string | One-line description. |
version |
string | Stacklet version (semver). |
category |
string | One of: infrastructure, media, ai, communication, productivity, development, automation. Used for grouping in stack list. |
port = 42010
always_on = true
type = "host"
requires = ["core", "messages"]
stage = "incubating"| Field | Type | Default | Description |
|---|---|---|---|
port |
int | none | LAN port for port mode. All ports live in the 42xxx range. |
ports |
table | none | Additional named ports: [ports] → element = 42030, synapse = 42031. |
always_on |
bool | false | If true, stack destroy refuses to remove it (only core uses this). stack down still works. |
type |
string | "docker" |
"docker" (default) or "host". Host stacklets install native macOS software (brew, compiled binaries) alongside optional Docker containers. |
requires |
list | [] |
Stacklet IDs that must be enabled before this one. The runtime enforces ordering on stack up and prevents destroying dependencies. |
build |
bool | false | If true, the stacklet has a local Dockerfile. stack up rebuilds the image on every run instead of pulling from a registry. Use for stacklets with custom code (bots, agents). |
stage |
string | "stable" |
How finished the stacklet is. "stable" is silent. Any other value ("beta", "incubating", or your own) is shown in stack list and warned about on every stack up. |
required_secrets |
list | [] |
Secret names (unprefixed, as ctx.secret() reads them) the stacklet cannot work without. stack doctor reports any that are absent and points at stack setup <id>. Declare a secret here when it is minted by on_install_success, since that hook never runs again on an instance that is already installed. |
Declares the primary Docker image for auto-update tracking.
[upstream]
image = "ghcr.io/immich-app/immich-server"
channel = "patch"| Field | Values | Description |
|---|---|---|
image |
string | Docker image reference. Watchtower monitors this for updates. |
channel |
"patch" / "none" |
patch: Watchtower auto-updates. none: manual only. |
The environment system renders configuration from templates. No .env files
to maintain — the runtime generates them on every stack up from these
declarations.
[env]
generate = ["DB_PASSWORD", "SECRET_KEY"]
[env.defaults]
UPLOAD_LOCATION = "{data_dir}/photos/library"
DB_DATA_LOCATION = "{data_dir}/photos/postgres"
TZ = "{timezone}"
DB_USERNAME = "postgres"
ADMIN_USER = "{admin_email}"
ADMIN_PASSWORD = "{admin_password}"generate — list of env var names. Values are auto-generated as
cryptographically random strings and stored in .famstack/secrets.toml,
namespaced by stacklet ID (photos__DB_PASSWORD). Idempotent — existing
secrets are never overwritten. Destroying a stacklet does not remove its
secrets, so re-enabling reuses the same credentials.
defaults — key-value pairs with {template} variables. Rendered
against stack.toml values on every stack up. The rendered output is
written to the stacklet's .env file.
Available template variables:
| Variable | Source |
|---|---|
{data_dir} |
stack.toml → [core].data_dir |
{domain} |
stack.toml → [core].domain |
{ip} |
[core].host if set, else auto-detected LAN IP (falls back to localhost) — used in port-mode URLs |
{language} |
stack.toml → [core].language (falls back to [ai].language, then en) |
{timezone} |
stack.toml → [core].timezone |
{shared_bucket} |
stack.toml → [core].shared_bucket (defaults to "family") — slug for the shared/institutional bucket inside the memory vault |
{stacklet_id} |
The stacklet's own id field |
{admin_username} |
Tech admin username (stackadmin) |
{admin_email} |
Tech admin email (stackadmin@home.local) |
{admin_password} |
Tech admin password, generated and stored in secrets.toml |
{family_names} |
Comma-separated first names of everyone in users.toml |
{ai_openai_url} |
Derived from stack.toml → [ai].openai_url |
{ai_openai_url_docker} |
Same, rewritten for container access via host.docker.internal |
{ai_openai_key} |
stack.toml → [ai].openai_key |
{ai_whisper_url_docker} |
Derived from stack.toml → [ai].whisper_url, or the AI server when unset |
{ai_whisper_key} |
stack.toml → [ai].whisper_key with a dedicated speech server, otherwise the AI key |
{ai_whisper_model} |
stack.toml → [ai].whisper_model, default whisper-1 |
{ai_default_model} |
stack.toml → [ai].default |
{ai_tts_voice} |
Derived from [ai].language |
{messages_server_name} |
stack.toml → [messages].server_name |
{url}, {<id>_url}, {home_url} |
Public URLs: http://<ip>:<port> in port mode; https://<id>.<domain> in domain mode once [core].dns_provider is set, http:// before that |
{browser_url} |
The address to open in a browser on this Mac: {url} in domain mode, http://localhost:<port> in port mode, because a web app that needs WebCrypto (Element) only runs over HTTPS or on localhost |
{url_host} |
The host of {url}: <id>.<domain> in domain mode, {ip} in port mode. For a protocol the stacklet serves under the same name besides HTTP, such as Forgejo's SSH |
{<id>__<NAME>} |
A secret from secrets.toml, e.g. {docs__API_TOKEN}, {infra__DNS_API_TOKEN} |
{oidc_issuer}, {oidc_client_id}, {oidc_client_secret} |
Single sign-on for a stacklet with an [oidc] table, see Single Sign-On. All three empty until the provider has registered the stacklet |
Post-setup messages shown to the user after stack up. Template variables
from the environment plus {url} and {ip} are available.
hints = [
"Open {url} and create your admin account",
"Install the Immich app on your phone — enter {url} as the server",
]Defines how the runtime confirms the service is actually responding, not
just that the container is running. Checked after stack up and by
stack status.
[health]
url = "http://localhost:42010/api/server/ping"
path = "$.res"
expect = "pong"| Field | Description |
|---|---|
url |
HTTP endpoint to poll. |
expect |
Expected HTTP status code (as string) or response body value. |
path |
JSONPath into the response body. If set, expect is compared against the extracted value instead of the status code. |
For several endpoints, use [[health.checks]], which additionally takes a
name, a hint shown when the check fails, and [health.checks.headers].
[[health.checks]]
name = "TTS"
url = "http://localhost:42063/"
hint = "TTS not running — check 'docker logs stack-ai-speech'"
skip_when_env = "STACK_AI_NO_VOICE"skip_when_env names an environment variable that, when set to "1",
drops the check entirely. Use it for a part of a stacklet the user can
choose not to start: stack up ai --no-voice leaves the speech containers
out, and without this the start sequence waits a full timeout for each of
them and then reports containers it was told to skip as broken. Any other
value, including "0" or empty, leaves the check in place, so an
accidentally exported variable cannot quietly disable monitoring.
A stacklet that can be served by another machine declares a top-level
remote = "{template}" in its manifest. When it renders to an address,
stack list shows the row as remote with that host, or localhost when
the address is this machine, meaning something on it that the stack does
not manage serves it. Only the checks marked remote = true probe that
server; the others probe this Mac, so they run only while the stacklet's
own containers run here as well.
Host stacklets (type = "host") declare native macOS services that run
outside Docker. The runtime checks each service on stack up and
optionally starts it.
[services.omlx]
name = "oMLX"
description = "MLX inference with SSD caching (Metal GPU)"
check_url = "{ai_openai_url}/models"
start = "brew services start omlx"
stop = "brew services stop omlx"
[services.whisper]
name = "Whisper"
description = "Speech-to-text (whisper.cpp, Metal GPU)"
check_url = "http://localhost:42062/"
start = "launchctl load ~/Library/LaunchAgents/dev.famstack.whisper.plist"
stop = "launchctl unload ~/Library/LaunchAgents/dev.famstack.whisper.plist"| Field | Description |
|---|---|
name |
Display name for status output. |
check_url |
URL polled to determine if the service is running. Supports {template} variables from stack.toml (e.g. {ai_openai_url}). |
start |
Shell command to start the service if not responding. Optional. |
stop |
Shell command to stop the service. Used by down.sh. Optional. |
A stacklet ships a bot by adding a bot/ directory with a bot.toml
manifest and a Python file. The bot runner (in core) discovers bots
across all enabled stacklets and runs them in one async process.
Bot IDs always end with -bot (e.g. archivist-bot, scribe-bot).
stacklets/docs/
bot/
bot.toml # declaration
archivist.py # MicroBot subclass
messages/ # i18n templates (optional)
# stacklets/docs/bot/bot.toml
id = "archivist-bot"
name = "Archivist"
description = "Auto-files documents with AI classification"
room = "documents"
room_topic = "Drop files here — they get filed automatically."
[settings]
classify = true
reformat = true| Field | Description |
|---|---|
id |
Bot identifier, ends with -bot. Becomes the Matrix username (@archivist-bot:home). |
name |
Display name in Matrix. |
room |
Room alias to create/join. Optional — omit for bots that only respond to DMs/invites. |
room_topic |
Topic set on auto-created room. |
join_rooms |
Aliases of rooms another stacklet owns that the bot takes part in. Joined on every start when they exist; never created, and no one else is joined to them. |
settings |
Arbitrary key-value pairs passed as kwargs to the bot constructor. |
Module convention: strip -bot from the ID → archivist.py → class ArchivistBot.
Bot passwords are declared in the stacklet's [env].generate (e.g.
"ARCHIVIST_BOT_PASSWORD" in docs/stacklet.toml). The bot runner reads
passwords from .stack/secrets.toml on startup.
Matrix is famstack's canonical event ledger — every stacklet emits typed envelopes into the room timeline, and any state in the system must be derivable from replaying those events. Two delivery shapes:
- Visible event —
m.room.messagecarrying adev.famstack.eventcontent field. The body renders for humans in Element; bots and the deriver read the structured envelope alongside it. One event, full picture. - Silent event —
dev.famstack.event-typed message. Element ignores unknown event types, so the event acts as a pure machine signal with no chat noise.
Both shapes carry the same envelope schema:
{
"source": "<stacklet id>", // "docs", "messages", "photos", …
"type": "<verb.noun>", // "document.filed", "voice.transcribed"
"summary": "<one-liner>", // human-readable activity description
"data": { … }, // structured payload
"actor": "@user:home" | "bot",
"ts": "<ISO-8601 UTC>"
}
Examples shipping today:
| Type | Delivery | Emitter | data keys |
|---|---|---|---|
document.filed |
visible (on m.room.message) | archivist-bot |
paperless_id, title, date, topics[], persons[], correspondent, document_type, summary, facts[], action_items[], url |
document.reclassified |
visible | archivist-bot |
same as document.filed + user_hint |
Finding famstack events in a room: enumerate the timeline and treat any
event whose content has a dev.famstack.event key OR whose Matrix
type is dev.famstack.event as a ledger entry. Both deliver the
same envelope.
To send a silent event from a bot, call MicroBot.emit_event:
await self.emit_event(
room_id,
"dev.famstack.event",
{"source": "docs", "type": "service.started", "summary": "…",
"data": {…}, "actor": "archivist-bot", "ts": "…"},
)To attach an envelope to a visible message, pass metadata on a
text-sending helper (see archivist.py's _send):
await self._send(
room_id, body_text, reply_to,
metadata={"dev.famstack.event": envelope},
)Both paths are best-effort; transport failures are logged but never raised. A downstream bot being offline must not take the emitter's main path with it.
Subscribing to custom events on the bot side isn't wired yet — that
comes with the first consumer bot. The emit contract is stable and
tested end-to-end (see tests/e2e/test_archivist_e2e.py).
A stacklet declares the data it wants backed up. The backup stacklet
discovers these declarations across all enabled stacklets and routes them
through configured targets (see stack.toml → [backup.targets.*]).
The stacklet itself never reads the manifest — discovery is the runtime's
job.
# stacklets/photos/stacklet.toml
[[backup.archive]]
name = "library"
path = "{data_dir}/photos/library/library"| Field | Description |
|---|---|
name |
Short slug for this source. Combined with the stacklet id, this becomes the global source id (photos/library), which is also the directory it occupies on a vault (data/photos/library/). Used in stack backup status output and (future) --source= selection. |
path |
Filesystem path to sync. Template variables from the rendered environment are available ({data_dir}, etc.). |
There is no threshold to declare. The engine judges each source against
the number of files it held on the previous run, recorded in its own
history, and refuses to sync one that is suddenly empty or has lost more
than half its files. This replaced a min_files constant, which could
not work: a number written at authoring time cannot know the scale of the
household it guards, so a library of 50,000 photos reduced to 11 passed
min_files = 10 without complaint. A source that has never held anything
is skipped rather than failing the run.
[[backup.archive]] declares an append-only store: files are added,
never modified, never deleted. The engine commits to kernel-enforced
immutability where the filesystem supports it. If a stacklet's data is
genuinely append-only (photo originals, archived PDFs), this is the
right section. (Storage-industry vocabulary calls this WORM — Write
Once Read Many.)
[[backup.snapshot]] declares mutable state that cannot be rsynced.
A database changes under you continuously and a copy taken mid-write will
not restore, so it is dumped instead: one pg_dump per run, packed with
whatever small files must travel beside it, into a dated .tar.gz. Each
run adds a tarball and never touches an older one, which gives mutable
state the same append-only shape an archive has.
# stacklets/messages/stacklet.toml
[[backup.snapshot]]
name = "synapse"
postgres = { container = "stack-messages-db", database = "synapse", user = "synapse" }
include = ["{data_dir}/messages/synapse/homeserver.yaml",
"{data_dir}/messages/synapse/*.signing.key"]| Field | Description |
|---|---|
name |
Short slug, as for an archive. Becomes the source id (messages/synapse). |
postgres |
How to capture the state, namespaced by what captures it. container is where the database runs (the dump goes through docker exec), database and user are what to dump and as whom. |
include |
Files that must travel with the dump for a restore to be possible. Globs allowed; a path this install never created is skipped rather than failing the snapshot. |
The capture key is namespaced so a stacklet keeping state somewhere other
than Postgres can declare a snapshot later without the contract having to
pretend every database looks like this one. Driving a containerised
Postgres lives in stack.postgres, shared with whatever eventually
restores one, rather than inside the backup coordinator where only it
could reach it.
Snapshots run before the sync, so a dump is never newer than the files it
references. pg_dump takes its own consistent view, so nothing stops and
nobody is logged out while it runs. The tarball records the image
versions and digests that produced it; nothing reads that yet, but it is
the one part of a snapshot that cannot be added afterwards, and a restore
has to be able to refuse an incompatible target.
A stacklet may declare zero, one, or several entries of each kind. Sources flow to every configured target whose engine supports the declared section type.
A stacklet can let people log in through an OpenID Connect provider that another stacklet runs. The two sides do not name each other: the client does not know which provider it gets, and the provider does not know the client's env.
A client declares [oidc] and reads three template variables:
[env.defaults]
OIDC_ISSUER = "{oidc_issuer}"
OIDC_CLIENT_ID = "{oidc_client_id}"
OIDC_CLIENT_SECRET = "{oidc_client_secret}"
[oidc]
name = "Documents" # shown on the provider's consent page; default: the stacklet name
callbacks = ["{url}/accounts/oidc/sso/login/callback/"]callbacks are rendered against the client's own template variables, so
{url} is the client's public URL. The three {oidc_*} variables are
set together or not at all: they stay empty while no provider exists, and
until the provider has registered the client. Test one of them in the
compose file to switch the login on, and keep the service's own login as
the fallback.
A provider declares [oidc_provider] (an empty table). The issuer is
its public URL, {url}. Its hooks do the registration through the
Stack instance in ctx.stack:
| Method | What it does |
|---|---|
oidc_clients() |
Every stacklet with [oidc]: stacklet, name, rendered callbacks, and the stored client_id and client_secret (None before the first registration) |
store_oidc_client(stacklet_id, client_id, client_secret) |
Stores the credentials the provider issued |
Credentials are kept in the provider's secret namespace
(<provider>__CLIENT_<ID>_ID and _SECRET). stack destroy <provider>
deletes them, and each client falls back to its own login on its next
stack up. A client reads new credentials only when its containers are
recreated, so a provider should not rotate a stored secret on every run,
and should tell the admin which client to stack up after storing new
credentials. If more than one stacklet declares [oidc_provider], the
first one discovered is used.
Brought up in one command, such as stack up all, the provider starts
before its clients, so they render their env after it has registered
them. A client brought up alone does not pull the provider in.
stack doctor warns about a running client that a running provider has
not registered, and about a second provider being ignored. A provider
that is present but was never brought up raises nothing.
A provider may need domain mode with HTTPS, and should refuse to start
without it in on_install and on_start. A passkey login is the usual
reason: browsers allow WebAuthn only on an https:// origin. The
framework does not check this, because the requirement belongs to the
provider, not to OIDC.
A stacklet is in exactly one of three states. There is no separate "enabled" registry — state is derived from what actually exists.
stack up
AVAILABLE ──────────────► RUNNING
▲ │ ▲
│ │ │
│ stack destroy │ │ stack up
│ │ │
│ stack down│
│ │ │
│ ▼ │
└──────────────── STOPPED
stack destroy
| State | How to detect | Meaning |
|---|---|---|
| Available | No containers (docker ps -a), no data dir |
Defined in the repo, never started or fully destroyed |
| Running | Containers exist and are running | Active, serving requests |
| Stopped | Containers exist but not running | Paused, data intact, stack up resumes |
Brings a stacklet to the running state. Idempotent — safe to run repeatedly. Every run refreshes config so changes in stack.toml take effect.
1. Check requires — fail if dependencies not running/stopped
2. Render .env from templates + generate missing secrets
3. First run only:
a. hooks/on_configure.py — interactive prompts
b. hooks/on_install.sh — create dirs, install deps, build
4. Write .env to stacklet directory
5. Bot runner discovers bots (if stacklet has bot/bot.toml)
6. hooks/on_start.sh — start native services (host stacklets)
7. Write the Caddyfile, counting this stacklet as up (domain mode)
8. Build or pull Docker images
9. Start containers (docker compose up -d)
10. Reload Caddy (domain mode)
11. Wait for health check
12. First run only:
a. hooks/on_install_success.py — obtain tokens, seed data
13. hooks/on_start_ready.py — service is healthy, seed data, sync accounts
14. Show welcome screen with URL, login, hints
First run detection: .famstack/{id}.setup-done marker. Absent
means first run. Created after on_install completes. Deleted by
destroy.
Transitions from running to stopped. Data and containers are
preserved — stack up brings it back without re-running setup.
Several ids stop together, separated by spaces or commas
(stack down memory code docs or stack down memory,code,docs), in
reverse dependency order. Every id is checked first, so an unknown one
stops nothing. stack up and stack destroy take ids the same way (up
in dependency order, destroy in reverse, each destroy confirmed on its
own unless --yes). stack restart stops them all, then starts them in
dependency order.
Use stack down all to stop every currently-running stacklet in reverse
dependency order (dependents first, deps last).
1. hooks/on_stop.sh — stop native services (host stacklets only)
2. Write the Caddyfile without this stacklet (domain mode)
3. docker compose stop — pause containers
4. Reload Caddy (domain mode)
Transitions to available. Removes everything — containers, data, secrets, config. Requires confirmation.
1. hooks/on_stop.sh — stop native services
2. hooks/on_destroy.sh — remove native services (host stacklets only)
3. Render .env if missing (compose needs it to parse volume defs)
4. docker compose down -v --remove-orphans — remove containers + volumes
5. Delete .env
6. Delete stacklet secrets from secrets.toml ({id}__*)
7. Delete setup-done marker
8. Delete data directory (~/{data_dir}/{id}/)
9. Write the Caddyfile without this stacklet, reload Caddy (domain mode)
Global secrets (global__ADMIN_PASSWORD) survive destroy. The user's
password doesn't change when they remove a single service.
Destroys all stacklets, removes Docker network, deletes runtime state
(.famstack/), and removes config files (stack.toml, users.toml).
The nuclear option — back to a fresh clone.
Each lifecycle transition can trigger stacklet-specific hooks. Convention over configuration: if the file exists, it runs. If not, the step is skipped. No registration needed.
First stack up:
on_configure ──► on_install ──► on_start ──► health ──► on_install_success ──► on_start_ready
(config gate) (system) (services) (wait) (first-run API work) (every-up API work)
on_configure is the gate: it collects all required settings (provider
choice, API keys, server names) and persists them to stack.toml or
secrets.toml. If it fails or is interrupted, the next stack up re-enters
on_configure and picks up where it left off. on_install reads the config
that on_configure wrote and acts on it. Both hooks should be idempotent.
Subsequent stack up:
on_start ──► health ──► on_start_ready
(services) (wait) (API work)
stack down:
on_stop
(services)
stack destroy:
on_stop ──► on_destroy
(teardown)
| Hook | Runs | Purpose |
|---|---|---|
on_configure |
Once | Config gate. Collects all required configuration via interactive prompts and writes it to stack.toml or secrets.toml. Must be idempotent — if it set some config values but the process was interrupted, the next run should detect what's already set and only ask for what's missing. on_install only proceeds when on_configure completes without error. |
on_install |
Once | System setup. Create directories, install native software, build from source. Should be idempotent — check whether each step was already done before doing it again (e.g. brew list omlx before brew install omlx, check if binary exists before building). |
on_install_success |
Once | Obtain API tokens, seed initial data, create accounts. Runs after first healthy start. |
on_start |
Every up | Runs before containers start. Validate config, start native services. If required config is missing or invalid, raise with a clear message — the framework stops the pipeline and containers won't start. |
on_start_ready |
Every up | Runs after health checks pass. The service is healthy and accepting API calls. Seed data, sync accounts, anything that needs the service running. Must be idempotent. Its ctx.env is rendered after on_install_success, so secrets written there are in it. |
on_stop |
Every down | Stop native services. Only stops services we manage (.state/ markers). |
on_destroy |
Once | Remove native services entirely (unload plists, uninstall). |
on_restore |
On stack backup restore |
Reserved — not yet invoked. Runs after the backup engine has put a stacklet's files back on disk. Owns stacklet-specific recovery: DB import, search-index rebuild, account re-seed. Photos can ship an empty stub (Immich re-indexes from the library on its own); Docs needs pg_restore + Paperless reindex. Hook signature will match the other run(ctx) hooks. |
File resolution: for each hook, the runtime looks for .py first,
then .sh. Only one can exist — not both. Python is preferred.
hooks/on_install.py ← checked first (preferred)
hooks/on_install.sh ← fallback
Once-only hooks (on_configure, on_install, on_install_success)
are gated by the .famstack/{id}.setup-done marker. Created after
on_install completes. Deleted by on_destroy. A future stack up
runs them again from scratch.
Python hooks (run(ctx)) receive a context dict:
| Key | Type | Description |
|---|---|---|
ctx.env |
dict |
Rendered environment variables. |
ctx.secret(name) |
callable |
Read a secret. ctx.secret(name, value) writes one. |
ctx.step(msg) |
callable |
Print a progress line. |
ctx.shell(cmd) |
callable |
Run a shell command with streaming output and error handling. The error message includes the command. |
ctx.run_in_container(argv, service=None, user=None) |
callable |
Run argv in the stacklet's container (stack-<id>, or stack-<id>-<service>) with docker exec, as a list and without a shell. Returns stdout. On failure it raises with the container, the exit code and the last stderr line, never the arguments, so a password or secret can be passed. |
ctx.http_post(url, body) |
callable |
HTTP POST, returns parsed JSON. |
ctx.http_get(url) |
callable |
HTTP GET, returns parsed JSON. |
Shell hooks receive environment variables: all rendered env vars
plus FAMSTACK_DATA_DIR and FAMSTACK_DOMAIN.
All Python hooks implement run(ctx). The runtime calls it with a
context object. Shell hooks receive environment variables instead.
The context object (ctx) provides:
| Key | Type | Description |
|---|---|---|
ctx["env"] |
dict |
Rendered environment variables (all templates resolved). |
ctx["secret"] |
callable |
secret(name) reads a secret. secret(name, value) writes one. Lookup chain: stacklet-specific (photos__X) then global (global__X). Writes go to stacklet namespace. |
ctx["step"] |
callable |
step(msg) prints a progress line to the user. |
ctx["http_post"] |
callable |
http_post(url, body, content_type=..., headers=...) → parsed JSON. Form-encoded by default. |
ctx["http_get"] |
callable |
http_get(url, headers=...) → parsed JSON. Pass auth explicitly: headers={"Authorization": "Bearer ..."}. |
A Python hook or CLI plugin may put its stacklet's directory on sys.path
and import helpers from it by bare name (from oauth import sync). The
framework forgets those modules and restores sys.path when the hook or
command returns, so two stacklets can each have a helper of the same name
even when one process runs both, as stack restart all does.
A hook that raises stops the command and is reported as a failure. A
hook that asked the admin and got a no raises stack.hooks.Cancelled
instead: stack up stops with {"cancelled": <message>}, prints the
message once, exits 1, and does not tell the family room that the
stacklet failed to start.
from stack.hooks import Cancelled
from stack.prompt import confirm
def run(ctx):
if not confirm("Replace the remote AI server with this Mac?", default=False):
raise Cancelled("Cancelled. Nothing changed.")Example hooks/on_install.py (system work with ctx.shell()):
def run(ctx):
data_dir = ctx.env["FAMSTACK_DATA_DIR"]
ctx.step("Creating directories...")
ctx.shell(f"mkdir -p {data_dir}/docs/paperless/media")
ctx.shell(f"mkdir -p {data_dir}/docs/postgres")
ctx.shell(f"chmod 700 {data_dir}/docs/postgres")Example hooks/on_install_success.py (API work):
def run(ctx):
secret = ctx["secret"]
step = ctx["step"]
existing = secret("API_TOKEN")
if existing:
# Verify it still works (may be stale after destroy + up)
try:
ctx["http_get"](
"http://localhost:42020/api/documents/",
headers={"Authorization": f"Token {existing}"},
)
return # still valid
except Exception:
step("Stored token invalid — obtaining new one")
step("Obtaining API token...")
data = ctx["http_post"](
"http://localhost:42020/api/token/",
f"username={ctx['env']['ADMIN_USER']}&password={ctx['env']['ADMIN_PASSWORD']}",
)
secret("API_TOKEN", data["token"])
step("API token saved")Any .py file under cli/ (except files starting with _ and
post_setup.py) becomes a subcommand.
stacklets/photos/cli/seed.py → stack photos seed
stacklets/messages/cli/send.py → stack messages send
The convention:
| Attribute | Purpose |
|---|---|
HELP |
Module-level string. Shown in stack <id> --help. |
| Module body | Executed when the command runs. Has access to sys.argv for arguments. |
Files starting with _ are private helpers (e.g., _matrix.py), not
exposed as commands.
Every stacklet adds commands, so these keep them reading as one CLI. Check a new command, or a changed one, against them.
- Name the thing or the action, as an admin sees it. Never the
component that does the work (
curator,archivist) and never when it usually runs (nightly): those change, the admin's intent does not. - A thing with more than one action is a noun with verbs.
stack memory wiki update,stack memory wiki clean,stack messages room list. The bare noun lists its verbs, and the noun leaves room for a later verb such asshow. - Flags narrow or modify, and one word means one thing everywhere.
--alleverything,--dry-runpreview and write nothing,--yesskip the confirmation,--as <member>act as that family member.--jsonbelongs to the framework. Flags that contradict each other are refused in one line that names both. - A command that changes something applies by default.
--dry-runis the opt-in preview, never the other way round. - A command that asks a background worker waits until the work is done, prints each step and each item while it waits, and on timeout says how far it got. The request itself survives the timeout.
- A command returns a dict,
{"error": ...}on failure, which exits non-zero. With--jsonthat dict is all of stdout; what the command prints for people goes to stderr.
A command that records a choice in stack.toml calls
config["set_cfg"](section, key, value), the plugin-side twin of a
hook's ctx.cfg(key, value). It changes that one line in that section
and leaves the admin's comments and other sections as they are.
stack ai switch uses it to write [ai].
Every docker-compose.yml follows a set of naming and wiring conventions.
The runtime relies on these for discovery, cleanup, cross-stacklet
communication, and auto-updates.
name: stack-docsSet explicitly. Format: stack-{stacklet_id}. This prevents Docker from
deriving a project name from the directory path, which breaks when the
repo is cloned to a different location.
services:
stack-docs-paperless:
container_name: stack-docs-paperlessFormat: stack-{stacklet_id}-{service}. Both the service key and
container_name use the same value. Single-container stacklets can
omit the service suffix: stack-chatai, stack-bots.
This convention enables:
stack psto group containers by stackletstack uninstallto find and remove all famstack containers (docker ps -a --filter "name=^stack-")- Inter-container references by predictable name
(
http://stack-docs-paperless:8000)
networks:
stack:
external: trueAll containers join the shared stack network, declared as external.
Created by stack init, removed by stack uninstall. Every service in
every stacklet must include this.
Containers reference each other by container name across stacklets:
environment:
PAPERLESS_URL: http://stack-docs-paperless:8000
MATRIX_HOMESERVER: http://stack-messages-synapse:8008Native macOS services (oMLX, whisper.cpp) are reached from containers
via host.docker.internal.
ports:
- "${PORT_BIND_IP:-127.0.0.1}:42020:8000"The PORT_BIND_IP variable controls access scope. The runtime sets it:
- Port mode:
0.0.0.0(reachable from the LAN) - Domain mode:
127.0.0.1(only Caddy reaches it)
A port the LAN needs in domain mode too is bound to 0.0.0.0 literally
instead: infra's DNS (53) and proxy (80, 443), and Forgejo's SSH, which
does not go through Caddy and is encrypted and authenticated by itself.
The host port is the stacklet's declared port from stacklet.toml.
The container port is whatever the upstream service uses internally.
volumes:
- ${PAPERLESS_DATA_DIR}/media:/usr/src/paperless/media
- ${PAPERLESS_DB_DATA}:/var/lib/postgresql/dataBind mounts to {data_dir}/{stacklet_id}/. All paths come from
environment variables rendered by the runtime — never hardcoded.
This makes the data directory discoverable and consistent:
~/famstack-data/docs/, ~/famstack-data/photos/, etc.
Named Docker volumes are avoided. Bind mounts are explicit, visible in the filesystem, and easy to back up.
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"Every container gets this label. Watchtower (in the core stacklet)
monitors labeled containers and pulls new images on the nightly schedule.
Set channel = "none" in stacklet.toml to disable.
Watchtower pulls the tag the compose file names, so the tag decides how far
an image can move. Pin the minor line where upstream publishes one
(gotenberg:8.37), otherwise the exact version (synapse:v1.161.0), or a
digest when upstream tags no release at all. Watchtower then delivers patch
releases, and a minor or major version changes only with a commit. Immich
keeps to semantic versioning and tags only its major line, so it is pinned
to that (immich-server:v3). For PostgreSQL the major tag (postgres:16-alpine) is already the patch line:
its minor releases are bug and security fixes. Named tags such as latest,
main or release fail tests/unit/framework/test_compose_pins.py.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5Supporting services (databases, caches) should declare Docker health
checks so depends_on with condition: service_healthy works. The main
service uses [health] in stacklet.toml instead — the runtime polls
it after docker compose up.
restart: unless-stoppedAll containers use unless-stopped. They survive Docker daemon restarts
and host reboots, but stay down if explicitly stopped with stack down.
depends_on:
stack-docs-db:
condition: service_healthy
stack-docs-redis:
condition: service_healthyWithin a stacklet, use depends_on with health conditions. Across
stacklets, use requires in stacklet.toml — the runtime enforces
ordering at the CLI level.
name: stack-docs
services:
stack-docs-paperless:
container_name: stack-docs-paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.0
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
networks:
- famstack
depends_on:
stack-docs-db:
condition: service_healthy
volumes:
- ${PAPERLESS_DATA_DIR}/data:/usr/src/paperless/data
environment:
PAPERLESS_DBHOST: stack-docs-db
PAPERLESS_DBPASS: ${DB_PASSWORD}
ports:
- "${PORT_BIND_IP:-127.0.0.1}:42020:8000"
restart: unless-stopped
stack-docs-db:
container_name: stack-docs-db
image: postgres:16-alpine
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
networks:
- famstack
volumes:
- ${PAPERLESS_DB_DATA}:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
famstack:
external: trueThe runtime operates in one of two modes based on stack.toml:
Port mode (domain = ""):
- Services bind to
0.0.0.0:<port> - URLs are
http://hostname:port - Caddy does not start
- Zero DNS setup required
Domain mode (domain = "home.internal"):
- Services bind to
127.0.0.1:<port>(only Caddy reaches them) - URLs are
http://photos.home.internal, orhttps://oncedns_provideris set (see TLS below) - The runtime assembles the Caddyfile from
caddy.snippetfiles - Needs two DNS records to the server's LAN IP,
*.<domain>and<domain>, because a wildcard does not cover the bare name
A stacklet that serves something in a browser ships its routes in a
caddy.snippet:
# stacklets/docs/caddy.snippet
docs.{$STACK_DOMAIN} {
reverse_proxy stack-docs-paperless:8000
}
The domain stays the {$STACK_DOMAIN} placeholder. Caddy substitutes it
from its own environment when it loads the file; the runtime never does.
Backends are container names on the stack network.
In domain mode the runtime assembles the snippets of every stacklet that
is up into {data_dir}/infra/Caddyfile, in stacklet order, and the infra
stacklet's Caddy container mounts that file. "Up" is read from Docker like
every other state: the stacklet has containers running, starting, or
restarting. A crash-looping stacklet keeps its routes, because its other
containers are usually still serving.
stack up, stack down and stack destroy write the file, each counting
the stacklet it acts on as already in its new state, and have a running
Caddy load it with caddy reload once the containers have changed. The
file is written before a starting stacklet's containers exist, because
infra's own Caddy reads it the moment it starts. A reload Caddy rejects
leaves it serving its previous routes, and the command prints a warning.
Nothing is written in port mode, or while infra is not up.
Around the snippets the assembler (lib/stack/caddy.py) emits:
- A global options block: how certificates are obtained, or, without TLS, every site served as plain HTTP on port 80.
- A
*.{$STACK_DOMAIN}site that answers 404 for any host no running stacklet claims, a stopped stacklet's included.
Snippets describe routes only. Anything that applies to every site belongs in the assembler, not in a snippet.
Sites for services that are not stacklets go in
{data_dir}/infra/Caddyfile.local. The admin writes it and the runtime
only reads it, appending it after the snippets every time it writes the
Caddyfile; stack up infra applies an edit. Only one proxy can own ports
80 and 443, so a hand-written Caddyfile cannot run beside this one. A local
site gets the wildcard certificate like any other subdomain. A host that a
running stacklet also serves makes Caddy reject the reload, and the
command prints the warning. stack destroy infra deletes the file with
the rest of infra's data.
[core] dns_provider ("hetzner" or "cloudflare") turns on HTTPS. The
global block then makes Let's Encrypt the issuer for every certificate,
through a DNS-01 challenge at that provider:
{
cert_issuer acme {
dns hetzner {env.DNS_API_TOKEN}
propagation_delay 30s
propagation_timeout 5m
}
}
Cloudflare's block has the dns line only. DNS-01 needs nothing on the
LAN to be reachable from the internet, and it is the only challenge that
issues a wildcard. Caddy obtains two certificates: *.<domain>, for the
catch-all site, which every subdomain site then uses, and <domain>
itself, which a wildcard does not cover.
The token is the infra stacklet's secret DNS_API_TOKEN. stack infra dns-token stores or replaces it, and the first stack up infra asks for
it in a terminal. It reaches Caddy as an environment variable and is read
when a certificate is requested, so it appears neither in the Caddyfile
nor in the config Caddy serves on its admin endpoint. infra's on_start
refuses a provider the image has no plugin for, or a missing token.
With dns_provider set, the URLs the runtime renders ({url},
{<id>_url}, {home_url}) are https://. [core] https = true does the
same for a reverse proxy in front of the stack that is not this one.
Declared in [env].generate. Stored in .famstack/secrets.toml,
namespaced by stacklet ID:
photos__DB_PASSWORD = "xK7mQp3JvR2nYs8LwB4dN6..."
docs__DB_PASSWORD = "aB3cD4eF5gH6iJ7kL8mN9..."
docs__API_TOKEN = "060068ace4f65db88c52da..."
global__ADMIN_PASSWORD = "muw7suf7"Properties:
- Auto-generated on first
stack upif missing - Never overwritten on subsequent runs
- Preserved across
stack destroy(so re-enable reuses credentials) - Gitignored
- 32-character alphanumeric for service secrets
- 8-character lowercase+digits for admin passwords (typed on phones)
A single admin password is generated once and shared across all stacklets.
Stored as global__ADMIN_PASSWORD in secrets.toml. Available to templates
as {admin_password}.
Identity (who the admin is) lives in users.toml. Credentials (the
password) live in secrets.toml. Never in the same file.
users.toml defines the family members. Identity only — no passwords.
[[users]]
id = "homer"
name = "Homer"
email = "homer@home.local"
role = "admin"
[[users]]
id = "sarah"
name = "Sarah"
email = "sarah@home.local"
role = "member"
stacklets = ["photos", "documents"]| Field | Description |
|---|---|
id |
Username. Used in CLI commands and as default login name. |
name |
Display name. |
email |
Email address. Used as login for services that require one. |
role |
admin (created on every stacklet), member, or restricted. |
stacklets |
Which services this user gets an account on. Admins ignore this — they're created everywhere. |
One file, committed to the repo. User edits it directly.
[core]
name = "famstack" # product name: CLI branding and
# derived paths (~/<name>-extensions).
# Missing = "stack". An instance
# without it whose data dir exists
# as <name>-data gets it written on
# the next command.
domain = "" # empty = port mode
dns_provider = "" # domain mode: "hetzner" or
# "cloudflare" serves HTTPS with
# certificates via DNS-01; empty
# serves plain HTTP. The API token
# is a secret (`stack infra
# dns-token`), never in this file.
host = "" # port-mode host override.
# Empty = auto-detect LAN IP.
# Set to "localhost" for
# single-machine testing, or a
# custom hostname for stable
# access across network changes.
data_dir = "~/famstack-data"
timezone = "Europe/Berlin"
language = "de" # document tags, UI language (de, en)
shared_bucket = "family" # slug for the shared/institutional
# bucket inside the memory vault.
# Personal entities (homer, marge, …)
# live at <vault>/<localpart>/. Default
# "family" fits famstack; non-family
# deployments override.
[updates]
schedule = "0 0 3 * * *" # Watchtower cron (3am nightly)
[ai]
openai_url = "http://localhost:8000/v1"
openai_key = "local"
default = "mlx-community/Qwen2.5-14B-Instruct-4bit"
whisper_url = "http://localhost:6111/v1"
language = "en"
[backup]
# One block per destination. Engine name selects the implementation.
[backup.targets.vault]
engine = "external-disk"
disk = "backup-vault"
schedule = "0 2 * * *"[backup.targets.<name>] defines one destination. <name> is the user's
label for that destination (any string). The required engine field
picks the implementation under stacklets/backup/engines/. Today only
external-disk ships. Engine-specific fields (disk, schedule,
future repository, password) live alongside engine in the same
block.
Stacklets never read stack.toml directly. The runtime resolves
template variables and passes everything through the rendered .env.
Gitignored. Created by stack init. Contains:
| File | Purpose |
|---|---|
secrets.toml |
Auto-generated credentials (passwords, API tokens). |
*.setup-done |
Marker files. Gates once-only hooks (on_install, on_install_success). |
No enabled file — stacklet state is derived from Docker containers
and the filesystem. See States.
Deleted entirely by stack uninstall.
One repo can power more than one stack instance. A stacklet definition (code, compose file, hooks) is shared; the instance (config, secrets, state, data) is swappable.
- Repo root — holds
stacklets/andlib/. Discovered by walking up from the current working directory; outside any checkout, the checkout the CLI runs from. That is how the installer's globalfamstackcommand (in Homebrew'sbin) works from any directory. It names only the checkout, soSTACK_DIRselects the instance through it as it does through./stack. - Instance dir — holds
stack.toml,users.toml,.stack/. Holds the data referenced by[core].data_dir. Defaults to the repo root.
Point the CLI at a different instance with STACK_DIR:
STACK_DIR=tests/e2e/instance stack up docs
The same stacklets/ definitions apply, but config, secrets, setup
markers, and data all live under that directory instead. If STACK_DIR
is set to a non-existent path, the CLI fails fast rather than silently
falling back.
Instances share Docker container names (stack-docs, stack-messages),
so two instances cannot run concurrently on the same machine — stack down the active one before bringing another up. Useful for: dedicated
test environments, sandboxes, experimenting with a clean state without
touching your real household install.