- Python 59%
- JavaScript 19.2%
- CSS 9.4%
- Nix 8.6%
- HTML 3.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The prompt that authors generated flakes moved to nixosModules.homefree; this described the old shape two lines apart from it, which is the copy that goes stale without anything failing. |
||
| apps/homefree-ai | ||
| docs | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| flake.nix | ||
| README.md | ||
homefree-ai
A HomeFree flake plugin that turns natural-language prompts into HomeFree plugin scaffolds — the same recursive idea as lovable.dev / gpt-engineer, narrowed to HomeFree's plugin contract.
The service runs as a HomeFree app: a small FastAPI backend, a
single-page vanilla-JS frontend, an OCI container built from nixpkgs,
SSO-gated by HomeFree's Caddy oauth2-proxy flow. The user enters a
prompt ("build me a HomeFree plugin called homefree-feedreader that
runs Tiny Tiny RSS on port 3060"); the model produces a complete
plugin-flake directory on disk; the user registers it through the same
admin panel Custom Flakes page they used to register this plugin.
It works against the Anthropic Claude API and against any server
that speaks the same /v1/messages protocol — including a local
genai-server, whose LiteLLM bridge does. The operator declares one or
more backends; the model and backend are then chosen inside the app.
What it adds
HomeFree AI runs as a single container, reverse-proxied at
appforge.<your-domain>. Once registered it appears in the admin panel
under Services → HomeFree AI with these options:
- enable — turn the service on/off
- public — expose it on the WAN port
- subdomain — defaults to
appforge - default-backend — which backend is used until one is picked in the
app; defaults to
anthropic - anthropic-effort — default reasoning effort, for backends that support extended thinking
- debug-browser — ship headless Chromium so the agent can see the rendered page (default on; costs ~0.5GB of closure). See Debugging the running app
- agent-deploy — let the agent build and restart its own app's container instead of waiting for a human to press Publish between attempts (default on)
backends is set in the host config rather than the service dialog, because its entries are deployment facts — which machine, which key file. Each entry is:
{
id = "genai"; # referenced by default-backend
label = "Local (genai-server)"; # shown in the picker
base-url = "http://logistikon.lan:4000"; # null = the real Anthropic API
models = [ "coder-pro" "qwen-dense" ]; # offered in the picker
models-url = "http://logistikon.lan:8897/v1/models"; # optional discovery
api-key-file = null; # null = needs no key
supports-effort = false; # extended-thinking fields
supports-vision = null; # null = infer from base-url
}
Three of those deserve a note. supports-effort must be false for
anything merely Anthropic-compatible: the thinking/output_config
fields are forwarded verbatim and a server that has never heard of them
fails the request. models-url is separate from base-url because
LiteLLM answers /v1/models with a single wildcard entry — discovery
has to ask whatever actually knows the fleet. And supports-vision
decides whether the agent's inspect_page tool may hand this backend a
screenshot; left null it infers true for the real Anthropic API and
false for anything with a base-url, so set it explicitly if your local
server does take images.
The web UI is gated by HomeFree's Zitadel SSO. A backend with
api-key-file = null needs no key at all, which is the normal case for a
local server. For one that does, the key is read at runtime from
/var/lib/homefree-secrets/homefree-ai/anthropic-api-key; populate it
once and systemctl restart homefree-ai (the file is mounted
read-only into the container, but re-read on every request, so a
restart is only needed if the file was empty at boot).
How to add it to HomeFree
This plugin is registered through the admin panel — no command line,
no editing /etc/nixos by hand.
- Put this repository on the HomeFree machine (it must be a git
repository — a
git+file://flake requires one). Either clone it there, or copy the directory and rungit init && git add -A && git commitinside it. - Open the HomeFree admin panel and go to Developers → Custom Flakes.
- Click Add a custom flake and choose:
- Local repository — file-browser to this directory, or
- Remote URL —
github:<you>/homefree-ai(or wherever you publish it).
- Click Register flake, then Apply Changes.
- Open Services → HomeFree AI, enable it, choose a provider, Apply Changes.
- Populate the API key (for a backend that needs one provider) — see below.
To remove it: delete the entry on Custom Flakes and Apply Changes.
Populating the Anthropic API key
Until the admin UI grows a generic secrets-entry page, set the key once over SSH:
sudo install -d -m 700 /var/lib/homefree-secrets/homefree-ai
sudo install -m 600 /dev/stdin /var/lib/homefree-secrets/homefree-ai/anthropic-api-key <<'EOF'
sk-ant-...
EOF
sudo systemctl restart homefree-ai
Using a local genai-server
genai-server exposes an Anthropic-protocol bridge (LiteLLM) that takes any model name and routes it to whatever is loaded, so it needs no API key on a trusted network. Declare it as a backend in the host config:
services.homefree-ai.backends = [
{
id = "anthropic"; label = "Anthropic (Claude)";
models = [ "claude-opus-4-8" "claude-sonnet-4-6" ];
api-key-file = "/secrets/anthropic-api-key";
supports-effort = true;
}
{
id = "genai"; label = "Local (genai-server)";
base-url = "http://<genai-host>:4000";
models-url = "http://<genai-host>:8897/v1/models";
models = [ "coder-pro" "qwen-dense" ];
}
];
Then pick the backend and model in the app's Settings dialog. The
choice persists in /data/runtime-settings.json, so it survives rebuilds
and needs no restart.
Note the GPU is shared: a generation run will evict whatever chat model is loaded on that machine.
How it works
flake.nix # exposes nixosModules.homefree
apps/homefree-ai/
default.nix # the NixOS module — declares options, container,
# systemd service, and the homefree.service-config
# entry that drives reverse proxy / SSO / backup.
image.nix # OCI image: python3 + fastapi/uvicorn/anthropic
# + jinja2 + httpx, plus the ./app source tree.
icon.svg # admin-panel tile icon
app/ # Python source copied into the image
main.py # FastAPI app: project APIs + SSE token stream
settings.py # env-var loader
providers/ # LLM provider abstraction
generator/ # agent, tools, orchestrator, deployers
agent.py # tool definitions + per-deployment gating
debug_tools.py # observe the running app (see below)
orchestrator.py # agent events → the UI's SSE shape
runtime/ # the podman/app-hosting layer
podman_client.py # libpod REST over the rootless socket
browser.py # headless Chromium over CDP
proxy.py # Host-header routing to deployed apps
static/ # vanilla-JS single-page UI
flake.nix's nixosModules.homefree is what HomeFree composes into the
system build (it prefers that attribute and falls back to default,
which this flake keeps as an alias). apps/homefree-ai/default.nix is an ordinary HomeFree
module — it declares options in both homefree.services.homefree-ai
and homefree.service-options.homefree-ai (the dual-namespace pattern
required for plugin flakes, see
~/homefree-navidrome/README.md for the rationale), wires a podman
container with virtualisation.oci-containers, and contributes a
single homefree.service-config list entry that the rest of HomeFree
consumes (Caddy reads the reverse-proxy block, restic reads the backup
paths, the admin panel reads the options-metadata).
Backends
There is no per-vendor provider class. Every backend speaks Anthropic's
/v1/messages, so generator/agent.py builds one PydanticAI
AnthropicModel and the backend contributes only a base_url (and
whether it needs a key). A second client implementation would mean a
second set of tool-calling semantics to keep in step, which in an agent
loop is where things break quietly.
settings.Backend describes an entry; settings.py resolves which one
is selected (runtime choice, else the configured default) and where its
key lives. providers/ is now just the ProviderError type.
A provider that does not speak /v1/messages — OpenAI's own API,
Gemini — would need pydantic-ai-slim[openai] adding to image.nix and
a branch in build_agent. Nothing else changes.
The generation flow
The model runs as a PydanticAI agent (generator/agent.py) driven by
generator/orchestrator.py, which translates the agent's event stream
into the SSE shape the UI renders as chat bubbles and action cards.
Rather than emitting a custom block format for a parser to scrape, the
agent calls typed tools: write_file, read_file, list_files, and
ask_user (which pauses the run and surfaces a question in the chat —
the user's answer becomes the tool's return value). Files land under
/data/projects/<slug>/; any path escaping the project root is
rejected before the write.
Debugging the running app
A code generator that can only write is guessing. Once a project has been published, the agent gets a second set of tools — its own eyes on the thing it built:
| tool | what it sees |
|---|---|
app_status() |
is it deployed, is the container running, last error |
app_logs(tail) |
the container's stdout/stderr — where tracebacks are |
http_request(path, …) |
the server's response: status, headers, body |
run_in_app(command) |
a shell inside the app container (ls /data, cat, ps) |
inspect_page(path, …) |
headless Chromium: rendered DOM, console, exceptions, failed requests, optional screenshot |
deploy_app() |
build and restart the container, capped per turn |
inspect_page is the one that matters most. http_request returns the
HTML before any JavaScript runs, so the single most common failure in
a generated app — a page that renders blank because a script threw on
boot — is invisible to it. Chromium sees the post-JS DOM, every console
message, every uncaught exception and every subresource that 404'd. It
can also run a snippet in the page first (click a button, fill a form)
and report the resulting DOM, render at a phone viewport to check the
responsive layout, and return a PNG for questions about appearance that
the DOM can't answer.
Name resolution is disabled inside that browser (--host-resolver-rules
maps everything to NOTFOUND except the app's own address). The app is
reached by IP so it is unaffected, while any third-party URL the
generated app embeds fails with ERR_NAME_NOT_RESOLVED and shows up in
the report — which makes the inspector an enforcement point for the
zero-external-requests rule rather than just an observer of it.
With deploy_app the loop closes: write a fix, deploy it, look at the
result, iterate — without a human clicking Publish between every
attempt. It runs the same deployer.start/deployer.stream path the
button does, so there is no agent-only deploy path to drift from the
one users exercise.
These tools are registered only for the podman (dockerfile) track and
only where the deployment actually has them: no Chromium in the image
means no inspect_page, agent-deploy = false means no deploy_app,
and a backend that takes no images gets the text report without the
screenshot. A tool the model is told about but cannot use costs a
wasted turn every time it reaches for one.
Security model
- The UI is fully SSO-gated (
oauth2 = true,kind = "caddy_gated"). - The Anthropic key is owned by root and mounted read-only into the container.
- The generator only writes inside
/data/projects/<slug>/; path traversal is rejected before any write. - The agent's reach stops at its own app. It can exec in, HTTP to,
read logs from and redeploy the container it authored, over the
same rootless podman socket and podman bridge the meta-proxy already
uses — no new privilege, and nothing that touches the host, another
app, or the live HomeFree configuration. Everything runs as the
unprivileged
ai-appsuser. - The headless browser resolves no hostnames at all and is given a throwaway profile per call, so it cannot reach the internet, the LAN, or any other HomeFree service.
- Flake-mode (dev-mode) projects get none of these tools. They deploy
through
nixos-rebuild, and triggering a system rebuild is not something an agent does unattended.
Limits and future work
- Backends must speak Anthropic's
/v1/messages. That covers the real API and anything bridging to it (genai-server's LiteLLM); native OpenAI/Gemini would need the[openai]extra and a branch inbuild_agent. - The API key is set over SSH today; the admin panel will eventually grow a generic secrets-entry page.
- Generated plugins are scaffolds — the user should still read and trim the output, and is expected to test in a HomeFree VM before deploying to a real box.
- The debug tools cover the podman track only. A flake-mode project has no container homefree-ai owns, so there is nothing to exec into and its logs live in the host journal, which the container can't read.