Making it ready for other people

Until today EmptyOS was deeply private. The kernel worked, the apps worked, but everything was shaped around one person's vault. There was no story for "here is how you would install this" and no story for "here is what you would publish from it." Every assumption pointed inward. Today we turned the system around and asked what it would take to give it away.

The first move was making one of the built-in apps genuinely community-shaped. Music-studio moved from apps/personal/ into apps/ — but the move only worked because we introduced two SDK primitives alongside it. BaseApp.app_config(key, default) reads [apps.<id>] from emptyos.toml, so anything machine-specific (a service URL, an extra field to track, an artist name) stays in gitignored config. VaultLibrary(extra_fields=...) accepts a runtime list of additional frontmatter fields, so each install can widen the schema without forking the code. The principle crystallised as rule #15 in CLAUDE.md: community apps, personal config. The same code, different behaviour per machine. It's the only reason community distribution is possible without a hundred per-user branches.

The second move was a publishing pipeline. A new apps/publish/ app took a vault and turned it into a deployable static site — builder.py for the scan and render, renderer.py for vault-flavoured markdown (wikilinks, callouts, code), templates.py for themes, and a management UI with 14 endpoints. A split-pane editor (writer.html) with AI polish/expand/compress/translate actions and a live preview landed on the same afternoon. The point wasn't to build another blog tool; it was to close the loop between "writes in the vault" and "reads on the web" so the vault can speak publicly without leaving the system. By nightfall a blog was live with three posts and a project site was live alongside it.

One publish pipeline turned into two with a small frontmatter change: layout: landing on a page causes the builder to render a hero + feature grid + CTA buttons instead of the usual blog shell. nav_order gives docs a sidebar. The builder now detects mode (project vs blog) by whether any page declares landing and adjusts. Multi-site storage moved into sites.json with per-site source folder, theme, repo, and domain — five CRUD endpoints, site switcher tabs in the dashboard, per-site build/deploy state. A legacy single-site config migrates to the new shape on first run. This is the same app serving blogs, docs, and landing pages for different vaults on different domains without conditional logic anywhere.

Release infrastructure came next. release.toml declares two distribution tiers — core (infrastructure essentials) and standard (everything generic enough for anyone). A packaging script walks the manifest, copies only the allowed paths into dist/, and runs two safety checks against every file it emits: check-personal.py (personal data) and check-branding.py (third-party names in user-facing text). Both scanners are driven by regex files in the repo root (.eos-personal, .eos-branding) so the rules are data, not code. The first package came out clean: no paths, no names, no vault snippets. A release app at /release/ wrapped the packaging script in a web UI so the whole thing is self-hosted.

Alongside the packaging, the public docs finally got written. README.md as the front door. docs/GETTING-STARTED.md for install → vault → AI setup. docs/APP-DEVELOPMENT.md as a build-your-first-app walkthrough. LICENSE (MIT). Internal-only docs (migration notes, backlog, system internals) stay in the repo but are excluded from release packages by release.toml path filters. The project now has a face; before today all it had was code.

One unglamorous but load-bearing change underneath all of this: data coupling decoupling. Before today, four apps scanned 10_Projects/ directly and three scanned 50_Journal/ directly — any structural change to those folders would ripple through every reader. We added get_deadlines() / get_all_tasks() on projects and get_tasks() / get_weekly() on journal, then migrated the consumers to call through call_app() instead of the filesystem. The task app went from a monolithic vault scan to local-folder scanning + parallel delegated fetches via asyncio.gather. It's the same rule as before — events over imports — applied to data reads. Vault folders are no longer a public interface; the owning app is.

Three smaller things worth mentioning. Boot sequence management (eos boot --generate / --install / --uninstall) reads emptyos.toml and emits a Windows VBS that starts ComfyUI, Applio, voice-api, then EmptyOS — replacing four scattered autostart scripts with one config-driven file. Skill consolidation: six overlapping skills became four (eos-system-check-and-fix, eos-session-wrapup, eos-external-vault-connector, eos-system-install) each with a clear lifecycle slot. And an EOS signature theme — warm paper background, cream cards, deep violet accent, a frosted nav, and an ensō favicon to replace the old bullseye. Identity matters once the system has a public face.

Left for later: the blog side of the pipeline needs podcast embeds that survive preview mode (partially fixed, still flaky around media path rewriting in sub-directories), and the release packaging script assumes a clean repo — it'll need an explicit "release from tag" mode before the first real version gets cut. Neither blocks the larger shift. A week ago the system was inward-facing. Tonight it has two sites deployed, a release package verified clean, and a README that answers "what is this." That's enough of a door to let someone else in.

Related Posts

← Back to posts