Config schema reference

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.