Getting Started
Prerequisites
- Docker, usable without sudo
(
sudo usermod -aG docker "$USER", then reboot).existential.shrefuses to run under sudo.- The stack starts with the host. For rootless Podman,
existential.shturns on its restart service and linger, and warns if Docker itself isn't set to start at boot. - Docker Desktop: turn on "Start Docker Desktop when you sign in".
- The stack starts with the host. For rootless Podman,
- A computer to host it (see Hosting): Linux, or a Mac with Docker Desktop.
- Windows: run everything inside WSL2. Clone the repo on the Linux side (not under
/mnt/c) and install Tailscale inside WSL.
- Windows: run everything inside WSL2. Clone the repo on the Linux side (not under
- For Core, Tailscale, logged in on that host and on every device that should reach it. Without it, see Reaching it without Tailscale.
- The repo:
git clone https://github.com/jtmckay/existential.gitcd existential
Setup
./existential.sh
docker compose up -d
The first run asks one thing: what to start with.
- Core (Enter): it then asks for a username and password. No GPU, no model downloads.
- Controller: Core, and the host your other hosts report to. See Controller and Workers.
- Worker: no services. It asks for its controller's address and reports this host's disks and automations there.
- Nothing:
./existential.sh serviceturns on what you want; the login is asked when the first service that needs it comes on.
| Access | Tailscale on the host: HTTPS on every device you log in |
| Workspace | code-server (editor + terminal over the work dir) |
| Automation | Decree (the automation daemon), ntfy (where automations report in) |
Four flows are on by default: the morning digest, the look backs, run cleanup, and a health check on ntfy.
Core ships no backups. Turn on automation-backup, or put
volumes/workspace_data under git with a remote you choose.
Turning on more
./existential.sh flow # pick from the list
./existential.sh flow on weekly-newsletter # or by name
A flow is one thing the stack does for you: your own newsletter, transcribed voice memos, bank alerts as budget entries.
flow onasks before it turns on each service it needs, turns on its machine, rebuilds, and prints any step only you can do (an OAuth grant, a password).docker compose up -dstarts what is new.flow off <name>turns the machine off and leaves the services alone.- The full list: Flows.
A service no flow needs (Caddy, Grafana, Mealie, ...) is one flag in .env.shared,
EXIST_IS_<CATEGORY>_<SLUG>=true, then ./existential.sh again.
What each ./existential.sh run does:
- Renders every
*.exist.*template, prompting forEXIST_CLIvalues and generating passwords and keys - Runs each enabled service's
exist.initial.sh(certs, caches, seed config) - Generates one
docker-compose.ymlfrom every enabled service - Generates the root
.envfrom.env.sharedand every enabled service's.env
With Tailscale up, it also runs tailscale serve:
- code-server at
https://<host>.<tailnet>.ts.net, ntfy at:8443, with a Let's Encrypt certificate. - Both containers publish on loopback only, so only your tailnet can connect.
- The name is saved as
EXIST_TAILNET_NAME, which ntfy and the digest's links use. - The name appears in public certificate transparency logs (visible, not reachable).
If Tailscale can't serve yet, the run prints what is missing and carries on:
- MagicDNS and HTTPS Certificates on in the admin console
sudo tailscale set --operator=$USER, once. The script never runs sudo itself.
Reaching it without Tailscale
Turn on Caddy (EXIST_IS_HOSTING_CADDY=true). Every service gets
https://<slug>.<domain>, with certificates from Caddy's own CA, trusted once per device.
EXIST_DOMAINdefaults to<lan-ip-with-dashes>.nip.io: public wildcard DNS that resolves on any device on your network with no setup.- If Tailscale is up, the tailnet IP is used instead, so the names work across your tailnet.
Don't set EXIST_DOMAIN to a MagicDNS name like my-box.tailnet.ts.net. MagicDNS resolves that
node name only, so every <slug>. under it fails. Use the tailnet IP in nip.io form.
From outside the house: a VPN back to your network, or a piHole on another server. See VPN.
The hardware questions
Asked once, by the run that enables the first service that needs a GPU (Ollama, ComfyUI, …). Core never asks.
- GPU vendor:
nvidia,amd(Vulkan/ROCm),none(CPU), orexternal: models live on another host and no Ollama runs here. PointEXIST_MODEL_URLat any OpenAI-compatible/v1server. - VRAM picks the model tier. Each model role can live on a different machine: Configuration.
Re-ask both with ./existential.sh run models.
Upgrading
git pull && ./existential.sh. Rendered files are never overwritten, except:
- New template keys are appended to your
.envfiles and listed. - Newer service images are listed, and updated in your compose files if you say yes.
Every run also pulls and builds the images, without touching what's running. The next
docker compose up -d switches to them. See
what happens when you git pull.
Enable/Disable Services
A flow asks for each service it needs. For anything else:
./existential.sh service # pick: Space switches one, Enter applies
./existential.sh service on ollama # or by name
./existential.sh service off nocodb
Each sets that service's EXIST_IS_* flag in .env.shared, then rebuilds: templates render for
newly enabled services and existing files are left alone. A service turned off keeps running until
docker compose up -d --remove-orphans.
Deploy
docker compose up -d
Open code-server at https://<host>.<tailnet>.ts.net. That is the workspace;
start claude in its terminal.
Decree waits for each migration's service to pass its exist.test.sh, then runs the migration.
To see what is working:
./existential.sh test services # every enabled service validates itself
./existential.sh run footprint # memory limits vs actual use
With automation-backup on, triage runs those checks on a schedule and notifies you when something breaks or recovers.
Logging in
- Most services use
EXIST_USERNAME/EXIST_PASSWORD, asked when the first service that needs them is turned on (Enter givesadminand generates a password) and stored in.env.shared. See Identity and permissions. - Edit
.env.sharedor a service's own.env, never the root.env: it is regenerated every run. - After a change:
./existential.sh, thendocker compose up -d.
Workspace
workspace/ (in volumes/workspace_data) is your directory:
notes/: your notes, plain markdown. Starts withmy-first-note.md..decree/: your automations, run by theautomationdaemon on the next tick. The repo's machines arrive as plugins, only the ones you have on, at the version in your checkout.graph/draws every machine. See Writing a Plugin.ai/: what automations write, indexed like everything else..capture/: everything you typed to an agent. See Capture.ai/you-were-here.md: the morning digest, with one file per day underai/you-were-here/<YYYY>/. See You were here.
In Core, code-server opens it at /workspace with claude,
opencode and pi in the terminal. The workspace carries the decree skill in .claude/skills/decree/,
so claude there already knows how to write an automation. Add-ons mount the same tree:
Nextcloud as its workspace folder.
- A file change can start an automation. A handler in
.decree/handlers.ymlruns once the file has been quiet for 30 seconds. A.gitignore'd path or anything in.git/never does. See File Handlers. - With Search on (
./existential.sh flow on search), qmd indexes the workspace within a minute of each change, and you, pi, Claude Code and opencode can search it. - To edit on other computers, sync it: the Nextcloud desktop client with Nextcloud in the stack, or a Nextcloud elsewhere. Its Ignored Files list keeps files off the server.
Capture
workspace/.capture/ keeps what you typed to an agent, one JSON line each (ts, source,
context, session, text), one file per writer and month (2026-10-code-server.jsonl).
- Only what you typed: never a response, tool output or transcript.
- Secrets (API keys, tokens,
password=-style assignments, private keys) are replaced with a marker such as[REDACTED:anthropic-key]before writing. - It never blocks the prompt. Failures go to
workspace/.capture/errors.log. - Prompts with a session id are also appended to
workspace/ai/conversations/<date>-<tool>-<session>.md, so search finds them. - One script does it all:
workspace/.decree/lib/capture/capture.sh(bash andjq).
| Source | On | Off |
|---|---|---|
| Claude Code in code-server | By default: a UserPromptSubmit hook | CODE_SERVER_CAPTURE=false in services/code-server/.env, then ./existential.sh && docker compose up -d code-server (turns off opencode too) |
| opencode in code-server | By default: a plugin, capture/opencode.ts (the capture plugin's) | The same key |
| Claude Code on another computer | bash <workspace>/.decree/lib/capture/capture.sh install <workspace dir>, with the workspace synced there | bash <workspace>/.decree/lib/capture/capture.sh uninstall |
| Anything that can POST | POST /capture on automation-webhook (EXIST_IS_SERVICES_AUTOMATION_WEBHOOK=true) | ./existential.sh flow off capture-stream |
install adds the hook to ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR's) beside your
other hooks; uninstall removes only that one.
curl -X POST https://automation-webhook.<domain>/capture \
-H "Authorization: Bearer $AUTOMATION_WEBHOOK_SECRET" \
--data-binary '{"source":"phone","text":"buy filters"}'
- Body:
{"source", "text"}JSON (optionalcontext), or plain text, filed as sourcewebhook. - The secret is
AUTOMATION_WEBHOOK_SECRETinservices/automation-webhook/.env. - Without the webhook:
cd /workspace && echo 'buy filters' | decree emit --machine capturein code-server's terminal.
You were here
Every morning at 7, you_were_here writes workspace/ai/you-were-here.md and pushes the top of
it to ntfy:
- The picture: two or three sentences.
- Waiting on you: at most three, from
.decree/attention/and runs waiting on a person. - One next thing.
- Holding steady: machines whose last run succeeded, and the active schedules.
- Where you left off: the last prompt of each agent session, by project, from capture.
- From your feeds: only if
workspace/notes/feeds.mdexists. Three to five new items from those feeds closest to your work, each shown once, never one a note already links to. - Worth learning: one Wikipedia article on a subject you've been looking into, never repeated
and never one a note links to. Only the subject phrase is sent to Wikipedia. Set the machine's
wikipediaparam to another edition (de,fr), or blank to turn it off. - Everything else (note only): decisions in
notes/decisions/, captured prompts that read like decisions, touched notes with open checkboxes, schedules, and changes since the last digest.
Details:
- The chat model writes The picture, One next thing, feed reasons and the Worth learning subject, linking each claim to its source. Everything else is built from files. Without a model, those sections are left out.
- The push is cut to fit ntfy's 4096 bytes, and skipped when nothing changed.
- Each day is kept as
workspace/ai/you-were-here/<YYYY>/<YYYY-MM-DD>.md, with a Done section. Past days are never rewritten. Worked on lines name their capture source, which work look backs filter on. - Schedule:
workspace/.decree/cron/you-were-here.md. Off:./existential.sh flow off you-were-here. - Now:
POST /you-were-hereonautomation-webhook, orecho | decree emit --machine you_were_herein code-server's terminal.note_linksets the push's last line.
Look back
look_back reads a range of day notes, writes
workspace/ai/you-were-here/<YYYY>/look-back-<from>-<to>.md, and sends a short ntfy:
- Highlights: checkboxes ticked, notes created, migrations landed, by project.
- Themes: projects that came up on more than one day.
- Decisions made: notes in
notes/decisions/and machines that landed. - By the numbers: counts only.
- In short: a paragraph from the chat model, when one is reachable.
Every item links to its day note.
- Default range: year to date.
period(week,month,quarter,year) ends atto(today when blank);from/to(YYYY-MM-DD) set either end. scope=workkeeps only whatworkspace/ai/you-were-here/scope.mdlists (projects, folders, capture sources), and writes…-work.mdbeside the full one.- Weekly (Sunday) and monthly (month-end) look backs are on by default
(
cron/look-back-weekly.md,look-back-monthly.md). Longer ranges read those instead of every day note. - With the webhook:
POST /look-back,/look-back/month,/look-back/month/work.
docker exec automation decree emit --machine look_back --param period=quarter --param to=2026-09-30 </dev/null
Syncing with a NAS or a Nextcloud (optional)
With automation-backup on, workspace-sync runs rclone bisync
every minute, both ways, with one other copy. A file changed on both sides keeps the newer edit.
- A NAS folder:
EXIST_NFS_SHARE_WORKSPACEin.env.shared, asked with the other NAS paths. Compose mounts it; when set, it is the only other copy. A change made on the NAS arrives within a minute: NFS tells nobody, so it is found by the next run. - A Nextcloud folder: in
.env,EXIST_NEXTCLOUD_DOMAIN,EXIST_NEXTCLOUD_USER,EXIST_NEXTCLOUD_APP_PASSWORD(from Nextcloud's Settings → Security). Folder:EXIST_NEXTCLOUD_WORKSPACE_DIR, defaultworkspace. - Neither set: nothing syncs.
- Never synced: anything a
.gitignoreunderworkspace/ignores, and every.git/. - WebDAV drops file modes, so each sync marks
workspace/.decree/scripts/executable again. - A sync that would delete over half the files on one side deletes nothing and waits for
docker exec automation-backup decree event <wait id> delete(orkeep). - With Nextcloud in this stack, its
workspacefolder already is this tree. PointEXIST_NEXTCLOUD_WORKSPACE_DIRelsewhere for a second copy.
Bringing in real notes (e.g. an Obsidian vault)
- Simplest: keep the vault inside
workspace/. - To edit it on other devices: sync through Nextcloud, see Obsidian.
- To pull it in from elsewhere (with automation-backup on): copy
services/automation-backup/cron.example/notes-pull.mdtoservices/automation-backup/cron/, setNOTES_PULL_SOURCEto an rsync-over-SSH path (user@nas:/path/to/vault/), and put an SSH key atautomation/secrets/notes-pull/id_ed25519. Pulls are additive unlessNOTES_PULL_DELETE=true. Pulled files fire no Nextcloud events.
Integrations
./existential.sh run automation gmail-sync # Gmail OAuth
./existential.sh run rclone # Remote file storage
See Integrations. ./existential.sh run lists every action.