Skip to content

Server Configuration

File: configs/server.yaml
Command: kenzy-server [config_path]

The server is the central WebSocket hub. It accepts connections from room nodes, runs the STT → LLM → TTS pipeline, and streams audio responses back. Each downstream service is optional — omit its url to disable that stage.

Full reference

Key Default Description
host "0.0.0.0" Bind address. 0.0.0.0 listens on all interfaces.
port 8765 WebSocket port
tls.cert / tls.key Paths to a certificate + private key on the server host. When both are set and load successfully, the node WebSocket port speaks wss and the dashboard https (one cert pair covers both). See TLS configuration below.
log_level "info" Log verbosity
experimental false Opt this server into experimental features that aren't ready to ship officially (none gated yet — reserved for future previews). Also switches the dashboard favicon to the experimental mark — gold tile, petrol "K", corner badge dot — so the browser tab is distinguishable from production at a glance. Editable from the dashboard's Settings tab.

Discovery and config-pull

Key Default Description
discovery.enabled true Advertise the server as _kenzy._tcp over mDNS so nodes auto-discover it without a hardcoded server_url
discovery.instance "kenzy-server" mDNS instance name
discovery.token (generated by kenzy-init) Shared secret required in each node's hello (mismatching nodes are rejected) and the service-to-service bearer. kenzy-init generates one by default and matches it in node.yaml + .env (KENZY_SERVICE_TOKEN); the dashboard shows it under Settings for copy-paste. Clear it to allow unauthenticated joins.
sounds.dirs [] Extra sound library roots for the alert system: HA automations (MQTT kenzy/chime, HTTP /chime) may name any file inside these folders — relative subpaths fine, traversal/absolute paths always rejected. data/sounds/ under the config home is always a root (and rides backups). Deliberately file-managed, not dashboard-editable: this list is the security boundary. MP3/OGG/FLAC decode needs pip install 'kenzy[sound]'; WAV works everywhere.
node_defaults {} Node tuning defaults (wake-word thresholds, VAD timing) pushed to every node on connect. Per-node overrides live in configs/nodes/<node_id>.yaml and shallow-merge over these.
cues (shipped phrases) The spoken-cue phraseserror (the failure apology, one string), thinking / working (the processing acknowledgements, string or list for variety). The texts are the source of truth; the dashboard's Settings → Regenerate spoken cues re-records them all through the configured TTS voice (into data/sounds/cues/) and points the fleet-wide sound keys at the renders. See the dashboard guide.

On connect, a node's hello carries its stable node_id and its room name; the server replies with the node's effective config = node_defaults merged with configs/nodes/<node_id>.yaml. The node blocks until this first frame arrives, then builds its audio stack from it (so hardware keys — audio device, sample rates, wakeword models, sounds — apply on that first pull); a later hardware change takes effect on restart, while live-tunable keys (thresholds, VAD timing, log levels) and the room name apply immediately. The per-node file is keyed by node_id, so a node keeps its config even if its room is renamed; pre-existing room-named files migrate automatically on first connect. This is how a room device runs with a bootstrap-only local file — see Node Configuration.

Central config for backend services

The server is also the config authority for the backend HTTP services. It exposes an always-on endpoint GET /config/<service> on the node WebSocket port (it runs whenever the server runs, independent of the dashboard), returning that service's effective config = the packaged default deep-merged with the server-owned override at configs/services/<service>.yaml. Secret-like keys are stripped, so secrets never leave the server — they stay in each host's environment / .env.

At boot, kenzy-stt/kenzy-tts/kenzy-llm/kenzy-speaker discover the server the same way a node does (mDNS, or an explicit KENZY_SERVER_URL), pull their config from this endpoint, and block with retry/backoff until the server answers — so the server must come up first (set After=kenzy-server in systemd units; the installer does this). The endpoint is gated by the service-to-service bearer (discovery.token / KENZY_SERVICE_TOKEN) when one is set. Each service also exposes a token-protected POST /restart that re-execs it to re-pull fresh config. Passing an explicit config path to a service (e.g. kenzy-stt configs/stt.yaml) bypasses the pull and loads locally — a dev/offline escape hatch.

Edit it all from the dashboard's Services tab: it reads each service's secret-stripped effective config, writes your changes to configs/services/<service>.yaml on the server, and restarts the service to apply. Secrets stay in the service host's environment and are never shown or stored.

Announce endpoint

The server exposes an always-on GET /announce on the node WebSocket port so external automations (e.g. Home Assistant) and scripts can make Kenzy speak in your rooms:

GET http://<server>:8765/announce?text=Dinner%20is%20ready&rooms=kitchen,office
Authorization: Bearer <discovery.token / KENZY_SERVICE_TOKEN>

text is required; rooms is an optional comma-separated list of room names (omit for every room). Returns {"announced": <node count>, …}. It must be a GET with query parameters (the websockets HTTP hook only accepts GET and exposes no request body), gated by the service-to-service bearer when one is configured.

For a ready-to-use Home Assistant rest_command, see Home Assistant Integration → Calling Kenzy from Home Assistant.

Its sound-alert twin /chime (same port, same auth) plays library sounds instead of speech — see Home Assistant integration for payloads and a rest_command example.

TLS configuration

Whether a new Kenzy server starts with TLS depends on how it was installed:

Installation path Initial transport
The one-line install.sh installer, profile server or all TLS on by default. It generates a self-signed certificate in the config home and writes the tls: block below. Choose --no-tls (or decline the prompt) for plaintext.
pip / pipx, kenzy-init, or a hand-built config home Plaintext. The packaged server.yaml has no active tls: block; add one yourself to turn TLS on.

The installer falls back to plaintext with a warning if it cannot generate the certificate (for example, openssl is unavailable). To configure TLS by hand, give the server a certificate and key:

tls:
  cert: /etc/kenzy/kenzy.crt
  key: /etc/kenzy/kenzy.key

One pair covers both listeners: the node WebSocket port becomes wss and the dashboard becomes https (the login cookie is then marked Secure). A self-signed certificate is fine — generate one with:

openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
  -keyout kenzy.key -out kenzy.crt -subj "/CN=kenzy"

Kenzy's own clients are built for exactly this posture: nodes and the backend services connect encrypted but unverified by default, so nothing needs a CA installed — traffic is protected from passive eavesdropping without pretending a trust chain exists. mDNS advertises the TLS flag, so auto-discovering nodes and services switch to wss:// automatically; nodes with an explicit server_url need it changed to wss:// by hand. Browsers do verify, so the dashboard shows a one-time certificate warning (or install the cert on your machines).

The backend services are covered too: co-located services receive the server's cert pair through the config they already pull and bring their own listeners up as https — the whole mesh (pipeline calls, dashboard proxies, health checks) is then encrypted with no extra setup. Services on other hosts supply their own pair via KENZY_TLS_CERT / KENZY_TLS_KEY in their environment.

A bad TLS configuration does not fail closed

If either path is missing or the certificate cannot load, kenzy-server logs the error and continues in plaintext. Confirm the scheme after changing TLS; do not assume that the presence of a tls: block means the listener is encrypted.

Operators with a real CA can turn verification on at each client: tls_verify: true / tls_ca: in node.yaml (see Node Configuration), or KENZY_TLS_VERIFY=1 / KENZY_TLS_CA=/path in a backend service's environment. The tls keys are deliberately not dashboard-editable (a bad path would lock the dashboard out); manage them in server.yaml. Everything also still works behind a reverse proxy terminating TLS — the dashboard honors X-Forwarded-Proto.

Dashboard

Web fleet manager served by kenzy-server. On by default in the shipped config (set enabled: false to disable — nothing is then wired up: no route, no overhead). When enabled it provides a live fleet/health view, a per-node config editor (with room rename), node controls (trigger/stop/restart), TTS announcements, a log viewer, and a settings page. See the Dashboard guide for the full walkthrough.

Key Default Description
dashboard.enabled true (shipped config; false when the key is absent) Master switch. false ⇒ nothing below is mounted.
dashboard.bind "0.0.0.0" Listener address. 0.0.0.0 is reachable across your LAN (the default — change the default password!); use 127.0.0.1 to restrict it to the server itself. Never port-forward it; without TLS it is plaintext HTTP.
dashboard.port 8770 Dashboard HTTP port (separate from the node WS port)
dashboard.auth.username / dashboard.auth.password_hash admin / (hash of password) Browser login. Change it with the server-only kenzy-passwd CLI (or the dashboard's Settings page); never edit the hash by hand.
dashboard.auth_token null Optional bearer token for API/CLI clients (the browser uses the login cookie, not this)
dashboard.controls true Enable mutating actions — config edits, room rename, trigger/stop/restart, announcements. Set false for a read-only dashboard.
dashboard.logs true Enable the pull-based log viewer (server, services, and per-node buffers) and the Activity tab. Set false to keep no logs/transcripts in memory.
dashboard.allowed_hosts [] Optional list of hostnames the dashboard will accept in the Host header (DNS-rebinding defense). Empty = no Host restriction; the cross-site Origin check always applies. Set it when serving under a fixed name (e.g. ["kenzy.local"]).

Keep the dashboard off the public internet

On a manual/plaintext install, login runs over HTTP and defaults to admin / password. Bind it to localhost or the LAN only, change the password with kenzy-passwd, and do not port-forward the dashboard port.

STT service

Key Default Description
stt.url URL of the kenzy-stt /transcribe endpoint. Omit or set to null to skip transcription.
stt.timeout 60.0 HTTP timeout in seconds

Speaker identification service

Key Default Description
speaker.url URL of the kenzy-speaker /identify endpoint. Omit to disable speaker ID.
speaker.timeout 10.0 HTTP timeout in seconds
dialog.max_turns 6 Max consecutive follow-up turns Kenzy holds the floor for in a multi-turn dialog before auto-ending
alarm.ring_repeats 10 How many times a firing alarm re-rings before giving up (a wake word stops it sooner)
alarm.ring_interval 25 Seconds between alarm re-rings
streaming.enabled true Sentence-overlapped streaming replies: speech starts on the first sentence while the model is still writing the rest. Off = the classic buffered pipeline. Regardless of the flag: a lockbox secret never rides a streamed preview (secret exchanges deliver whole), and providers that can't follow the streaming contract fall back to the buffered path automatically.

LLM service

Key Default Description
llm.url URL of the kenzy-llm /process endpoint. Omit to disable LLM processing.
llm.timeout 30.0 HTTP timeout in seconds

TTS service

Key Default Description
tts.url URL of the kenzy-tts /speak endpoint. Omit to disable TTS.
tts.timeout 60.0 HTTP timeout in seconds
tts.chunk_size 4096 Bytes per PCM chunk streamed to the node. At 24 kHz int16 mono, 4096 bytes ≈ 85 ms of audio.

Home Assistant / MQTT integration

Opt-in; nothing is wired (zero overhead) unless enabled. Requires the mqtt extra (pip install "kenzy[server,mqtt]"). See Integrations → Home Assistant for the full guide.

Key Default Description
integrations.mqtt.enabled false Publish node state/events to an MQTT broker via HA MQTT Discovery
integrations.mqtt.host "127.0.0.1" Broker hostname
integrations.mqtt.port 1883 Broker port
integrations.mqtt.base_topic "kenzy" Topic prefix for Kenzy's state/command topics
integrations.mqtt.chimes {} Extra named chimes for the kenzy/chime topic — name → audio file path (WAV anywhere; MP3/OGG/FLAC with kenzy[sound]) on the server host. Bundled sound names (doorbell.wav) work without an entry
integrations.mqtt.discovery_prefix "homeassistant" Must match HA's MQTT discovery prefix
integrations.mqtt.commands true Accept inbound commands (Trigger/Stop buttons, Mute switch, command topics). false = read-only

Broker credentials come from the environment, never this file: KENZY_MQTT_USERNAME / KENZY_MQTT_PASSWORD.

Fleet health

When a node disconnects it stays on the dashboard as absent, with how long it has been gone, rather than disappearing from the list. That distinction matters more than it sounds: a room that vanishes from the fleet looks exactly like a room you never installed, so a house quietly losing one can go unnoticed for days — especially since an orphaned node keeps answering its wake word and seems fine from inside the room.

Past offline_alert_minutes the card becomes a fault and the Fleet page raises a banner. If MQTT integration is on, the offline transition is also published as a node_state event, so Home Assistant can notify you.

Key Default Description
fleet.offline_alert_minutes 5 How long a node may be missing before it is reported as a fault rather than merely absent. 0 = never raise the fault (nodes still show as absent).
fleet.restart_grace_minutes 10 Expected-downtime window granted when you restart or upgrade a node from the dashboard, so routine churn doesn't raise an alert. An alert people learn to ignore is worth less than no alert at all.

The roster lives in data/nodes.json and rides the backup slice. A node you have decommissioned can be dropped with Forget on its (offline) fleet card; a node told to disable itself is removed automatically.

Occupancy

Kenzy keeps a live picture of which rooms have people in them — built from your Home Assistant motion/presence sensors and from who she hears speaking in each room — and shows it on the dashboard's Presence tab.

In this release it is watch-only: nothing is spoken unprompted and no delivery behaviour changes. The picture is built first so it can be trusted before anything acts on it.

Key Default Description
occupancy.enabled true Track room occupancy. Requires Home Assistant to be configured (Fleet → llm); without it nothing starts, whatever this says.

Rooms read unknown until something says otherwise — that is deliberate. "Unknown" and "empty" are different claims, and only one of them is honest when no sensor has reported and nobody has spoken.

Which sensors count as evidence is detected automatically from HA device classes (motion, occupancy, presence, plus person entities for home/away). Tune it per entity under Home Assistant → Presence sensors when a sensor lies — a hallway PIR the cat crosses, or one aimed through a window at the street, will otherwise keep a room permanently "occupied":

# data/home_assistant/curation.yaml
occupancy:
  exclude:
    - binary_sensor.hallway_motion     # the cat sets this off nightly
  include:
    - binary_sensor.workshop_door      # not a presence class, but good evidence here

Kenzy's own kenzy_* entities are never evidence — she would otherwise believe every room was occupied the moment she spoke — and that rule is not overridable.

Proactive speech

The only place Kenzy talks without being asked. Everything is off until you switch it on, one category at a time.

An announcement you send — from the dashboard, or from a Home Assistant automation using the MQTT announce topic — does not go through any of this. That's you speaking through her, and quiet hours shouldn't swallow a message you deliberately sent. What's below governs the case where a sensor changed and she decided it was worth saying.

Key Default Description
proactive.enabled true Master switch. false ⇒ she never speaks unprompted, whatever the categories say.
proactive.quiet_hours "" A window when unprompted speech is held, e.g. "22:00-07:00". Wrapping past midnight is normal. Safety ignores this — fires are nocturnal.
proactive.dnd_rooms [] Rooms that receive no unprompted speech. Safety ignores this too.
proactive.rate_limit 6 Most unprompted announcements allowed per rate_window. 0 disables the cap.
proactive.rate_window 3600 The rate limit's window, in seconds.
proactive.safety.enabled false Tier A. Smoke, carbon monoxide, gas, water leak, and an alarm panel that has actually triggered. Speaks in every room, immediately, including muted ones. Off by default because it will interrupt anything.
proactive.safety.repeat_after 300 Seconds before re-announcing a hazard that is still asserting.

Every entry Tier A acts on is a hazard a device asserted — a smoke sensor reading on, a panel reading triggered. Kenzy relays it; she never concludes one from a combination of states. That boundary is what makes speaking unprompted defensible, and it's why "a door opened while everyone's out" is not in this release.

Which entities count is detected from HA device classes, and tunable the same way presence sensors are:

# data/home_assistant/curation.yaml
safety:
  exclude:
    - binary_sensor.workshop_smoke     # the soldering iron sets this off
  include:
    - binary_sensor.sump_high_water    # not a standard class, but worth shouting about
    - input_boolean.test_hazard        # a toggle helper — see below

Toggle helpers, for testing and your own automations

Home Assistant input_boolean helpers appear in the candidate list too. None of them ever count automatically — they carry no device class, so only an explicit tick promotes one. Opted in, a helper behaves like any other hazard: on announces, off releases it, and silencing works the same way.

That buys two things a real detector can't:

  • Rehearsal. Flip the helper and hear the whole path — the tone, the wording, every room, the silence — without setting off a smoke alarm. (The Proactive tab's Test an alert button does this too, without needing a helper at all.)
  • Your own conditions. An HA automation can decide something is wrong on whatever logic you like and flip a helper to say so. That keeps Kenzy's boundary intact: your automation does the concluding, and she still only ever relays a state a device is asserting.

A hand-picked helper is announced as "There's an alert" — she won't invent a hazard type for something she can't classify.

Silencing an alert

Say anything to Kenzy while an alert is sounding and it stops — and stays stopped until that sensor goes off and trips again. It is not a snooze: a condition that's still asserting stays silent for as long as it keeps asserting, so "is it silenced?" is answerable without also knowing what time it is. The sensor cycling is what marks a genuinely new event, and that speaks immediately rather than waiting out any window.

Example

host: "0.0.0.0"
port: 8765

discovery:
  enabled: true
  instance: "kenzy-server"
  # token: "change-me"      # require this in every node's hello

node_defaults:             # pushed to nodes on connect (config-pull)
  wakeword_threshold: 0.5
  silence_rms_threshold: 50
  silence_ms: 400

dashboard:
  enabled: true            # false ⇒ nothing is wired up (zero overhead)
  bind: "0.0.0.0"          # LAN-reachable (change the default password!); 127.0.0.1 = this machine only
  port: 8770

stt:
  url: "http://127.0.0.1:8767/transcribe"
  timeout: 60.0

speaker:
  url: "http://127.0.0.1:8768/identify"
  timeout: 10.0

llm:
  url: "http://127.0.0.1:8766/process"
  timeout: 30.0

tts:
  url: "http://127.0.0.1:8769/speak"
  timeout: 60.0
  chunk_size: 4096

Disabling stages

You can run a partial pipeline for development. For example, omit llm.url and tts.url to transcribe audio and log the results without generating responses.