The hal0 platform itself is two systemd units: hal0-api, the API
daemon everything else talks to, and hindsight-api, the memory engine
it delegates recall/extraction to. Both run as the unprivileged hal0 system
user and are managed with plain systemctl/journalctl.
hal0-api.service
The API daemon — serves /api/*, /v1/*, and the dashboard’s static
assets. Generated at install time by installer/install.sh (not a static
file in the repo):
[Unit]
Description=hal0 API daemon
Documentation=https://github.com/hal0ai/hal0
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=hal0
Group=hal0
SupplementaryGroups=systemd-journal
WorkingDirectory=${API_WORKDIR}
EnvironmentFile=${API_ENV}
EnvironmentFile=-${ETC_DIR}/hermes-python.env
EnvironmentFile=-${HF_SECRETS_ENV}
ExecStart=${HAL0_BIN} serve --port ${HAL0_PORT}
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hal0-api
[Install]
WantedBy=multi-user.target
- Journal read —
SupplementaryGroups=systemd-journallets the daemon
read the journal behind/api/logsand the MCPlogs_tailtool. The group
applies to this unit’s process tree (children such as podman and flm
inherit it); thehal0account is not added to it, so
hal0-agent@units do not start with it. Under ADR-0002 the agent shares
thehal0uid and can influencehal0-api, so this is a scoping measure,
not an isolation boundary. The installer omits the line on a host that has
nosystemd-journalgroup.hal0 updatedoes not rewrite this unit:
re-run the installer once after updating to 1.4.0.hal0 doctor allwarns
(API journal read) until it has been. Until then the Logs page’s slot
channel and the slot log drawer show a notice naming the cause and the fix (a
degradedframe, held open with keepalives) instead of reconnecting in a
loop, the one-shot slot log tail carries the same hint, and
hal0 slot logs --followprints the reason and returns. - User/group —
hal0:hal0. Root-only operations (writing per-slot
units,daemon-reload, restarting itself during an update) go through a
narrowsudo -nwrapper (hal0-systemctl) rather than running the daemon
as root. Every wrapper verb that takes a payload on stdin allow-lists that
payload on the root side and writes its own validated reconstruction, so a
hand-crafted unit body cannot smuggle a host-sideExecStartPre=or
User=rootpast it. Usehal0-systemctl check-quadlet < unit(or
check-dropin <gateway|hindsight> < body) to see why something was refused;
both validate and echo without writing anything. Note the limit of that
boundary: slots run under rootful podman and a slot’s[Container]section
legitimately mounts host paths and passes through devices, so an attacker who
owns thehal0account can still author a privileged container. The wrapper
is a check on the unit file, not a sandbox around slot content. - WorkingDirectory — the
currentsymlink in a production install, so
hal0 update’s atomic swap moves it without rewriting the unit. - ExecStart — no
--hostflag. The bind host comes from
HAL0_BIND_HOSTinsideEnvironmentFile, so the unit and thehal0 serve
CLI can never disagree about it. - Port —
8080by default (HAL0_PORTinapi.env), binds0.0.0.0
unconditionally.
Config files it reads
| File | Purpose |
|---|---|
/etc/hal0/hal0.toml |
Main config — includes the [security] table (see Authentication). |
/etc/hal0/api.env |
Required systemd EnvironmentFile, mode 0600 — port, log level, network settings, provider tokens, and (post-rotation) HAL0_ADMIN_KEY/HAL0_CLIENT_KEY. |
/etc/hal0/hermes-python.env |
Optional — Hermes interpreter policy (HAL0_HERMES_PYTHON=, see below). |
/var/lib/hal0/secrets/hal0-api.env |
Optional, root:root 0600 — HF_TOKEN and similar secrets. |
/etc/hal0/upstreams.toml |
External LLM upstream definitions. |
Hermes interpreter policy
The Hermes venv runs on exactly one Python minor, which the pinned Hermes commit decides: the newest of 3.14 and 3.12 that its requires-python admits. The current pin, v0.22.4 (4d043038), declares >=3.11,<3.15, so Hermes runs on a uv-managed CPython 3.14 under /var/lib/hal0/hermes/python/cpython-3.14*, with its dependencies installed from upstream’s hash-locked uv.lock. Before 1.6.0 the pin capped below 3.14 and Hermes ran on 3.12; the first hal0 update that carries the new pin builds the 3.14 release next to the old one, switches, and rewrites /etc/hal0/hermes-python.env (the old 3.12 value is discarded with a hermes-python-discarded warning). When a build needs an interpreter for that minor, hal0 checks these in order:
HAL0_HERMES_PYTHON, the operator’s override, from the environment and then from/etc/hal0/hermes-python.env. A value that is not the target minor is discarded with a warning, and the interpreter is re-probed. A value whose interpreter links an SQLite older than 3.51.3 is still used, with a warning.- A uv-managed CPython under
/var/lib/hal0/hermes/python, fetched by the uv that ships in hal0’s venv (uv python install --managed-python). It must link SQLite 3.51.3 or newer. - A system
python<minor>(python3.14 for the current pin) that links SQLite 3.51.3 or newer, used when uv cannot fetch one. Ubuntu 26.04’spython3.14links SQLite 3.46.1, below the floor where Hermes forces DELETE journal mode, so it is never used.
hal0’s own venv interpreter is never used for Hermes.
hal0 writes the interpreter it chose back to /etc/hal0/hermes-python.env. hal0 doctor reports the SQLite version of the live Hermes interpreter, and warns below 3.51.3.
The uv that does the fetching ships in hal0’s own venv (pinned to 0.13.4.x); the installer no longer installs it separately. uv’s cache lives at /var/lib/hal0/hermes/.cache/uv. A host with no network cannot build the first 3.14 release: the build fails (status build_failed) and the box keeps running its current Hermes. Rolling hal0 back to 1.5.x after the 3.14 build leaves the Hermes build failing (Hermes Python must be exactly 3.12) until you update again; do not edit hermes-python.env to 3.12 on 1.5.x.
Hermes releases
Every Hermes build (install, hal0 agent reprovision hermes --repair and hal0 update (including hal0 update component hermes --target)) produces an immutable release under /var/lib/hal0/hermes/releases/<id>/ with a release.json. hal0 checks it (imports, hermes --help, an MCP probe) before the current symlink moves, and /var/lib/hal0/venvs/hermes points at current/venv. Activating a release stops hal0-agent@hermes and the gateway, switches, runs hermes config migrate, writes sessions.auto_prune: false and the persona prompt into config.yaml, and restarts them. If the re-check fails, hal0 switches back to previous, restores config.yaml and reports activation_failed. The first update to 1.6.0 therefore causes a short Hermes outage. Hermes keeps session history: sessions.auto_prune is set to false, where Hermes 0.22 would otherwise delete ended sessions after 90 idle days. The directory tree is in Paths & files.
Health endpoints
| Endpoint | Auth | Checks |
|---|---|---|
GET /api/health |
open | Shallow liveness — returns immediately, no slot/upstream/disk work. What the installer and the systemd watchdog poll. |
GET /api/health/system |
open | Deep health — disk headroom on state/config roots (disk_state, disk_config: ok, free_mb, total_mb, floor_mb, path), slot-manager responsiveness, errored-slot detection. Always 200, with {"status": "ok"|"degraded", "checks": {...}}. |
GET /api/status |
client | Dashboard summary poll (hardware, slots, memory-degraded flag). |
hindsight-api.service
The memory engine — extraction, consolidation, reflection, and recall. A
static unit file shipped at installer/systemd/hindsight-api.service:
[Unit]
Description=hal0 shared Hindsight memory engine (platform brain)
After=network-online.target hal0-api.service
Wants=network-online.target
[Service]
Type=simple
User=hal0
Group=hal0
WorkingDirectory=/var/lib/hal0/memory/hindsight
Environment=HOME=/var/lib/hal0/memory/hindsight
Environment=HF_HOME=/var/lib/hal0/memory/hindsight/hf-cache
Environment=HINDSIGHT_API_LLM_PROVIDER=openai
Environment=HINDSIGHT_API_LLM_BASE_URL=http://127.0.0.1:8080/v1
Environment=HINDSIGHT_API_LLM_MODEL=hal0/utility
Environment=HINDSIGHT_API_LLM_API_KEY=hal0-local-noauth
EnvironmentFile=-/etc/hal0/hindsight-llm.env
Environment=HINDSIGHT_API_LLM_TIMEOUT=300
Environment=HINDSIGHT_API_SKIP_LLM_VERIFICATION=true
Environment=HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=true
Environment=HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=true
ExecStart=/var/lib/hal0/memory/hindsight/.venv/bin/hindsight-api --host 127.0.0.1 --port 9177
Restart=on-failure
RestartSec=3
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target
- Port —
9177, loopback only (--host 127.0.0.1). Not LAN-exposed;
hal0-api is the thing that talks to it. - Dependency direction is inverted from what the ordering suggests —
After=... hal0-api.serviceis soft ordering only (an unknown unit name is
tolerated), but the real dependency runs the other way: hindsight routes
its own extraction/consolidation/reflection LLM calls back to hal0-api’s
OpenAI-compatible endpoint (HINDSIGHT_API_LLM_BASE_URL=http://127.0.0.1:8080/v1).
hindsight-api is a consumer of hal0-api, not the reverse. - No TOML config — every setting is an inline
Environment=line in the
unit itself, with one exception:EnvironmentFile=-/etc/hal0/hindsight-llm.env
(the leading-means “ignore if absent”) overrides the inline
HINDSIGHT_API_LLM_API_KEY=hal0-local-noauthplaceholder with the real
client key.installer/install.shwrites that file at install time from
HAL0_CLIENT_KEY/HAL0_ADMIN_KEYin/etc/hal0/api.env, andhal0 auth rotatekeeps it fresh afterward. A hal0-owned drop-in,
hindsight-api.service.d/extraction-model.conf, overrides the shipped unit
without editing it. Its model line is alwaysHINDSIGHT_API_LLM_MODEL=hal0/memory,
the virtual id hal0’s/v1resolves on every call to the local
[memory.graph].extraction_slotor to the upstream model in
extraction_model. Choosing a slot or an upstream model (Engine model on
Settings → Data → Memory, orhal0 memory graph enable --slot|--model|--local)
is therefore a config write that applies at once and never restarts the
engine. The drop-in also carriesHINDSIGHT_API_LLM_TIMEOUTand the extraction
limits (HINDSIGHT_API_RETAIN_LLM_MAX_CONCURRENT,
HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS,
HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES,HINDSIGHT_API_WORKER_MAX_RETRIES,
HINDSIGHT_API_WORKER_TASK_RETRY_BACKOFF_SECONDS, and the derived
HINDSIGHT_API_RETAIN_WALL_TIMEOUT). Changing one of those
rewrites the file and restarts hindsight-api, and the first save after
upgrading from a release that pinned a slot id there repairs the drop-in the
same way. That drop-in lives under/etc/systemd/system
and hal0-api runs unprivileged, so the write and the follow-up restart both
route through thehal0-systemctlwrapper
(write-hindsight-dropin+svc-restart hindsight). If the wrapper or its
sudoers grant is missing, the API’s response carries a non-null
propagation.errorand the engine keeps the drop-in it had —
checkhal0 doctorfor the seam rows. The wrapper allow-lists the drop-in
body on the root side: only#comments, a single[Service]header and
Environment=assignments of the seven keys above are accepted
(HINDSIGHT_API_LLM_MODELmust be ahal0/<name>value), so a
hand-crafted fragment (ExecStart=,User=, any other directive) is
rejected rather than written. Run
hal0-systemctl check-dropin hindsight < bodyto see why a body was refused;
it validates and echoes without writing anything. - Overrides in
hindsight-llm.envwin. systemd appliesEnvironmentFile=
after everyEnvironment=line in the unit and its drop-ins. A
HINDSIGHT_API_LLM_PROVIDER,HINDSIGHT_API_LLM_BASE_URLor
HINDSIGHT_API_LLM_MODELline in/etc/hal0/hindsight-llm.envtherefore beats
hal0/memoryand whatever you picked under Engine model: hal0 reports the
slot or model you chose while the engine runs the override. The Engine
running row on Settings → Data → Memory (highlighted when an override applies),
hal0 memory graph statusandengine_llminGET /api/memory/graph/status
name the provider and model hindsight-api actually loads, read from the
running engine process; when hal0-api can read neither the process nor the
file, they saycannot verifyinstead of claiming no override. Remove the lines and
restart hindsight-api to hand the choice back to hal0. The installer (on a box
that has a key) andhal0 auth rotaterewrite the file with the API key line
only, which drops any override you added. - Data root — embedded Postgres at
/var/lib/hal0/memory/hindsight/.pg0,
pinned via the unit’sHOME=env (Hindsight has no dedicated data-dir
variable of its own).
Config files it reads
| File | Purpose |
|---|---|
/etc/hal0/hindsight-llm.env |
Optional EnvironmentFile, - prefixed (ignored if absent) — supplies the real HINDSIGHT_API_LLM_API_KEY, written by installer/install.sh and kept fresh by hal0 auth rotate. A HINDSIGHT_API_LLM_PROVIDER, _BASE_URL or _MODEL line here overrides hal0’s engine target. |
hindsight-api.service.d/extraction-model.conf |
hal0-owned systemd drop-in under /etc/systemd/system — sets HINDSIGHT_API_LLM_MODEL=hal0/memory, HINDSIGHT_API_LLM_TIMEOUT and the extraction limits; written when the timeout or a limit changes (hal0 memory graph enable, the Memory settings page) or the file is stale. |
Health endpoint
GET http://127.0.0.1:9177/health — polled by the installer after start (up
to 40 × 3s) and by hal0 doctor verify’s memory-engine check. If
unreachable while the memory feature is enabled, hal0’s own health report
degrades to WARN "engine enabled but :9177 unreachable" rather than
failing hard.
Starting, stopping, and inspecting
Same commands for both units:
systemctl status hal0-api
systemctl start hal0-api
systemctl stop hal0-api
systemctl restart hal0-api
systemctl enable hal0-api # already enabled by the installer
journalctl -u hal0-api -f # follow
journalctl -u hal0-api -n 40 # last 40 lines — the recovery recipe install.sh itself uses
Swap hal0-api for hindsight-api for the memory engine. hal0-api’s own
self-restart path (the updater) shells out to the same systemctl restart
via the hal0-systemctl wrapper, so a manual restart and an update-triggered
one behave identically.
Log access over HTTP
Both units’ logs are also available without shell access, proxied from
journald with secret-redaction applied:
GET /api/logs?unit=hal0-api&n=200[&since=...&level=...]
GET /api/logs/stream?unit=hal0-api&level=... # SSE tail
Swap unit=hindsight-api for the memory engine’s logs. Both routes are
admin-tier, and both read only hal0’s own units: hal0.target, any
hal0-* unit (hal0-api, hal0-slot@<id>, hal0-agent@<id>,
hal0-openwebui, hal0-bench*, …), hindsight-api and hermes-gateway.
Any other unit is rejected with a 400 system.logs_error.
Companion services
The dashboard’s Services page manages four companion services from a single
registry — hal0.services.registry.SERVICES in
src/hal0/services/registry.py. One ServiceDef per service names its
owning unit, its probe strategy, and which lifecycle verbs it permits:
| Service | Purpose | Unit | Allowed actions |
|---|---|---|---|
| OpenWebUI | Chat UI companion (podman container, LAN :3001) |
hal0-openwebui.service |
start, stop, restart, enable, disable |
| ComfyUI | Image generation engine (runs as the seeded img slot container, LAN :8188 by default; [network].comfyui_listen = "loopback" fences it to the box) |
[email protected] |
start, restart (no stop) |
| Hermes | Bundled agent + its own dashboard (loopback :9119) | [email protected] | start, stop, restart, enable, disable |
| Hindsight | Memory engine — this is the same unit documented above | hindsight-api.service | start, stop, restart, enable, disable |
The Open WebUI card also shows an Agents chip with the Hermes profiles wired into the picker, taken from openwebui_wiring_status.
Hermes systemd drop-in
hal0 ships its Hermes unit settings as
/etc/systemd/system/[email protected]/10-hal0.conf. Put your own
changes in a separate drop-in with systemctl edit --drop-in=local hal0-agent@hermes (it lands as local.conf and sorts after 10-hal0.conf, so
your values win). Earlier releases shipped this file as override.conf, the
name systemctl edit uses by default. Re-running the installer deletes an
override.conf that still matches a hal0-shipped version. An override.conf
you edited is kept and the installer warns, naming the hal0-owned directives it
repeats. Trim it to your own lines. A drop-in carrying ConfigurationDirectory=
is renamed to <name>.hal0-disabled, because that directive crash-loops the
unit (status 241); the file is kept, not deleted.
Hermes gateway and linger
The Hermes gateway runs as hermes-gateway.service, a system unit with
User=hal0. hal0 update renders the unit from hal0’s own template through
the root verb hal0-update hermes-gateway once the new release is live: it
orders the gateway after user@<uid>.service, turns systemd linger on for
hal0, and restarts the gateway only if it was running. It never enables,
starts or stops a gateway you disabled. Linger is needed because Hermes’s
scheduled and kanban workers start through systemd-run --user under that
user’s manager; without it kanban workers never start. hal0 doctor all
shows the linger state on its hal0_linger row, and if the verb fails
hal0 update prints sudo hal0 agent install hermes. hal0 agent uninstall hermes and installer/uninstall.sh run loginctl disable-linger hal0. A
gateway the operator disabled or masked is left off by the installer,
hal0 agent install hermes and hal0 update.
From Hermes v0.22.3 the gateway daemon owns chat turns and the session store. The dashboard’s default Chat tab still runs its turns in the dashboard process and needs no gateway. A chat opened for another profile (such as hal0-brain) and the classic hermes chat CLI are gateway clients that attach to hermes-gateway.service. hal0 keeps the unit file installed even when you turn the gateway off (it only ever runs systemctl disable --now, never removes the file), because an installed unit in any state is what stops a client from starting a gateway of its own. hal0 doctor has a Hermes gateway unit row (hermes-gateway-unit) that fails when the file is missing or a gateway run --idle-exit process is running, and the hermes-api row warns rather than fails while the gateway is off. To chat in another profile, run the gateway with sudo systemctl enable --now hermes-gateway. During hal0 update, a gateway unit whose ExecStart, ExecStop or ExecStopPost is not hal0’s launcher (for example one written by hermes gateway install outside hal0) is rewritten to hal0’s launcher while the gateway is stopped, before the new release starts.
Hindsight’s entry here isn’t a second thing to learn: the Services page’s
buttons for it run through the identical unit_action → hal0-systemctl
path already described in hindsight-api.service above.
Start, Stop, Restart
The Services page renders one button per allowed action
(ui/src/dash/services.jsx:100-124): Restart whenever it’s allowed, Start
when the unit isn’t currently active, Stop only while it is. Clicking one
calls POST /api/services/{id}/action with {"action": "start"|"stop"|"restart"},
which hal0.services.systemd.unit_action() runs against the unit — the
same verb allow-list and hal0-systemctl wrapper described above for
hal0-api and hindsight-api.
Stop carries a browser confirmation — “Stop <name>? Clients using it will
lose access until it is started again.” — because stopping OpenWebUI,
Hermes, or Hindsight takes down something other browsers or agents may be
using right now. It’s a client-side prompt only: it isn’t enforced by the
API, so a direct POST bypasses it. Restart shows no such prompt.
ComfyUI has no Stop button at all — the registry grants it only start
and restart. Its “start” is not a raw systemctl start: the route
substitutes the GPU-arbiter switchover (_comfyui_start,
src/hal0/api/routes/services.py:187-208, delegating to the same
comfyui_switchover handler the FirstRun repair flow uses) so resident LLM
slots get drained before the iGPU is handed to ComfyUI. Stopping is
arbiter-managed too, per its card hint: “stop is GPU-arbiter managed —
switch back to inference in the Image-Gen pane”.
The registry also allows enable/disable for OpenWebUI, Hermes, and
Hindsight, but the Services page renders no buttons for those two verbs —
only POST /api/services/{id}/action with {"action": "enable"|"disable"}
reaches them.
There’s no hal0 service command group, and for OpenWebUI and Hermes
specifically there’s no CLI equivalent for start/stop/restart. hal0 app list and hal0 agent list/hal0 agent status show a service’s systemd
enabled/active state, but neither hal0 app nor hal0 agent has a
start/stop/restart subcommand — for those two services, the dashboard
buttons, or a direct systemctl {start|stop|restart} <unit> as root (same
mechanism as the platform units above), are the only ways to drive these
verbs today. ComfyUI is the exception: its unit is [email protected],
the same seeded img slot the Slots page manages, so hal0 slot restart img, hal0 slot load img, and hal0 slot unload img reach it from the
CLI.
Health
Two endpoints report health, for different consumers:
GET /api/services— the Services page’s own source. Every entry
carriesup(bool), a probe-specificdetailstring, and the raw
systemdunit_state(active_state/sub_state/unit_file_state/since).
The page’s status pill is green up whenupis true, red failed
whenunit_state.active_stateis"failed", grey down for any other
managed service, and a plain grey — for an unmanaged one
(ui/src/dash/services.jsx:67).GET /api/services/health— a stable summary over the same service
catalog (OpenWebUI, ComfyUI, Hermes, Hindsight) that feeds the Overview
page’s Services widget. It collapses the same probes into
state: "up" | "stopped" | "down", splitting “not up” into a deliberate
stopped(unitinactive— comes back on demand) fromdown(unit
failed, or reachable-but-unhealthy).
Each service is probed differently: OpenWebUI is a loopback
GET http://127.0.0.1:3001/health (2xx = up); ComfyUI is its own
/system_stats + /queue (reachable = up, with a live “N running / M
queued” job count); Hermes and Hindsight both fall back to systemd
active_state == "active". Every probe is fail-soft by design — a probe or
systemd failure degrades to up=false with an honest detail, never a
fabricated “up” and never a 500. A service with no wired probe would report
up: false, state: "down", detail: "unmonitored", though none of the four
registered services hits that path today. The summary’s probes run
concurrently, so one hung or raising probe reports its own row as down
without dropping the rest.
mDNS discovery toggle
The DISCOVERY (mDNS) card above the service grid
(DiscoveryCard, ui/src/dash/services.jsx:200-241, toggle button at
:214-219) toggles POST /api/services/mdns with
{"advertise": true|false}. Turning it on writes one avahi service-group
file — /etc/avahi/services/hal0-addon-<id>.service — per service that is
both mDNS-capable and LAN-published: OpenWebUI (:3001) and, by default,
ComfyUI’s editor (:8188), so other machines reach them at
http://<host>.local:<port>. Hermes and Hindsight listen on the box’s
loopback, so they’re never mdns-capable. ComfyUI’s LAN publication is the
[network].comfyui_listen key in hal0.toml (ADR-0041):
"lan" (the default) binds the LAN and makes it advertisable; "loopback"
fences :8188 to the box, and the card then neither lists nor announces it
(its link answers only on the box itself or through an SSH tunnel). A change
applies at the next hal0 slot restart img. avahi-daemon inotify-watches that
directory, so the announcement takes effect immediately — no reload, no unit
restart. Turning it off removes every hal0-addon-*.service file. A ComfyUI
announcement written while it was on the LAN stays after you switch to
"loopback" until you turn the toggle on or off again; either removes it.
The toggle never touches hal0.service, the base dashboard announcement
the installer writes separately — that’s why “Dashboard announced as
<host>.local” can be true even when the addon toggle is off. If
avahi-daemon isn’t active, the button is disabled and the card tells the
operator to install/enable it or add a static hosts entry.
See also
- Authentication —
/etc/hal0/api.env, the
EnvironmentFilehal0-api.serviceloads, is where the admin/client keys
described there actually live.