The house, written down
Dobby's understanding of the house is not hidden in a model prompt. It has an explicit source of truth.
One house model, four ways to write it
The shared thread, the forms on /house and /admin, an outside agent over MCP, and a text editor can all change the same model. One writer validates and serializes every application change.
YAML is the current durable representation. It records which devices Dobby manages, what the household calls them, how they bind to Home Assistant, which settings narrow their capabilities, and how the Dobby server should run. The file never holds a secret, so you can share it.
In development, DOBBY_HOME_MANIFEST names the file. In an install it is /opt/dobby/config/home.yaml. Start from the example:
cp config/homes/example.yaml config/homes/my-house.yaml
Two sections. house: is your house. system: is the box Dobby runs on.
The house
house:
id: my-house
name: Our House
timezone: America/New_York
home_assistant:
url: env:DOBBY_HA_URL
token: env:DOBBY_HA_TOKEN
devices:
# …one entry per device, below…
Credentials are references. token: env:DOBBY_HA_TOKEN names an environment variable. Dobby reads it at boot. If the variable is missing, Dobby stops and names it instead of starting a house that cannot reach anything.
A device entry
Every device has the same parts. Only id, type, name, and bindings are required.
| Key | What it is |
|---|---|
id | How Dobby refers to it, forever. Convention: type:place, like thermostat:hall. |
type | One of the semantic types below. A type describes what the device means in the house, not who made it. |
name | What the household calls it. |
aliases | Other names people use for it: "the thermostat", "downstairs". |
bindings | Which Home Assistant entity is which part of the device. Copy entity IDs exactly. |
hands_only | Optional. Set true when Dobby and agents over MCP may read the device but may not command it. Card controls and schedules made in /admin still work. |
settings | House rules. Optional. They can narrow what the hardware allows, never widen it. |
Entity ids are in Home Assistant under Settings → Devices & services → Entities. A device bound to a wrong id shows NOT KNOWN and stays that way. Verify names it.
hands_only names the authority that remains. It does not mean the device is absent or globally read only. The language layer can still answer status questions. The command gate lives in the shared device protocol, so the same refusal applies to Dobby's model, MCP, and a delayed schedule they authored.
The device types
Home Assistant owns brands, accounts, radios, and protocols. Dobby owns the household contract. A Sonos room and a Cast speaker are both speaker. A Ring and a Reolink doorbell are both doorbell. Add one device entry for each household object you want Dobby to manage.
Some Home Assistant devices expose several entities. Keep them in one Dobby entry. A doorbell can bind its ring event, camera, and motion sensor. An environment monitor can bind temperature and humidity. Discovery proposes the complete binding map when Home Assistant says those entities share one device.
thermostat
Dobby reads it and sets it. The hardware's range comes from Home Assistant. settings narrows that range to what your house allows anyone to ask for.
- id: thermostat:hall
type: thermostat
name: hall thermostat
aliases: [the thermostat, downstairs thermostat]
bindings:
climate: climate.hall
settings:
min_temperature_f: 60
max_temperature_f: 78
With this range, nobody can set the hall to 90. Not a person at a card, not the model, not an agent over MCP. The thermostat shows HELD with the reason beside it.
light
Dobby switches it, and dims it if the bulb can dim. The bulb reports that itself. Nothing to set.
- id: light:living_room
type: light
name: living room light
aliases: [living room lamp]
bindings:
light: light.living_room
The common library
| Type | Bindings | What Dobby offers |
|---|---|---|
speaker | media_player | Status, play, pause, and volume. Sonos is an example integration, not a separate type. |
camera | camera; optional motion | Status and motion. Streams and snapshots stay on Home Assistant's authenticated media path. |
doorbell | event; optional camera and motion | The last ring event, camera availability, and motion. |
lock | lock | Status and secure. Dobby does not offer unlock. |
access_cover | cover | Status and close for a garage door, gate, powered door, or window. Dobby does not offer open. |
power_switch | switch | Status, on, and off for a plug, outlet, or relay. |
shade | cover | Status, open, close, and position for a blind, curtain, shutter, shade, or awning. |
fan | fan | Status, on, off, and speed when the device reports speed support. |
environment_monitor | One or more of temperature, humidity, carbon_dioxide, air_quality, pm25, volatile_organic_compounds, and illuminance | Readings and units. Read only. |
contact_sensor | contact | Open or closed for a door, window, or garage contact. Read only. |
occupancy_sensor | occupancy | Motion, occupancy, or presence. Read only. |
safety_sensor | alarm | Smoke, carbon monoxide, gas, water, heat, or cold alarm state. Read only. |
The action limit is part of each type. It does not depend on the model behaving well. Locks cannot be unlocked, access covers cannot be opened, and safety sensors cannot be actuated through the thread, a schedule, or MCP.
Household appliances
Home Assistant handles manufacturers, models, accounts, and connections. Dobby binds the resulting entities to household device types. Floor heating uses thermostat; a standalone freezer uses refrigerator.
| Type | Bindings | What Dobby offers |
|---|---|---|
water_heater | water_heater | Temperature, mode, away mode, and power where HA reports the capability. Configure settings.temperature_unit to match HA's system unit when the entity omits it. |
humidifier, dehumidifier | humidifier, with the matching HA device class | Power, humidity target within the reported range, and advertised modes. Household bounds can narrow the range, and the fader on the card narrows too. |
air_purifier, range_hood | fan; optional filter_remaining and filter_due | Power, supported speed, and filter readings. Bind a hood light as a separate light. |
dishwasher, washer, dryer | Explicit cycle, door, progress, and finish-time sensors; remaining time on the washer and dryer | Status only. A remote-readiness flag does not start a cycle. |
oven, refrigerator, wine_cooler | Explicit measured-temperature, setpoint, door, or activity sensors | Status only. A setpoint does not establish a measured temperature. |
coffee_maker, ice_maker | Explicit activity, supply, or maintenance sensors | Status only. Missing readings remain unknown. |
cooktop, microwave | Explicit activity, residual-heat, door, or cycle sensors as applicable | Status only. Use one cooktop entry per heating zone when HA reports separate zones. |
Use the bindings shown in the example house. Purifiers, hoods, and sensor-based appliances need explicit entries because HA entity names do not establish their household role. Every sensor-based appliance needs at least one reading binding.
vacuum
Dobby starts it and sends it home.
- id: vacuum:downstairs
type: vacuum
name: robot vacuum
aliases: [the vacuum, the roomba]
bindings:
vacuum: vacuum.downstairs
wifi_endpoint
Something on the network Dobby can report as reachable or not: a printer, a TV, a speaker. Read only. The usual source is a binary_sensor from Home Assistant's ping integration.
- id: wifi:office_printer
type: wifi_endpoint
name: office printer
aliases: [the printer]
ha_integration: ping
bindings:
connectivity: binary_sensor.office_printer
A printer that drops off at three in the morning shows on its card and in the log. It does not post into the thread.
Standing rules
Definitions live under house.rules. The House page offers a typed form using each device's available readings. Changes made there apply immediately without restarting Dobby's conversation. Hand edits apply on restart. Proposals and notices are stored in the database.
rules:
- id: main-room-cold
name: Main room cold
source: Tell me if the main room stays below 60°F for twenty minutes
enabled: true
device: thermostat:main
kind: state
attribute: current_temperature_f
operator: lt
value: 60
duration_seconds: 1200
window:
start: "20:00"
end: "07:00"
days: [1, 2, 3, 4, 5]
The example belongs inside house. Device ids must match your house. Operators are eq and ne, plus gt, gte, lt, and lte for numbers. Environmental readings also require the exact reported unit. Unsupported readings, values, and fields are refused.
An absence rule uses kind: absence, event_kind: device_changed, and action: state_changed, alongside the desired attribute, operator, and value. Only a recorded change to that attribute counts; a battery update while a vacuum is cleaning does not count as another cleaning start. Absence rules for grouped environmental readings are not supported.
Rules only report. A hands-only device can still be watched. The limit is 100 rules per house and one year per duration. A state duration may be zero; an absence duration must be positive. A rule with a window must have a duration shorter than the window, because the watch starts over at every window edge. source is the household's own sentence when Dobby wrote the rule from the thread; a rule written by hand or from the form has none. For what notices and acknowledgments mean, see Living with Dobby.
The system section
All seven settings are optional. Three have an environment variable that wins while it is exported, so you can try something without editing the file.
system:
model: openrouter:openai/gpt-5.6-luna
reasoning: low
routing: latency
provider: together
port: 4000
lan: true
hostname: dobby.local
| Key | Override | Takes effect |
|---|---|---|
model | DOBBY_MODEL | At the next reply. OpenRouter names use openrouter:author/model. Three have been walked: openai/gpt-5.6-luna, the default, z-ai/glm-5.3-flash, and z-ai/glm-5.2. |
reasoning | — | At the next reply. How hard a reasoning model thinks before it answers: low, medium or high. Unset leaves the provider's default. |
routing | — | At the next reply. What OpenRouter optimizes for when it picks the endpoint that serves the model: latency, throughput or price. It is an OpenRouter setting, so it needs a model reached through OpenRouter: set it beside a model that is not an openrouter: name and the house refuses to start, saying so. That is deliberate — the alternative is a house that starts and then cannot answer anything. |
provider | — | At the next reply. The one OpenRouter endpoint that serves the model, with no fallback to another, named by the slug OpenRouter's endpoint listing uses: together, io-net/fp8, amazon-bedrock/us-east-1. Set it when a measurement has chosen an endpoint and you want the same one every time; routing lets OpenRouter choose per request, and the same model's first word moved from half a second to three within one hour on that setting. With a pin in force the routing has nothing left to choose. An endpoint that stops serving the model refuses the reply rather than falling back, which is the point of a pin. Like routing, it needs a model reached through OpenRouter. |
port | PORT | On restart. |
lan | DOBBY_LAN | On restart. Binds every interface and advertises the hostname. |
hostname | — | On restart. One label and .local. Dobby answers only to this name, localhost, PHX_HOST and LAN addresses; any other name goes in DOBBY_ALLOWED_HOSTS. See running Dobby. |
routing: latency is the default: OpenRouter picks whichever endpoint looks fastest for each reply, and that is how the local house runs. provider is for a household that wants the same endpoint every time. Which endpoint serves a model decides how long the first word takes, more than the model does. On 2026-09-07 every endpoint under each of the three models Dobby is run with was timed on the same requests, pinned one at a time with reasoning: low. The fastest to its first token, measured from the model call, and in the last column the slug worth writing if you pin, which is not always the first to speak:
| Model | Fastest first token | First token, median | Worst turn | "Set the thermostat to 70", end to end | provider worth writing |
|---|---|---|---|---|---|
| z-ai/glm-5.3-flash | wafer | 0.5 s | 1.0 s | 2.2 s | io-net/fp8 |
| z-ai/glm-5.2 | together | 0.3 s | 0.5 s | 1.1 s | together |
| openai/gpt-5.6-luna | amazon-bedrock/us-east-1 | 0.7 s | 1.9 s | 1.3 s | amazon-bedrock/us-east-1 |
The same model on its slowest endpoint took four to twelve seconds for the same request, which is why the pin exists. Wafer got its first word out soonest under Flash and finished both requests slower than io.net, with the lowest listed uptime of the twenty-four, so a household pinning Flash would do better with io-net/fp8. Two things to know before writing a pin. A pin has no fallback: an endpoint that is rate-limited or down refuses the reply instead of handing it to another, and on the day of the measurement nine of Flash's twenty-four endpoints were refusing for a minute at a time. And the numbers are one afternoon's; the eval that made them (test/dobby/eval/provider_eval_test.exs) reruns in a few minutes per model when they look stale. GLM 5.2 costs about twenty times Flash per token and answers in half the time; Luna sits between them on both. Luna stays the default because Dobby is built and tested against it first.
The same seven settings are a form on /admin. After a save, each field says whether it took effect now or waits for a restart.
Dobby writes this file too
The forms on /house and /admin, a device Dobby proposes in the thread, and an agent over MCP all write to this file. One writer handles every change, so two saves at once queue instead of colliding. Two things to know before you edit by hand:
- Hand edits take effect when Dobby restarts.
- When Dobby writes, it rewrites the whole file. Your comments and key order do not survive.
What you should see
# This file is written by Dobby, and rewritten in full every time the # house or the system panel is changed. Comments and key order do not # survive that — the file is the record, not the document.
The header Dobby puts on a file it has written. If your file starts with it, something other than your editor has saved it.