hal0 config lives under /etc/hal0/ (or $HAL0_HOME/etc/hal0/ in a dev install — see
Environment variables). Config files are TOML, parsed
with tomllib/validated with Pydantic models in hal0.config.schema, except
hardware.json and manifest.json, which are JSON. All writes go through an
atomic tempfile-write + fsync + os.replace (write_toml_atomic/write_env_atomic),
so a crash mid-write can’t corrupt a config file.
Files
| File | Format | Purpose |
|---|---|---|
/etc/hal0/hal0.toml |
TOML | Main config — [meta], [slots], [dispatcher], [telemetry], [updates], [models], [memory], [activity], [brain_chat], [security], [network], [bridge], [openwebui], [realtime], [metrics] (the last is read independently of the rest — see below). |
/etc/hal0/slots/<name>.toml |
TOML | One file per slot. |
/etc/hal0/providers.toml |
TOML | Local cloud-provider API entries ([[provider]]). |
/etc/hal0/upstreams.toml |
TOML | Remote/cloud LLM upstream entries ([[upstream]]). |
/etc/hal0/profiles.toml |
TOML | Named backend-tuning profiles. Optional — seeds apply if absent. |
/etc/hal0/stacks.toml |
TOML | Named stack bundles (slot + profile + model sets). Optional. |
/etc/hal0/agents/<name>.toml |
TOML | Per-agent MCP allowlist/config. hermes.toml also carries the [hermes] table (command_approvals, delegation_slot); see Config schema: providers, profiles, stacks & agents. |
/etc/hal0/hardware.json |
JSON | Hardware probe snapshot, written by hal0 config hardware --refresh. |
/etc/hal0/manifest.json |
JSON | Release manifest — the runners table of runner-image pins. |
/etc/hal0/api.env, /etc/hal0/openwebui.env |
env-file (KEY=value) |
systemd EnvironmentFile= — secrets/keys, not part of the Pydantic schema. |
hal0.toml and slots/<name>.toml are documented on this page. providers.toml,
upstreams.toml, profiles.toml, stacks.toml, agents/<name>.toml, hardware.json
and manifest.json are documented in
Config schema: providers, profiles, stacks & agents.
hal0.toml
Root model Hal0Config, extra="allow".
[meta]
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
schema_version |
int | 1 |
>= 1 |
[slots]
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
max_slots |
int | 0 (unlimited) |
>= 0 |
port_range_start |
int | 8081 |
1024-65535 |
port_range_end |
int | 8099 |
1024-65535; must be >= port_range_start |
idle_timeout_s |
int | 300 |
>= 0 |
evict_pressure_mb |
int | 8192 |
>= 0 |
preload_evict_enabled |
bool | true |
— |
preload_evict_headroom_mb |
int | 1024 |
>= 0 |
publish_host |
str | "127.0.0.1" |
non-empty, no whitespace/:// |
network_mode |
str | "" |
must be "" or "host" |
default_images |
dict[str,str] | {} |
keys must be a RUNNER_IMAGES runner key (standard, cpu, cuda, halogen, flm, kokoro, moonshine, qwen3tts, comfyui) — any other key is a config error; values must be non-empty image refs; sending null for a runner key clears its override |
[dispatcher]
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
prefetch_timeout_s |
float | 8.0 |
> 0.0 |
direct_read_timeout_s |
float | 300.0 |
30.0-600.0 |
stream_total_timeout_s |
float | 900.0 |
0.0-86400.0 (0 disables) |
stream_idle_timeout_s |
float | 300.0 |
0.0-86400.0 (0 disables) |
prefetch_parallel_cap |
int | 4 |
>= 1 |
stream_total_timeout_s / stream_idle_timeout_s are the streaming stall
guard: a chatty never-terminating upstream resets the transport read timer
with every chunk, so it would otherwise be relayed forever. While the guard is
active, streaming requests disable the HTTP client’s read timeout per-request so
the guard owns the body bound; direct_read_timeout_s still bounds the wait for
response headers on those requests, since the guard’s own clocks only start once
headers arrive. With both bounds set to 0 the client read timeout stays on as
a transport backstop. When a bound trips, hal0 closes the upstream stream and —
on text/event-stream responses — appends one terminal chunk carrying a
human-readable notice, finish_reason: "length", and an x_hal0_stall object
naming the reason, followed by data: [DONE]. Note the total bound is wall
clock: a healthy long generation on slow hardware that outlives it is also cut.
[telemetry]
extra="forbid". Opt-in.
| Key | Type | Default | Constraints |
|---|---|---|---|
enabled |
bool | false |
— |
channel |
"stable"|"preview"|"nightly" |
"stable" |
— |
[models]
extra="allow".
| Key | Type | Default | Constraints |
|---|---|---|---|
roots |
list[str] | [models_dir()] = ["/var/lib/hal0/models"] |
each entry non-empty and absolute |
auto_scan_on_start |
bool | true |
— (the startup scan also prunes rows whose file is gone, except rows a slot, stack or hal0.toml field references, rows with a pending pull, FLM rows, and rows under an empty or missing root such as an unmounted share) |
file_extensions |
list[str] | [".gguf", ".safetensors", ".hgn"] |
— (.hgn is a halogen checkpoint) |
pull_root |
str | "/var/lib/hal0/models" |
deprecated; must be non-empty and absolute |
store |
str | "" |
absolute if set, else empty |
flm_store |
str | "" |
absolute if set (NPU/FLM model store) |
effective_store() resolves to store if set, else pull_root. scan_roots() returns
roots plus the effective store, deduplicated. Env override: HAL0_MODEL_STORE
(precedence: env → store/effective_store() → models_dir() default). NPU store env
override: HAL0_FLM_MODELS_DIR.
[memory]
extra="allow".
| Key | Type | Default | Constraints |
|---|---|---|---|
enabled |
bool | true |
— |
unified_bank |
bool | true |
— |
engine |
str | "hindsight" |
one of hindsight, mem0, pgvector — "cognee" is explicitly rejected (retired) |
graph |
table | — | see below |
embedding |
table | — | see below |
[memory.graph]
extra="ignore".
| Key | Type | Default | Constraints |
|---|---|---|---|
enabled |
bool | false |
informational only |
extraction_slot |
str | "utility" |
regex ^[a-z0-9][a-z0-9_-]{0,31}$; the local slot the engine runs on while extraction_model is unset |
extraction_model |
str | null | null |
an upstream model id hal0’s /v1 routes (regex ^[A-Za-z0-9][A-Za-z0-9_./:-]{0,127}$, never hal0/…); set → the engine runs on it instead of extraction_slot, validated against the live upstream catalogs at set-time |
llm_timeout_s |
int | 300 |
30-3600 |
extraction_max_concurrent |
int | 1 |
1-8 |
extraction_max_tokens |
int | 16384 |
3072-32768 |
extraction_llm_retries |
int | 1 |
0-5 |
extraction_task_retries |
int | 2 |
0-10 |
extraction_retry_backoff_s |
int | 120 |
10-3600 |
hindsight-api always calls one virtual model id, hal0/memory, which the extraction-model.conf systemd drop-in sets. hal0’s /v1 resolves it on every call to hal0/<extraction_slot>, or to extraction_model when that is set. Changing extraction_slot or extraction_model therefore applies at once and does not restart the engine. Changing llm_timeout_s or one of the extraction_* limits rewrites the drop-in and restarts hindsight-api, as does the first save after an upgrade when the drop-in is missing or stale. An extraction_model that no configured upstream advertises is rejected (422 config.memory_graph_model_invalid, listing the pickable ids) and the config is left as it was. A HINDSIGHT_API_LLM_PROVIDER, HINDSIGHT_API_LLM_BASE_URL or HINDSIGHT_API_LLM_MODEL line in /etc/hal0/hindsight-llm.env overrides both settings; see Services.
[memory.embedding]
extra="ignore".
| Key | Type | Default | Constraints |
|---|---|---|---|
rerank_gateway_url |
str | "http://127.0.0.1:8080" |
non-empty |
rerank_model |
str | "builtin.jina-reranker-v1-tiny-en-q8" |
non-empty |
rerank_connect_timeout_s |
float | 1.0 |
0.05-10.0 |
rerank_read_timeout_s |
float | 8.0 |
0.05-60.0 |
[updates]
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
auto_restart_image_drift |
bool | true |
read once at start; a change needs a hal0-api restart |
At every hal0-api start, slots whose running container uses a different
runner image than a fresh render would pick are restarted (busy slots are
skipped). Argv-only drift is never restarted.
[activity]
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
enabled |
bool | true |
— |
retention_days |
int | 30 |
>= 1 |
max_rows |
int | null | 50000 |
>= 100; in memory, null disables the cap |
max_rows is unrepresentable as null in TOML (tomli_w raises on it), so
the on-disk spelling of “unlimited” is 0, not the bare key omitted or set to
an unwritable null. A load-time validator maps on-disk 0 to None in
memory; the reverse holds on save — an in-memory None is written back out
as 0 (0 is otherwise unreachable as a real cap, since it sits below the
>= 100 floor). Write max_rows = 0 to disable the cap; anything you read
back as null from the API is the same state (#1665).
[brain_chat]
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
enabled |
bool | true |
— |
read_only |
bool | true |
— |
model |
str | "" |
empty → persona default |
max_rounds |
int | 8 |
1-100 |
completion_timeout_s |
float | 300.0 |
> 0 |
tool_model |
str | "hal0/agent" |
see below |
tool_model is the model a brain turn reroutes to when it needs a tool the
chat model’s runner cannot serve natively. It is a live routing knob, not a
dead key.
[security]
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
require_auth |
bool | null | null (resolves to off) |
HAL0_REQUIRE_AUTH env wins if set |
trust_forwarded_for |
bool | null | null (resolves to off) |
HAL0_TRUST_FORWARDED_FOR env wins if set |
[network]
extra="forbid". Browser origins added on top of the installer-managed
HAL0_ALLOWED_ORIGINS for the REST and WebSocket origin checks, read live on
the next request, plus where ComfyUI’s web UI answers. The bind host, port and
hostname stay in api.env. See
Allow the public URL and
Reaching the editor.
| Key | Type | Default | Constraints |
|---|---|---|---|
public_url |
str | null | null (no reverse proxy) |
absolute http/https URL; no user info, query or fragment; stored with a lower-case host, no default port and no trailing slash. An empty string means unset. The host is a DNS name (punycode for an internationalised name), an IPv4 address or a bracketed IPv6 address; the path may hold only letters, digits and . _ ~ / % -. Its origin joins the allowlist |
extra_origins |
list[str] | [] |
each scheme://host[:port] with http/https and no path, query or fragment; the host follows the public_url rules; stored without a trailing slash or default port, deduplicated. hal0 network origins add|remove and the Settings page edit it in place on the server (POST /api/settings/network) |
comfyui_listen |
"lan" | "loopback" |
"lan" |
where ComfyUI’s web UI (the img slot, :8188) answers. "lan" binds the LAN so other machines reach http://<host>:8188 and the Discovery (mDNS) card can announce it; ComfyUI has no sign-in of its own. "loopback" fences the port to 127.0.0.1 like every other slot (the box itself, an SSH tunnel, or HAL0_COMFYUI_PUBLIC_URL). Case-insensitive, trimmed. Applies at the next hal0 slot restart img (ADR-0041) |
[bridge]
extra="forbid". The agent bridge, which lets a remote coding agent reach
hal0’s MCP servers and delegate to Hermes with a key of its own that you can
revoke. Clients are added with hal0 bridge add; they live in
/var/lib/hal0/bridge.db, not in this file. See
Connect an external MCP client
and ADR-0043.
| Key | Type | Default | Constraints |
|---|---|---|---|
enabled |
bool | true |
master switch. Off: no bridge key authenticates anywhere, the MCP mounts included, and hal0-api does not create bridge.db; the hal0-agents MCP server is not mounted (404); adding, rotating and re-scoping a client and GET /api/bridge/me answer 404 bridge.disabled, and hal0 bridge add, rotate and scopes say the bridge is disabled. hal0 bridge list and revoke (GET /api/bridge/clients, DELETE /api/bridge/clients/{name}) keep working, so you can clean up clients while the bridge is off. hal0 doctor all passes the bridge-store row as “disabled”. On does nothing until you add a client: with no clients, only the admin key can authenticate, and the bridge asks for a credential even when sign-in is off. Applies at the next hal0-api restart |
max_concurrent_runs_per_client |
int | 2 |
1-16. How many Hermes runs one client may have in flight; a start past the limit is refused until one finishes. Applies to the next start request, no restart |
run_retention_days |
int | 7 |
1-365. How long delegated runs and their recorded events are kept before the maintenance pass deletes them. Applies at the next pass, no restart |
clarify_timeout_s |
int | 900 |
60-3600. How long a run waits for the calling agent to answer a clarifying question before it is stopped with “clarification unanswered”. Applies to the next question, no restart |
[openwebui]
extra="forbid". How hal0 treats requests that arrive from Open WebUI. See
ADR-0035 decision 8.
| Key | Type | Default | Constraints |
|---|---|---|---|
hermes_session_from_chat_id |
bool | true |
For a chat request to a Hermes-profile model that carries X-OpenWebUI-Chat-Id: send X-Hermes-Session-Id: owui-<chat id> to Hermes when the client sent no session header (the chat id must be 1-64 letters, digits, _ or -), and drop tools and tool_choice from the body, because a Hermes profile already has hal0’s memory and admin tools. Off: every request to Hermes is relayed unchanged. Applies to the next request, no restart |
[realtime]
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
enabled |
bool | true |
— |
sample_rate |
int | 24000 |
8000-48000 |
default_model |
str | "" |
— |
stt_model |
str | "" |
— |
tts_model |
str | "kokoro" |
— |
tts_voice |
str | "" |
— |
vad_energy_threshold |
float | 0.02 |
0.0-1.0 |
vad_silence_ms |
int | 500 |
50-10000 |
vad_min_speech_ms |
int | 200 |
0-10000 |
vad_window_ms |
int | 20 |
5-100 |
frame_ms |
int | 20 |
5-200 |
approval_wait_s |
float | 20.0 |
> 0, <= 300.0 |
max_buffer_seconds |
float | 30.0 |
> 0, <= 600.0 |
[metrics]
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | true |
Master on/off. When false, every background metrics task (sampler, aggregator, retention sweep) stays unconstructed rather than running disabled. |
sample_interval_s |
float | 5.0 |
Sampling cadence. |
aggregate_interval_s |
float | 3600.0 |
Rollup/aggregation cadence. |
retention_interval_s |
float | 21600.0 (6h) |
How often the retention sweep runs. |
retention_request_days |
int | 7 |
Retention window for raw request-level rows. |
retention_slot_sample_days |
int | 3 |
Retention window for slot sample rows. |
retention_rollup_days |
int | 90 |
Retention window for aggregated rollup rows. |
queue_maxsize |
int | 1024 |
Bounded queue depth for the async metrics writer. |
write_batch_size |
int | 64 |
Rows drained per BEGIN IMMEDIATE transaction. |
Each key also has a HAL0_METRICS_* environment variable that overrides it
(precedence: env var > [metrics] TOML table > default) — see
Environment variables for that list.
Slot [tune] table
An LLM slot outside the NPU lane may contain:
[tune]
ctx_size = 8192
enable_thinking = false
reasoning_effort = "low"
reasoning_budget = 512
vision = "off"
speculation = "ngram"
All keys are optional and inherit the profile when absent. Context accepts
128–16777216. Effort accepts default|minimal|low|medium|high|xhigh|max;
default emits no effort flag. Budget is -1 through 16777216 (-1 emits no
budget flag; 0 ends reasoning immediately). Vision accepts auto|off and
speculation accepts auto|off|ngram, except on a halogen slot, where ngram is
refused because the engine has no n-gram drafter.
Unsupported slot types/devices fail with slot.tune_not_supported; a thinking
override pinned by profile flags fails with slot.tune_pinned_by_flags; a value
the slot’s engine cannot run (speculation ngram on a halogen slot, or a halogen
context past the engine’s native 262144 positions with no HALOGEN_ROPE_YARN in
the profile’s flags) fails with slot.tune_unsupported_value (400).
Legacy root-level tuning keys remain refused with slot.tuning_key_denied.
Effort flags require a resolved runner build of at least 11840.
slots/<name>.toml
Root model SlotConfig, extra="forbid": every key a slot file may carry is a field
below, and a key that is not fails the load, naming the key and the file
(failed to validate slot config 'x' at /etc/hal0/slots/x.toml: unknown key 'lru' ...).
Both a nested [slot] table and flat top-level scalars are accepted on load and normalized
([slot] wins over a root scalar of the same name, as it does on the launch path). The same
vocabulary gates every write: POST /api/slots, PUT /api/slots/{name}/config and the
in-process writers (SlotManager.create / update_config, stacks apply) refuse an unknown key
with a 400 validation.unknown_keys, and a key hal0 removed (enabled, backend, lru) with
a 400 slot.removed_key_denied that names its replacement.
| Key | Type | Default | Req | Constraints |
|---|---|---|---|---|
id |
int | null | null |
— | >= 1 |
name |
str | — | required | regex ^[a-z0-9][a-z0-9_-]{0,31}$ |
type |
str | null | null |
— | slot kind (llm, embedding, reranking, tts, transcription, image, …); a type-less llm-shaped file is healed to llm on load |
default |
bool | null | null |
— | the slot is its type’s default; the first slot of a type becomes it at create, and at most one slot per type may be |
served_by |
str | null | null |
— | FLM trio shadows only: the NPU anchor slot whose process serves this one |
port |
int | — | required | 8081-8200 |
device |
str | "gpu-rocm" |
— | one of gpu-rocm, gpu-vulkan, gpu-cuda, cpu, npu |
gpu_index |
int | null | null |
— | >= 0 |
n_gpu_layers |
int | -1 |
— | -1 = all, 0 = CPU-only |
threads |
int | 0 |
— | >= 0; 0 = unset |
metrics |
bool | true |
— | emits --metrics, enabling llama-server’s loopback-only /metrics endpoint; without it /metrics 501s and hal0’s tok/s and request-count collection can never populate (#1810) |
binary |
str | "" |
— | key into RUNNER_IMAGES |
image_pin |
str | null | null |
— | debug-only escape-hatch image ref override (ADR-0006) — not the intended path to a shipped runtime; use profile.runner or the slot drawer’s Runtime selector instead |
provider |
str | "llama-server" |
— | one of llama-server, flm, moonshine, kokoro, qwen3tts, comfyui; deprecated, legacy label only — all slots run as podman containers now |
runtime |
"container" |
"container" |
— | only value; deprecated field |
profile |
str | null | null |
— | a seed name or a profiles.toml entry; the profile is the slot’s whole tune (ADR-0029). null = the slot type’s seed (embedding, reranking), else chat; a profileless tts, transcription or image slot uses its engine’s default instead (kokoro, or qwen3-tts on the Qwen3-TTS engine; moonshine; ComfyUI’s built-in flag bundle). A name the catalog does not have fails the launch with slot.profile_missing |
output_sanity |
bool | null | null |
— | false exempts this one slot from the output-sanity gate; null = the gate runs. See Slot lifecycle |
system_prompt |
str | null | null |
— | persona text for hal0/<slot> delegation; absent = no system message |
default_voice |
str | null | null |
— | TTS slots |
default_speed |
float | null | null |
— | 0.25-4.0 |
default_response_format |
str | null | null |
— | one of mp3, wav, opus, flac, pcm |
idle_timeout_s |
int | 300 |
— | >= 0; 0 disables IDLE-TTL eviction for this slot only — pressure and pre-load eviction are separate paths, exempted by pinned. idle_timeout_s does not apply to the ComfyUI img slot, which idle, pressure and pre-load eviction never unload. Read via the raw-dict sweep path (not the typed loader), an out-of-range value there is clamped/floored at read time, not errored — the typed POST/PUT API path still hard-rejects via pydantic’s ge |
pinned |
bool | false |
— | exempts the slot from auto-eviction; blocks manual unload without force |
autoload |
bool | null | null |
— | true → the Quadlet unit gets [Install] WantedBy=hal0.target and the slot starts at boot; false → unit exists, nothing starts it. null (key absent) is the migration shim: derived from a non-empty model.default. POST /api/slots persists an explicit value (false) |
priority |
int | 50 |
— | 0-100; eviction order, lowest evicted first, last_used breaks ties. 100 is not a pin — use pinned. Read via the raw-dict sweep path, an out-of-range TOML value is clamped to 0-100 at read time, not errored — the typed POST/PUT API path still hard-rejects via pydantic’s ge/le |
model |
table | — | — | [model], see below |
server |
table | — | — | [server], see below |
npu |
table | null | null |
— | [npu], see below |
image (alias image_gen) |
table | — | — | [image], see below |
There is no catch-all: a table or key the schema does not declare is an error, not a place
to park a hand edit. There is no enabled field — a non-empty model.default is the
activation signal. Boot start is separate and explicit (autoload). enabled, backend (now
device), lru (now priority / pinned) and a bare image = "<ref>" (now image_pin) are
retired; delete or rewrite them by hand, because a file that still carries
one fails the load with the replacement named. See
Slot lifecycle for the activation model.
A slot holds placement (device, layers, threads, runner), its model and its profile, and nothing else about how the model runs. A write that carries a tuning key (parallel, chat_template, workers, ctx_size, mtp, enable_thinking, vision, [model].context_size, [model].ctx_size, [model].n_gpu_layers, [model].rope_freq_base, [model].extra or [server].extra_args) is refused with a 400 slot.tuning_key_denied (“belongs on a profile”). A slot file on disk that still carries one is refused at launch with slot.unconverted: delete the keys from the file and put their values on a profile (hal0 profile create).
[model] (nested under a slot)
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
default |
str | "" |
model id from the registry |
The slot’s [model] table names its model and nothing else about it. Base tune
lives on the profile, and six sparse overrides belong in [tune] (ADR-0045).
A write carrying tuning under [model] is rejected with
slot.tuning_key_denied.
[server] (nested under a slot)
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
env |
dict[str,str] | null | null |
keys must match ^[A-Za-z_][A-Za-z0-9_]*$; values must not contain \n/\r |
env may not set a llama-server option. Names that start with LLAMA_ARG_, and LLAMA_API_KEY, HF_TOKEN and MTMD_BACKEND_DEVICE, are refused with a 400 slot.llama_env_denied, at write time and again at launch: each of them sets a llama-server option. A LLAMA_ARG_* name sets what the matching flag sets, so it belongs in the profile’s flags and only when that flag is allowed there. LLAMA_API_KEY, HF_TOKEN and MTMD_BACKEND_DEVICE set --api-key, --hf-token and --mmproj-device, which hal0 owns and no profile may set either.
[npu] (nested under a slot)
extra="forbid".
| Key | Type | Default |
|---|---|---|
asr |
bool | false |
embed |
bool | false |
chat |
bool | true |
[image] (nested under a slot, alias for image_gen)
extra="forbid".
| Key | Type | Default | Constraints |
|---|---|---|---|
default_size |
str | "" |
— (format WxH; empty means the model’s native size) |
default_steps |
int | 0 |
>= 0; 0 means the model-class default |
A /v1/images/generations request that sets no size or steps gets these when they are set,
and the model’s own defaults otherwise.