App-creating app for HomeFree
  • Python 59%
  • JavaScript 19.2%
  • CSS 9.4%
  • Nix 8.6%
  • HTML 3.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ellis Rahhal a5929517ba README: describe the nixosModules.homefree contract
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.
2026-08-12 15:59:58 -07:00
apps/homefree-ai generator: author plugin flakes with nixosModules.homefree 2026-08-12 15:48:22 -07:00
docs moved to non-nix managed podman containers 2026-06-01 23:37:15 -07:00
.gitignore first revision 2026-05-29 22:54:26 -07:00
AGENTS.md guidance updates 2026-06-03 19:05:40 -07:00
CLAUDE.md guidance updates 2026-06-03 19:05:40 -07:00
flake.nix flake: export nixosModules.homefree 2026-08-12 15:47:05 -07:00
README.md README: describe the nixosModules.homefree contract 2026-08-12 15:59:58 -07:00

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.

  1. 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 run git init && git add -A && git commit inside it.
  2. Open the HomeFree admin panel and go to Developers → Custom Flakes.
  3. Click Add a custom flake and choose:
    • Local repository — file-browser to this directory, or
    • Remote URLgithub:<you>/homefree-ai (or wherever you publish it).
  4. Click Register flake, then Apply Changes.
  5. Open Services → HomeFree AI, enable it, choose a provider, Apply Changes.
  6. 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-apps user.
  • 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 in build_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.