A Cloudflare Worker plus Durable Object that connects a Home Assistant smart home to LLMs. It keeps a persistent WebSocket open to Home Assistant, mirrors live entity state in memory, writes every meaningful event to a queryable D1 log, and exposes the whole surface as an MCP server for clients like Claude Desktop and Claude Code.
There is also a built-in chat agent — "Ranger" — reachable at a /chat web UI
and through the ai_chat MCP tool. It runs GLM 5.2 Fast on Fireworks with reasoning
enabled and a native tool-calling loop. The model is configurable at runtime
(see LLM configuration). Imperative garage/bay-door
commands skip the LLM entirely through a sub-500ms fast path.
This is a single-household deployment: the author's house. There is no staging
environment, and a push to main deploys to the live house. The rest of this
file is the working reference for the code. It starts with how the architecture
ended up this shape, because that history explains most of the current design.
The first version was more ambitious. An LLM ran every 60 seconds with the full tool surface: it drained a queue of state changes filtered down to the interesting ones, pulled context from the vector index, and decided whether to act, notify, save an observation, or do nothing. The MCP intro message advertised it with lines like "I locked the back porch deadbolt at 4:49 AM because it was unsecured and the house was occupied." The premise was that a model watching the house around the clock would catch things a person misses.
It didn't work out. Over about a month it produced no proactive intervention worth keeping. Asked directly what it had done, it was candid about the gaps:
- It saw aux heat spike above 5000W and chose not to flag it, because the spike was brief.
- It logged patterns like the basement porch camera triggering eight times at 1:38 AM across four weeks, but never raised them in chat.
- It knew it was supposed to surface those patterns and couldn't work out how to bring them up unprompted.
When it did act, the service calls were either unnecessary (annoying) or wrong (occasionally unsafe). The useful-and-correct case essentially never happened, so on balance the monitor cost more than it returned.
The obvious fix was a better model, but that wasn't the problem. The model reasoned fine; it had the wrong job. A streaming agent sees events one at a time, and you don't notice that the basement porch camera fires at 1:38 AM by looking at a single event — you notice it across thirty days of them. The same model, asked "what happened in the last 24 hours?" in chat, gives a clean narrative and surfaces the right anomalies. Asked "should I interrupt John?" on a 60-second tick, the answer is no almost every time.
The data the monitor was meant to surface already existed: live state in the cache, history in Home Assistant, semantics in the vector index. What was missing wasn't a daemon volunteering things — it was a cheap way to ask.
There was also a concrete reason to stop. My first kid is due in October, and "the AI occasionally does something dangerous on its own" is not something I wanted running with a baby in the house.
So the monitor came out. Now every HA state change, automation fire, and service call lands in D1 — three tables, with HA's context-chain IDs preserved so cause-and-effect queries work, on a 90-day rolling window. On WebSocket reconnect the worker pulls any missed state changes from HA's history API, idempotent against a unique index so a retry can't double-insert.
The chat agent got three tools to read that log: query_state_history,
query_automation_runs, and query_causal_chain (which walks parent/child
context links across all three tables). The same helpers back both the chat
dispatcher and the MCP surface, so Claude Code can reach everything the chat
agent can. A FORENSIC MEMORY block in the system prompt tells the model the
log is its always-on memory and to query it instead of guessing.
The cleanup removed about 720 lines from ha-websocket.js plus seven
now-pointless MCP tools (ai_enable, ai_disable, ai_status, ai_trigger,
ai_clear_log, ai_clear_memory, ai_clear_chat). The bundle dropped from
~390KB to ~352KB, and alarm() collapsed to a WebSocket ping/pong with a
mandatory reschedule. (It later picked up one more job — firing due scheduled
actions; see Scheduler.)
Gone for good: the autonomous prompt, the runAIAgent paths, the event queue,
the burst filters, the synthetic heartbeat, and the aiEnabled flag with its
toggle endpoints. Git history keeps them if the experiment is ever worth
revisiting with a different hypothesis. What stayed is what paid for itself: the
persistent WebSocket and stateCache, the hibernation snapshot, the chat path
and its native tool loop, the vector index, the cover fast path, save_memory
and save_observation (now chat-only, gated on an explicit request), the
ai_log and observations tables, the crons, and the rest of the MCP surface.
The chat agent has been through a lot of models for a one-house deployment. Early on it ran Gemma 4 MoE on Cloudflare Workers AI — local-ish, free, fast at small scale, but its tool-call adherence was poor; it would call services with the wrong arguments. Kimi K2.6 via Moonshot reasoned better but drifted on multi-step chains. NVIDIA's Nemotron Super 120B looked strong on paper and was awkward against the OpenAI tool-call format.
gpt-oss-120b on Groq held the job for a few versions (V13–V15). Inference was fast, but it couldn't keep a multi-step tool chain together — fabricated entity states, reasoning paralysis, falling back to synthesis too early. Those failures only surface once the agent has to chain three or four calls without losing the thread, which is most of the interesting work here.
V16 reverted to MiniMax-M2.7, which handled the long chain better. V17–V26 ran
DeepSeek V4 Flash on Fireworks with reasoning_effort: "high" — cheaper than
MiniMax and better at the chain. V27 moved to MiniMax M3, and the reasoning
control moved from reasoning_effort to the thinking: { type: "enabled" }
toggle (Fireworks rejects sending both). V29 switched to Qwen 3.7 Plus, still on
the thinking toggle.
The current default is GLM 5.2 Fast on Fireworks with reasoning enabled — the
accounts/fireworks/routers/glm-5p2-fast high-speed serving path (same GLM 5.2
model and quality, higher per-token price). It drives reasoning through
reasoning_effort rather than the thinking toggle, so the reasoning mode moved
back to "effort" ("high" maps to its High tier). It's strong on the
multi-step tool chain.
Since V29 the model is no longer a code-level decision: endpoint, model, and
reasoning mode resolve at runtime (DO storage override → env vars → baked-in
defaults), so a swap no longer needs the persistent-WebSocket class rename
described in Operational notes. See
LLM configuration. Every model still fumbles edge cases —
V22 added a deterministic guard against unsupported color descriptors on
light.turn_on, V28 added a coalescer for flat tool-call arguments — but those
get fixed with source-level guards rather than left to the model.
The chat LLM has been runtime-configurable since V29. resolveLLMConfig in
ha-websocket.js resolves the effective config at call time from three layers,
highest precedence first:
- DO storage override — the
llm_configkey, set live via/admin/llm-config. No deploy, no class rename. - Environment variables —
LLM_ENDPOINT,LLM_MODEL,LLM_REASONING_MODE,LLM_REASONING_EFFORT(picked up by a fresh isolate). - Baked-in defaults — static class constants
(
HAWebSocketV29._defaultLLMConfig()): endpointapi.fireworks.ai/.../chat/completions, modelaccounts/fireworks/routers/glm-5p2-fast, reasoning modeeffortathigh.
Both call sites (callLLM, callLLMWithTools) go through _getLLMConfig(), so
they can't drift apart. applyReasoningToBody applies the reasoning setting:
thinking mode sends thinking: { type: "enabled" }, effort sends
reasoning_effort: <effort>, none sends neither. Fireworks rejects thinking
and reasoning_effort together, so the helper emits at most one. Reasoning comes
back in reasoning_content either way, so extraction, the SSE stream, and the
prior-turn re-feed don't depend on which mode is active.
GET /admin/llm-config # effective + stored + env + defaults
POST /admin/llm-config {"model":"accounts/fireworks/models/minimax-m3",
"reasoning_mode":"thinking"} # set override (merged)
POST /admin/llm-config {"reset":true} # clear override → back to env/defaults
sanitizeLLMConfigPatch validates a write (only endpoint, model,
reasoning_mode, reasoning_effort; enums enforced) and updates the in-process
cache, so even the pinned live isolate honors the change on its next call. Any
OpenAI-compatible Fireworks model works; the API key is the FIREWORKS_API_KEY
secret. This is the fast rollback path for a bad model swap, with no redeploy.
User (web / Claude Desktop / Claude Code / WhatsApp[dormant])
│
▼
Cloudflare Worker (worker.js) ◀── /mcp, /chat, /transcribe, /refresh,
│ /health, /twilio[dormant],
│ /admin/bugs, /admin/bugs/clear,
│ /admin/recent_activity,
│ /admin/token-usage,
│ /admin/rebuild-knowledge,
│ /admin/index-stats,
│ /admin/cleanup-stale-vectors,
│ /admin/reindex-observations,
│ /admin/version, /admin/llm-config
│ routing, MCP handler (82-tool surface), CHAT_HTML, ElevenLabs STT
│ proxy, multi-kind backfill, two crons (cache prewarm; daily resync
│ + ai_log + forensic-log retention)
▼
Durable Object: HAWebSocketV29 (ha-websocket.js)
│ singleton "ha-websocket-singleton" — owns the persistent WS
│ in-memory stateCache, hibernation snapshot, cover fast path,
│ HOUSE_STATE_SNAPSHOT builder, chat tool loop, forensic D1 writers,
│ reconnect backfill, ephemeral scheduler, per-channel chat_history
├──► Home Assistant WebSocket (port 8123, JWT auth)
│ subscribes to: state_changed, entity_registry_updated,
│ device_registry_updated, automation_triggered,
│ call_service
├──► Cloudflare D1 "ha_db" (env.DB)
│ tables: ai_log, observations, state_changes, automation_runs,
│ service_calls (bugs live in DO storage — see below)
├──► Cloudflare Vectorize "ha-knowledge" (env.KNOWLEDGE)
├──► Cloudflare Workers AI (env.AI, bge-large-en-v1.5)
├──► GLM 5.2 Fast on Fireworks at api.fireworks.ai (OpenAI-compatible,
│ runtime-configurable model/endpoint/reasoning)
└──► ElevenLabs Scribe at api.elevenlabs.io (speech-to-text)
The layers, bottom to top:
- Physical devices — Zigbee, Z-Wave, Wi-Fi plugs, ESPHome, Roku, Ecobee, Tesla.
- Home Assistant — the open-source server that owns every integration, automation, scene, script, and recorder. WebSocket API on port 8123.
- HA Green — a Nabu Casa appliance running HA OS on the local LAN, cloud-relayed via Nabu Casa for remote access.
- ha-mcp-gateway (this repo) — the Worker and DO. It turns natural language into HA service calls and answers historical questions from the forensic log. Auth is Cloudflare Access JWT for browser users and a long-lived HA token in a worker secret.
- Vectorize knowledge index (
ha-knowledge) — see Knowledge index. - Cloudflare D1 (
ha_db) — the relational store.ai_logkeeps chat/action history,observationskeeps tagged hypotheses, andstate_changes/automation_runs/service_callsare the forensic log. - GLM 5.2 Fast (Fireworks) — chat completions plus native tool calls, run with
reasoning enabled via
reasoning_effort: "high"(GLM 5.2's High tier; it does not use thethinkingtoggle). Model, endpoint, and reasoning mode are runtime-configurable (see LLM configuration). The DO drives the loop, not the model. Each call has a 45sAbortControllertimeout. - ElevenLabs Scribe —
scribe_v1speech-to-text. The Worker proxies the chat UI's audio blobs through/transcribe. - Frontends — Claude Desktop and Claude Code (MCP), and the
/chatHTML UI served by the Worker (SSE streaming, hero mic button, collapsible reasoning panel). Twilio (WhatsApp) is currently dormant.
| File | Role |
|---|---|
src/worker.js |
Cloudflare Worker entry. Owns the MCP handler (TOOLS list — 82 tools — plus handleTool dispatch, getAgentToolset role filter, DANGEROUS_TOOLS set), HTTP routes including /admin/recent_activity, the embedded CHAT_HTML UI, the ElevenLabs STT proxy, the formatBugsAsMarkdown helper, KV cache helpers, the per-kind build*Docs builders, the multi-kind backfillKnowledge, and the scheduled() cron handler (prewarmCache, dailyKnowledgeResync, dailyAiLogRetention, dailyForensicLogRetention). |
src/ha-websocket.js |
Durable Object class HAWebSocketV29 (renamed forward through the persistent-WS refresh dance — see Operational notes). Holds the persistent HA WebSocket and in-memory stateCache. Subscribes to five HA event types: state_changed, entity_registry_updated, device_registry_updated, automation_triggered, call_service. Writes every meaningful event fire-and-forget to D1 via _writeStateChangeToD1 / _writeAutomationRunToD1 / _writeServiceCallToD1, gated by _shouldLogStateChange for the state path (Zigbee/network-noise denylist). Reconnect backfill from HA history via _backfillStateChangesFromHA. Chat system prompt is split into getStaticChatSystemPrompt (cacheable) + buildDynamicContext (per-turn). One execution path: chatWithAgentNative (user-driven, SSE), preceded by _tryDeterministicFastPath for cover commands. Native tool loop in runNativeToolLoop, action executor in executeAIAction, tool dispatcher in executeNativeTool. alarm() runs WS keepalive (ping/pong, reconnect on no-pong, mandatory reschedule) and fires due scheduled actions via _fireDueScheduledActions. |
src/agent-tools.js |
OpenAI-format tool schemas passed to the chat model. ACTION_TOOLS (6: call_service, ai_send_notification, save_memory, save_observation, schedule_action, cancel_scheduled_action) + READ_TOOLS (13: get_state, get_logbook, render_template, vector_search, get_house_topology, get_automation_config, report_bug, query_state_history, query_automation_runs, query_causal_chain, get_nws_weather, get_nws_discussion, list_scheduled_actions) = NATIVE_AGENT_TOOLS, 19 total. CHAT_ALLOWED_TOOL_NAMES includes all 19 — the chat agent has the full native surface. NATIVE_ACTION_TOOL_NAMES marks the 6 side-effecting tools (logged as actions). |
src/vectorize-schema.js |
Canonical metadata schema, vectorIdFor(kind, refId), FNV-1a hash, topicTagFor(text), per-kind embed-text builders, isNoisyEntity / isNoisySwitch / isNoisyService / entityCategoryFor helpers, buildMetadata (lowercase-coerces area, string-coerces is_noisy, propagates created_at). |
migrations/0001_d1_indexes_and_columns.sql |
Indexes on the legacy ai_log / observations / bugs tables. Also adds the nullable data JSON column to observations. |
migrations/0002_forensic_log.sql |
Creates state_changes, automation_runs, service_calls and their context-chain indexes. |
migrations/0003_state_changes_dedup.sql |
De-dupes state_changes and adds a unique index on (entity_id, fired_at_ms, new_state) so reconnect backfill's INSERT OR IGNORE is idempotent. |
wrangler.toml |
Bindings (HA_WS, HA_CACHE, KNOWLEDGE, AI, DB, CF_VERSION_METADATA), build command (esbuild bundles src/worker.js → dist/worker.js), cron triggers (* * * * * cache prewarm, 30 8 * * * daily resync + retention), DO migrations v1→v29 (renamed_classes chain). compatibility_date = "2026-05-09". |
dist/worker.js |
esbuild output. Build artifact — never edit. |
.dev.vars |
Local-dev secrets. Never committed. |
The forensic log is what lets the chat agent answer questions about the house's past. Every meaningful event lands in D1 within seconds, with the HA context chain preserved so cause-and-effect queries are tractable.
state_changes (id, entity_id, friendly_name, domain, old_state, new_state,
attributes_json, fired_at_ms, fired_at_iso, fired_at_central,
context_id, context_parent_id, context_user_id, source)
automation_runs (id, automation_id, automation_name, fired_at_ms,
fired_at_iso, fired_at_central, trigger_entity_id,
trigger_description, context_id, context_parent_id, result)
service_calls (id, domain, service, service_data_json, target_entity_ids,
fired_at_ms, fired_at_iso, fired_at_central,
context_id, context_parent_id, context_user_id)All three tables have time, context, and table-specific indexes.
state_changes also carries a unique (entity_id, fired_at_ms, new_state)
index for backfill idempotency. Schema lives in
migrations/0002_forensic_log.sql and migrations/0003_state_changes_dedup.sql.
| HA event | Forensic table | Filter |
|---|---|---|
state_changed |
state_changes |
_shouldLogStateChange: domain skip (image.* — timestamp churn); suffix denylist (_lqi, _signal_strength, _rssi, _bssid, _ssid, _last_update_trigger, _audio_output, _link_quality — trailing _<digits> stripped first); signal_strength device-class skip; unit skip (dBm/dB/lqi); hard denylist (*_summation_delivered, *_summation_received, roborock *_cleaning_area/*_cleaning_time/*_cleaning_progress/*_filter_time_left/*_main_brush_time_left/*_side_brush_time_left/*_dock_strainer_time_left/*_sensor_dirty_time_left); numeric deadband (power 50W, energy 0.01kWh, voltage 2V, humidity 2%, temperature 0.5°, illuminance 10% relative) |
automation_triggered |
automation_runs |
none — every fire logged |
call_service |
service_calls |
none — every call logged |
The filter is deliberately more permissive than the old monitor's, which was tuned to avoid waking the LLM. For forensic use the cost of an extra row is trivial; the cost of a missing row is a question you can't answer.
Writes are fire-and-forget — the WS handler never blocks on D1. Failures
increment _d1WriteFailures (exposed via the DO /status route and the
cache_status MCP tool) so silent loss is visible.
On every successful HA WS auth-ok, the DO reads last_event_seen_ms from its
storage. If it's set and the gap is under an hour, it pulls
/api/history/period/{iso} from HA and inserts the missed state changes into
state_changes with source='backfill' and context_id=null (HA history
doesn't preserve context). Writes use INSERT OR IGNORE against the unique
index on (entity_id, fired_at_ms, new_state), so backfill is idempotent.
Service calls and automation fires are not backfilled — HA's REST API doesn't
expose them reliably, and brief gaps in those tables on reconnect are
acceptable.
90-day rolling window for all three forensic tables. dailyForensicLogRetention
runs at 03:30 CDT (08:30 UTC) alongside dailyAiLogRetention and
dailyKnowledgeResync.
Three tools surface the log to the chat agent and to MCP clients:
query_state_history— filtered SELECT overstate_changes. Filters:entity_id,entity_id_like,domain,new_state,since/until(ISO 8601 with offset orNOW-Nh/NOW-Nm/NOW-Ndrelative). Default window is the last 24 hours.limitdefaults to 50, capped at 500.query_automation_runs— same shape overautomation_runs. Filters:automation_id,automation_id_like,trigger_entity_id,since/until.query_causal_chain— given acontext_id, walks parent/child links across all three tables.direction: forward | backward | both,depth: 1-10(default 5). It's an iterative walker, not a single recursive CTE — each pass is one D1 query, depth-bounded, results de-duped and sorted chronologically.
Single helpers (_executeQueryStateHistory, _executeQueryAutomationRuns,
_executeQueryCausalChain) back both the native chat dispatcher (via
executeNativeTool) and the MCP endpoints, so there's no duplication.
GET /admin/recent_activity?hours=N (default 1, max 720) returns a plain-text
dump of the last N hours UNION'd across all three tables:
[May 12, 2026, 2:32 PM] state_change input_boolean.basement_work_mode off -> on
[May 12, 2026, 2:32 PM] service_call input_boolean.turn_on input_boolean.basement_work_mode
?format=json returns structured rows. Useful for verifying data flow without
invoking the chat agent.
GET /admin/token-usage?days=N (default 30, clamp 1–30) rolls up the chat
agent's Fireworks token usage per day from the ai_log chat_timing_ms rows —
the native tool loop records each response's usage (prompt / completion /
cached) totals there. Default output is a plain-text table; ?format=json
returns structured rows (per_day, totals, averages), ?format=markdown a
Markdown table:
Day Prompt Completion Cached Total Turns
---------- ------ ---------- ------ ------ -----
2026-07-04 48,120 12,880 31,020 61,000 42
TOTAL (1d) 48,120 12,880 31,020 61,000 42
AVG/day 48,120 12,880 31,020 61,000 42
Day buckets are UTC; total_tokens = prompt + completion (cached is the cache-hit
subset of prompt); fast-path / error turns count as turns with 0 tokens; history
is capped at ai_log's 30-day retention. Tokens only — multiply by the current
Fireworks per-model rate for a dollar figure.
Cloudflare Vectorize, 1024-dim, cosine, model @cf/baai/bge-large-en-v1.5,
pooling cls (it must match at backfill and query time — mismatched pooling
gives near-random rankings).
| Field | Type | Filterable? | Notes |
|---|---|---|---|
kind |
string | ✓ | entity automation script scene area device service memory observation |
ref_id |
string | entity_id, automation HA-internal id, <domain>.<service>, topicTagFor(text) for observations, fnv1aHex(text) for memories |
|
friendly_name |
string | Display label (first 80 chars for memory/observation) | |
domain |
string | ✓ | Entity domain for entity kind, kind name otherwise |
area |
string | ✓ | Resolved area name, "" if none |
entity_category |
string | ✓ | primary diagnostic config (entity-only) |
is_noisy |
string | ✓ | "true" / "false" literals (string-typed index). device_class: battery is exempted. |
topic_tag |
string | ✓ | Bracketed prefix for observations ("[topic-name]"). retrieveKnowledge normalizes — callers can pass "foo" or "[foo]". |
hash |
string | fnv1a of embed text — change detection | |
created_at |
string | ISO timestamp on memory/observation — drives time-decay scoring | |
device_class |
string | Entity-only extra; not filterable |
Default vector_search filters out is_noisy: "true" records. Pass
include_noisy: true to include them.
{kind}:{ref_id} truncated to 64 bytes with an _<fnv1aHex> suffix when the
prefixed form would exceed the cap. vectorIdFor(kind, refId) is the single
source of truth. For observations, ref_id is topicTagFor(text), locking
Vectorize identity to the D1 primary key.
| Kind | Refresh trigger |
|---|---|
| entity | event-driven (entity_registry_updated) |
| device | event-driven (device_registry_updated) + nightly cron resync |
| memory | write-through (executeAIAction.save_memory) |
| observation | write-through (executeAIAction.save_observation to D1) |
| automation | nightly cron |
| script | nightly cron |
| area | nightly cron |
| service | nightly cron |
| scene | manual rebuild only — single scene in this household, dropped from the nightly resync |
dailyKnowledgeResync resyncs automation, script, area, device,
service — the slow-changing kinds not already covered by event-driven or
write-through updates. Hash-based skip means most docs land in the "skipped"
column on a typical run.
POST /admin/rebuild-knowledge?force=1&kinds=a,b,c— multi-kind backfill.GET /admin/index-stats— last 20 backfill summaries.POST /admin/cleanup-stale-vectors— one-shot legacy cleanup (idempotent).POST /admin/reindex-observations— enumerates observation vectors via a multi-probe similarity query (topK=100 per probe, 4 probes ≈ 400 IDs), deletes anything not in the canonicaltopicTagFor-derived keep-set, then force-rebuilds the observation kind. Idempotent; re-run if the index ever holds more than 400 observation vectors and a single pass misses some.- DO
POST /vector_search— internal endpoint backing thevector_searchtool.
wrangler vectorize create ha-knowledge --dimensions=1024 --metric=cosine
wrangler vectorize create-metadata-index ha-knowledge --property-name=kind --type=string
wrangler vectorize create-metadata-index ha-knowledge --property-name=domain --type=string
wrangler vectorize create-metadata-index ha-knowledge --property-name=area --type=string
wrangler vectorize create-metadata-index ha-knowledge --property-name=entity_category --type=string
wrangler vectorize create-metadata-index ha-knowledge --property-name=is_noisy --type=string
wrangler vectorize create-metadata-index ha-knowledge --property-name=topic_tag --type=stringMetadata indexes are immutable, so declare all six at creation time.
_buildHouseStateSnapshot() emits a small text block read directly from the
in-memory stateCache. It groups a curated set of entity IDs into seven blocks
— LOCKS (4), COVERS (3 garage/basement bay doors), CLIMATE (2
thermostats with inline current/target/hvac_action), PRESENCE (2 person
trackers), POWER (whole-home demand), EXTERIOR DOORS / CONTACT SENSORS
(4), and TESLA MODEL Y (8 EV status entities) — and prints
<entity_id> (<friendly_name>): <state>, with cover position inline when
present.
It's gated, because most turns don't need it. buildDynamicContext injects the
snapshot only when:
- the user's message matches
HOUSE_STATUS_TRIGGER_RE(aggregate-state terms like "house", "status", "secure", "everything"; security terms "alarm", "armed", "intrusion"; presence terms "home", "away", "gone", "left", "back", "outside"; vehicle terms "tesla", "car", "vehicle"; plus the household first names "John" and "Sabrina"), or - vector retrieval surfaced no entity scoring ≥ 0.65 (an open-ended question with no specific target), or
- defensively, when the caller passed no message.
It earns its place three ways:
- Authoritative for the listed entities. The prompt tells the model to trust the snapshot — it's regenerated every turn from live cache. This is what makes the TRUTHFULNESS rule enforceable.
- Aggregation guard. Claims like "everything secure" are forbidden unless
every asserted entity is in the snapshot or the agent called
get_stateon it this turn. - Fast-path no-op guard. The cover fast path checks the snapshot — if the target is already in the requested state, it replies "already closed" without firing a service call.
Anything not in the snapshot is fair game for get_state, vector_search, or
the forensic query tools.
The chat model is given OpenAI-format tool schemas (NATIVE_AGENT_TOOLS in
agent-tools.js, 19 tools). The DO drives the loop: send messages, read
tool_calls, dispatch via executeNativeTool, push tool results back, repeat
until the model emits no tool_calls. callLLMWithTools posts to Fireworks
(temperature: 0) using the runtime-resolved model and reasoning mode — by
default accounts/fireworks/routers/glm-5p2-fast with reasoning_effort: "high" (see
LLM configuration) — with a 45s AbortController timeout.
Caps:
- Chat path (
chatWithAgentNative):runNativeToolLoopis invoked withmaxIterations: 6,maxTokens: 4096,hallucinationGuard: true. The tool set is the full 19-tool surface filtered throughCHAT_ALLOWED_TOOL_NAMES(a defensive no-op now that all 19 are allowed; kept for future flexibility).runNativeToolLoop's own defaults —maxIterations: 8,maxTokens: 16384— apply to any other caller. - Synthesis fallback on overflow — at the iteration ceiling the loop pushes a
"stop using tools, compose now" message and re-calls the model with
tools: []. - Post-tool fast-return (V11) — when the model fires a single
call_servicewhosedomain.serviceis in a conservative whitelist (light.turn_on/off/toggle,switch.turn_on/off/toggle,lock.lock/unlock,scene.turn_on), the call succeeded, and the model emitted no accompanying text, the loop synthesizes"Done — <friendly> <verb>."server-side and returns without a second model call. Cover, climate, media, scripts,save_*, andreport_bugkeep the LLM wrap-up — their replies benefit from context. - Hallucination guard — if the model's text claims an action ("I'm closing
the garage", "Done — locked") but it emitted no
tool_call, the loop logsaction_hallucinationand forces a retry.
Tool surface (19 total — the chat agent's native set):
| Tool | Side effect | Notes |
|---|---|---|
call_service |
yes | Any HA service. return_response: true for data-returning services. |
ai_send_notification |
yes | notify.notify + writes a notification entry to ai_log. |
save_memory |
yes | Append to DO storage (100 FIFO, PINNED: prefix exempt). Embed + upsert. Chat-prompt rule: only on explicit user request. |
save_observation |
yes | INSERT OR REPLACE into D1 observations keyed on topicTagFor(text). Embed + upsert. Chat-prompt rule: only on explicit user request. |
schedule_action |
yes | One-shot HA service call fired once after a delay, then auto-deleted. ~60s granularity. See Scheduler. |
cancel_scheduled_action |
yes | Cancel a pending scheduled action by task_id. |
get_state |
no | stateCache hit; force_refresh: true fetches via REST GET /api/states/{id} (single entity, updates cache); force_refresh: "bulk" repopulates the entire cache via WS get_states. |
get_logbook |
no | HA /api/logbook. The schema requires an explicit TZ offset (-05:00 for CDT). |
render_template |
no | HA /api/template. Jinja2 evaluation. |
vector_search |
no | DO /vector_search → retrieveKnowledge. Multi-kind metadata-filtered semantic search. Args: query, kinds (REQUIRED), domain, area, topic_tag, min_score (default 0.50), top_k (default 15, max 50), include_noisy. |
get_house_topology |
no | Zero-arg. Returns the full room-by-room layout (_getHouseTopologyText()), moved out of the default prompt in V10. |
get_automation_config |
no | HA /api/config/automation/config/{id}. Returns the trigger/condition/action body. |
report_bug |
log-only | User-flagged issue capture to DO bugs storage (per-id keys, FIFO 200). |
query_state_history |
no | Filtered SELECT over state_changes. See Forensic event log. |
query_automation_runs |
no | Filtered SELECT over automation_runs. |
query_causal_chain |
no | Iterative parent/child walker across all three forensic tables. |
get_nws_weather |
no | Official NWS forecast for the home location (alerts, hourly, 7-day). ~1h cache. |
get_nws_discussion |
no | Latest NWS Area Forecast Discussion (the meteorologist narrative). ~4h cache. |
list_scheduled_actions |
no | List pending scheduled actions, soonest-first. See Scheduler. |
Action tools are dispatched through executeAIAction with
source="native_loop" so ai_log records who did what (legacy_json /
native_loop / tool_call / fast_path).
Tool messages persisted into chat_history are truncated at 4 KB
(TOOL_CONTENT_CAP) so long results don't bloat the byte budget across turns.
Tool results run through _reformatToolResultTimestamps before being pushed
back, converting ISO/UTC timestamps to Central.
V13 split the chat system prompt in two so the leading prefix caches cleanly against Fireworks' passive prefix cache.
getStaticChatSystemPrompt() — 100% static, byte-identical on every request
and channel, sent as the system message. Sections, in order:
getAgentContext()— IDENTITY (the agent is "Ranger"), PERSONALITY, HOUSEHOLD, LAYOUT NOTE, LOCK MAP, GARAGE/BASEMENT BAY DOORS, KEY DEVICES.- "Be concise / take action" framing.
- TOOLS — notes beyond the attached schemas.
- COMMITMENT RULE (don't claim to have done something you didn't tool-call).
- TOOL ERROR HANDLING — NO CONFABULATION (an
error-shaped tool result means the call failed; surface it verbatim, never fabricate success). - BUG REPORTS (when to call
report_bug; check the result before replying). - SAVING MEMORIES / OBSERVATIONS (chat-path rule: explicit user request only).
- ACTION CONFIRMATION (user-initiated commands act immediately; agent-initiated proposals require an explicit affirmative — emoji, "sure", silence, and topic changes do not count).
- QUICK FACTS (thermostat zone disambiguation, smoke/CO note, the Central-time timestamp rule, the Tesla Model Y control cheat-sheet).
- RETRIEVAL DISCIPLINE (vector_search-first when the entity isn't visible).
- FORENSIC MEMORY (the agent's always-on log — points at the three query tools).
- TRUTHFULNESS — STATE CLAIMS (no state assertion without verification this turn).
buildDynamicContext(ctx) — every per-request value, built once per turn and
prepended to the trailing user message (so it doesn't bust the static prefix).
Order: "answering a chat from X", GATEWAY HEALTH (degraded-only), climate
preamble (when HVAC keywords are detected), HOUSE_STATE_SNAPSHOT (gated — see
above), UNIFIED TIMELINE (recent non-chat ai_log events — actions, state
changes, notifications, memory/observation writes — last hour / 15 events,
Central time), the semantic top-K blocks (RELEVANT MEMORIES / OBSERVATIONS /
AUTOMATIONS, score ≥ 0.65), and RELEVANT ENTITIES (top-K from the live state
cache).
The trailing user turn is ${dynamicContext}\n\nCurrent time: ${now}\n\n${message}.
_tryDeterministicFastPath(message) runs at the top of chatWithAgentNative.
It short-circuits to a direct cover.open_cover or cover.close_cover service
call when:
- Open-verb XOR close-verb (
open|raise|liftvsclose|shut|lower|drop). - Not a question (a trailing
?or sentence-initial state verb bails). - The target entity matches an explicit phrase or a bare-noun fallback that survives the disqualifier regex.
- The cover is not already in the target state (
stateCacheno-op guard).
On match: explicit open_cover / close_cover (never toggle), an ai_log
entry tagged source: "fast_path", the SSE reply event emitted before
bookkeeping (V12 latency reorder), and history persisted. Returns
{ reply, actions_taken, fast_path: true }. Sub-500ms typical.
schedule_action, list_scheduled_actions, and cancel_scheduled_action let
the chat agent set up one-shot, delayed service calls — "turn the drop lights
off in an hour", "set the thermostat back in 30 minutes". A pending task lives in
DO storage under the scheduled:<uuid> namespace (not in HA's automation list)
and is deleted once it fires. Granularity is the 60-second alarm() tick, which
calls _fireDueScheduledActions for anything past due.
Two rules keep it safe:
- Delete before fire. A task is removed from storage before its service call
runs, so a crash mid-fire loses the task rather than firing it twice — the
right trade for non-idempotent services like
toggle. - WS-up guard. The tick skips firing when the HA WebSocket is down, so a transient outage doesn't quietly drop tasks.
Compound requests ("turn X on for an hour", "AC down two degrees then back up")
are handled by the agent in a single turn: it calls call_service for the
immediate change and schedule_action for the reversal, snapshotting any "from
the current value" arithmetic into absolute values at schedule time.
The implementation is _scheduleAction / _listScheduledActions /
_cancelScheduledAction / _fireDueScheduledActions on the DO, dispatched
through executeNativeTool for the chat agent and through the DO's internal
HTTP router for the MCP surface.
The Worker exposes a JSON-RPC 2.0 MCP endpoint at POST /mcp (and /).
handleMCP supports initialize, notifications/initialized, tools/list,
tools/call, and ping; serverInfo reports version 5.1.0.
This is a separate, much broader surface than the chat agent's 19-tool native
set. tools/list returns getAgentToolset("mcp_external") — the full 82-tool
TOOLS array in worker.js: complete HA control (entities, devices, areas,
floors, labels, automations, scripts, scenes, dashboards, calendars, todo lists,
input helpers, services), plus the agent-state tools (save_memory,
save_observation, ai_send_notification, ai_log, ai_memory,
ai_observations, vector_search), the three forensic query tools, the three
scheduler tools, report_bug, and ai_chat (which routes a message into the
chat agent). handleTool is the dispatcher; many tools delegate to the DO over
HTTP, and the forensic query tools share single helper implementations with the
chat agent's executeNativeTool.
getAgentToolset filters out a DANGEROUS_TOOLS set (12 tools — restart_ha,
create/update/delete_automation, bulk_disable/enable_entities,
disable/enable_entity, update_entity_registry, update_dashboard_config,
clear_cache, fire_event) for non-external roles. External MCP clients get the
full set; Cloudflare Access sits in front of /mcp regardless, and
MCP_AUTH_TOKEN adds an optional bearer check.
Three native chat tools have no MCP equivalent (get_house_topology,
get_nws_weather, get_nws_discussion); everything else in the 19-tool native
set maps to an MCP tool.
| Bucket | Storage | Cap | Semantics |
|---|---|---|---|
ai_memory |
DO storage | 100 FIFO | Confirmed long-term facts. Embedded into KNOWLEDGE on write. PINNED: prefix exempt from FIFO. |
observations |
D1 observations table |
Unbounded | Tagged hypotheses-in-progress. INSERT OR REPLACE keyed on topicTagFor(text). Embedded into KNOWLEDGE. |
ai_log |
D1 ai_log table + in-memory ring |
30 days D1 / 1000 in-memory | Unified timeline: chat_user, chat_reply, action, action_verified, notification, memory_saved, observation_saved, chat_timing_ms, vector_*, errors. Pruned >30 days via daily cron. |
state_changes / automation_runs / service_calls |
D1 | 90 days each | Forensic log. See Forensic event log. |
chat_history:${channelKey} |
DO storage | 8 user turns (6 on the fast path) / 110 KB | OpenAI-format messages including tool_calls and tool results. Per-channel via sanitizeChannelKey(from). |
scheduled:<uuid> |
DO storage | per-task keys | Pending one-shot scheduled actions. Fired and deleted by _fireDueScheduledActions. See Scheduler. |
state_cache_snapshot |
DO storage | 127 KB | Hibernation snapshot for cold-start. |
bug:<id> + bug_ids index |
DO storage | 200 FIFO | User-flagged report_bug entries (per-id keys after the v5 storage fix). Cleared during the iteration ritual. |
last_event_seen_ms |
DO storage | scalar | Cursor for reconnect backfill (_backfillStateChangesFromHA). |
Timeline timestamps are reformatted to Central time at prompt-injection
(HAWebSocketV29._formatTimelineTimestamp). The underlying ai_log entries
stay ISO 8601 for parseability.
| Binding | Resource | Purpose |
|---|---|---|
HA_WS |
Durable Object class HAWebSocketV29 |
Singleton ha-websocket-singleton |
HA_CACHE |
KV namespace | TTL'd cache for HA registries / states between cold starts |
KNOWLEDGE |
Vectorize index ha-knowledge |
Multi-kind semantic index |
AI |
Workers AI | @cf/baai/bge-large-en-v1.5 embeddings (cls pooling) |
DB |
D1 database ha_db |
All forensic + agent persistence |
CF_VERSION_METADATA |
version metadata | Powers /admin/version for stale-DO detection |
| Secret | Purpose |
|---|---|
HA_URL |
HA base URL (Nabu Casa cloud-relayed) |
HA_TOKEN |
Long-lived HA access token |
FIREWORKS_API_KEY |
api.fireworks.ai bearer — the chat model |
ELEVENLABS_API_KEY |
(optional) xi-api-key for /transcribe |
MCP_AUTH_TOKEN |
(optional) Bearer for /mcp. Cloudflare Access sits in front anyway. |
| Flag | Purpose |
|---|---|
USE_NATIVE_TOOL_LOOP |
"true" — the chat path; the legacy JSON-action fallback is dormant |
USE_VECTOR_ENTITY_RETRIEVAL |
"true" flips context build to vector retrieval. Falls back to flat-list on failure. |
CLIMATE_PREAMBLE_ENABLED |
(optional) "false" disables the climate preamble |
DUMP_SYSTEM_PROMPT |
(optional) "1" emits a one-shot preflight breakdown of the model request body to ai_log. Unset after use. |
LLM_ENDPOINT / LLM_MODEL / LLM_REASONING_MODE / LLM_REASONING_EFFORT |
(optional) env-var layer for the runtime LLM config; overridden by the llm_config DO-storage override, overrides the baked-in defaults. See LLM configuration. |
* * * * *—prewarmCache(env). Warms KV every minute, does a heavy refresh of all registries every 15 minutes, and reconnects the DO if the WS is dead.30 8 * * *— daily resync + retention at 03:30 CDT (08:30 UTC):dailyKnowledgeResync(env)— resyncs the slow-changing kinds.dailyAiLogRetention(env)— prunesai_logrows >30 days.dailyForensicLogRetention(env)— prunesstate_changes,automation_runs,service_callsrows >90 days.
POST /chat (text/event-stream)
→ DO chatWithAgentNative
0. SSE `started` event (iOS Safari heartbeat) + keepalive interval
1. _tryDeterministicFastPath(message) ── cover hit? emit `reply`,
log, persist history, return. No LLM call. Sub-500ms.
2. Load chat_history:${channelKey}
3. _buildNativeContextEntities(message, { entityTopK: 6 })
├ retrieveKnowledge kinds=["entity"], k=6
├ retrieveKnowledge kinds=["memory"], k=3
├ retrieveKnowledge kinds=["observation"], k=3
└ retrieveKnowledge kinds=["automation"], k=2
4. _buildClimatePreambleIfNeeded(message)
5. getStaticChatSystemPrompt() → the `system` message
6. buildDynamicContext(...) → prepended to the user message
7. runNativeToolLoop(messages, {
maxIterations: 6,
allowedTools: 19-tool surface,
hallucinationGuard: true,
maxTokens: 4096,
onEvent
})
loop: callLLMWithTools (45s timeout)
→ emit `reasoning` from the model's reasoning output
→ dispatch tool_calls (including query_state_history etc.)
→ push results
8. Save preserved tool-calling trace to chat_history:${channelKey}
9. SSE events: started | thinking | reasoning | tool_call |
tool_result | reply | error
HA WS event_type=state_changed → onEvent
├ stateCache.set/.delete (unconditional)
├ _persistStateSnapshot (60s cadence, unconditional)
└ if old_state != new_state && _shouldLogStateChange:
_writeStateChangeToD1({…, source: "ws"}) fire-and-forget
_touchLastEventSeen(now) updates backfill cursor
HA WS event_type=automation_triggered → onEvent
└ _writeAutomationRunToD1({…, result: "ran"}) fire-and-forget
HA WS event_type=call_service → onEvent
└ _writeServiceCallToD1({…}) fire-and-forget
HA WS auth_ok
├ subscribeToStateChanges()
├ subscribeToRegistryEvents()
└ subscribeToForensicEvents() (automation_triggered, call_service)
→ state.storage.get("last_event_seen_ms")
if 0 < age < 1h:
_backfillStateChangesFromHA(sinceMs)
GET /api/history/period/{iso}?minimal_response&significant_changes_only=false
for each transition: _writeStateChangeToD1({…, source: "backfill",
context_id: null})
logAI("backfill_complete", …)
HA event entity_registry_updated → handleEntityRegistryUpdated → reembedRefs({kind:"entity", …})
HA event device_registry_updated → handleDeviceRegistryUpdated → reembedRefs({kind:"device", …})
+ ({kind:"entity", refIds:[device's entities]})
Browser mic ──multipart audio──► /transcribe
→ Worker forwards to api.elevenlabs.io/v1/speech-to-text
with model=scribe_v1, xi-api-key=env.ELEVENLABS_API_KEY
→ returns { text, language_code }
→ UI populates input box and auto-sends
CHAT_HTML is a single embedded HTML/CSS/JS template in src/worker.js, served
at /chat. Features:
- Hero mic button + auto-send. A 3-state machine (idle / recording /
processing). It records audio, POSTs to
/transcribe, populates the input box, and auto-submits. - Collapsible reasoning panel. A
<details>element above each assistant bubble surfaces thereasoningSSE stream (the model's reasoning output). Collapsed by default. - Per-message buttons. Copy, retry (on error bubbles), clear.
- iOS Safari "Load failed" auto-retry. Catches the iOS-specific error and silently retries once.
- SSE event handling.
started|reasoning|thinking|tool_call|tool_result|reply|error.
Deploys are git-driven: a push to main triggers a Cloudflare Workers Builds
pipeline (configured in the Cloudflare dashboard) that runs the [build] step
and deploys. A merged commit goes live — treat a push to main as a production
deploy.
# Manual / local deploy from C:\Users\obert\ha-mcp-gateway
wrangler deployEither path runs the [build] directive — esbuild bundles src/worker.js →
dist/worker.js — and reconciles bindings, crons, and DO migrations with
Cloudflare.
# Unit tests (vitest)
npm test# One-time browser login (token cached ~24h)
& "C:\Program Files (x86)\cloudflared\cloudflared.exe" access login `
https://ha-mcp-gateway.obert-john.workers.dev
# Forensic log eyeball
& "C:\Program Files (x86)\cloudflared\cloudflared.exe" access curl `
"https://ha-mcp-gateway.obert-john.workers.dev/admin/recent_activity?hours=1"
# Forensic-log query via chat (write the body to a temp file to dodge
# PowerShell's JSON quoting rules; UTF-8 without BOM is required)
$gw = "https://ha-mcp-gateway.obert-john.workers.dev"
$tmp = "$env:TEMP\probe.json"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($tmp,
'{"message":"Use query_state_history to show what changed in the last 30 minutes."}',
$utf8NoBom)
& "C:\Program Files (x86)\cloudflared\cloudflared.exe" access curl `
"$gw/chat" --silent -X POST `
-H "Content-Type: application/json" --data-binary "@$tmp"
# Direct D1 row counts
wrangler d1 execute ha_db --remote --command="SELECT 'state_changes' AS t, COUNT(*) FROM state_changes UNION ALL SELECT 'automation_runs', COUNT(*) FROM automation_runs UNION ALL SELECT 'service_calls', COUNT(*) FROM service_calls"
# Full knowledge backfill
& "C:\Program Files (x86)\cloudflared\cloudflared.exe" access curl `
"$gw/admin/rebuild-knowledge?force=1" -X POST --max-time 300- A DO with a persistent WS holds onto its V8 isolate across deploys.
Cloudflare normally hibernates an idle DO isolate after ~10s and reloads the
latest code on the next request. The HA WebSocket is always active, so the DO
never idles, so worker-side code can update for arbitrarily long stretches
without the DO picking it up. Symptom: worker-side endpoints work immediately
on deploy, DO-side method-body changes don't. To diagnose, run
wrangler tail --format jsonand compare per-eventscriptVersion.idagainst the latest deployed version id, or compare/admin/version(worker) against the DO/versionroute. To fix, rename the DO class via arenamed_classesmigration. The class has been renamed twenty-eight times for exactly this reason (HAWebSocket→HAWebSocketV2→ … →HAWebSocketV29); storage is preserved across renames. Since V29 the LLM config (model/endpoint/reasoning) is the exception — it's runtime-swappable via/admin/llm-configwith no rename. See LLM configuration. - Three redundant DO keepalives. The persistent HA WebSocket is primary; the
60s alarm (ping/pong + mandatory reschedule) is secondary; the minute-cadence
prewarmCachecron is tertiary. The same alarm tick also fires due scheduled actions (see Scheduler). Removing the alarm reschedule would leave a 60s window in which the DO could hibernate if both the WS and a request happen to be quiet, so don't. - Pooling discipline. Every Workers AI embedding call uses
pooling: "cls". Mixing pooling between backfill and query produces near-random rankings. - Cloudflare Access fronts the worker. A direct curl / Invoke-RestMethod hits
the Access login page. Use
cloudflared access curlor a service token. - Snapshot-oversize is best-effort. If
state_cache_snapshotexceeds 127 KB, we logsnapshot_oversizeand attempt the put anyway (the DO storage hard limit is 128 KiB). If entity count grows past ~1500, the snapshot will need a tighter allowlist or chunking. - Iteration ceiling synthesis. Watch
ai_logforiteration_ceiling_synthesize— it should be rare. The chat cap is 6 iterations. - Fast-path observability. The cover-command fast path tags
ai_logentries withsource: "fast_path". Queryai_logby source to audit hit-rate or false positives. - Latency instrumentation. Every chat turn writes a
chat_timing_msrow toai_logwith per-phase timing, token counts (prompt,completion,cached),tool_iterations, and anintent_tag(fast_path/chat_only/forensic/deterministic_candidate/multi_tool).cached_tokensshould climb on iterations 2+ once the static system+tools prefix is warm; if it stays at 0, the prefix cache isn't hitting. - Time-format leak. Timeline timestamps are pre-formatted Central in the
prompt, and tool results pass through
_reformatToolResultTimestampsbefore being pushed back. If a user-facing reply still shows a UTC/Z suffix, the model copied a timestamp it shouldn't have — QUICK FACTS forbids emitting ISO 8601 /Z/UTCin any reply. - Model timeout. Each call has a 45s
AbortControllertimeout (Fireworks API timeout after 45000ms). Persistent timeouts usually mean api.fireworks.ai is degraded. - D1 write observability.
_d1WriteFailureson the DO surfaces forensic-log write failures via the/statusroute and thecache_statusMCP tool. Spot-check it periodically — silent loss of forensic data is the failure mode worth catching early.
Newest first.
- Ephemeral scheduler. Three tools —
schedule_action,list_scheduled_actions,cancel_scheduled_action— for one-shot delayed service calls. Tasks live in DO storage underscheduled:<uuid>and are fired by the 60salarm()tick (_fireDueScheduledActions), with a delete-before- fire rule and a WS-up guard. See Scheduler. This was item #1 on the old roadmap. - V29 — runtime-configurable LLM config; default GLM 5.2 Fast. The endpoint,
model, and reasoning mode now resolve at call time from a DO storage override →
env vars → baked-in defaults, swappable live via
/admin/llm-configwith no class rename. The default moved from Qwen 3.7 Plus to GLM 5.2, then to GLM 5.2 Fast (theaccounts/fireworks/routers/glm-5p2-fasthigh-speed serving path), with the reasoning mode back onreasoning_effort("high"). See LLM configuration. - V28 — flat tool-call argument coalescer. A guard that repairs tool calls
where the model emitted arguments flat (e.g.
entity_idat the top level) instead of nested underdata, folding them into the expected shape before dispatch. Unit-tested. - V27 — MiniMax M3. Reasoning control moved from
reasoning_effortto thethinking: { type: "enabled" }toggle (Fireworks rejects sending both). - V22 — sanitize light color descriptors before the HA call. DeepSeek V4
Flash repeatedly fumbled
light.turn_onon brightness-only lights by including mutually-exclusive color descriptors (rgb_color,color_temp_kelvin,white, …) alongsidebrightness_pct. HA groups all color descriptors into one mutually-exclusive set ("Color descriptors" 400), and each descriptor requires the matching mode inattributes.supported_color_modes._sanitizeLightServiceDatareads the target'ssupported_color_modesfromstateCacheand strips any descriptor the light doesn't support before the call reaches HA; if multiple compatible descriptors remain, it keeps one by fixed priority. It's wired into bothexecuteAIAction(chat path) and the DO/call_serviceroute (MCP path) so one helper covers both surfaces. Light entity context now also includesbrightness,supported_color_modes, andcolor_modeso the model sees the capability up front, and a one-lineLIGHT CALLSrule was added to QUICK FACTS. Strips are logged toai_logaslight_sanitize. Covered bytest/sanitize-light-service-data.test.js. This wasn't a prompt regression — the MiniMax-era prompt had no color-descriptor guidance either; DeepSeek just inferred the constraint less well from the schema. The deterministic guard is the floor regardless of model. - V21 — chat-prompt trim + Tesla Model Y knowledge. History, timeline, and
top-K retrieval windows tightened; tool schemas condensed; the snapshot
trimmed. A
TESLA MODEL Ygroup was added to HOUSE_STATE_SNAPSHOT (8 EV status entities) and a Tesla control cheat-sheet to QUICK FACTS — charge start/stop, charge limit, cabin precondition, lock, frunk/trunk/charge-port, plus a note that a sleeping car needsbutton.tesla_model_y_wakebefore commands land. - V17–V20 — LLM provider switch to DeepSeek V4 Flash on Fireworks. After a
detour through gpt-oss-120b on Groq (V13–V15) and a revert to MiniMax (V16 —
gpt-oss handled multi-step agentic tool calling poorly: fabricated entity
states, reasoning paralysis, premature synthesis fallback), the chat agent
moved to DeepSeek V4 Flash on Fireworks
(
accounts/fireworks/models/deepseek-v4-flash). V18–V20 enabled Think High reasoning (reasoning_effort: "high"), dropped the conflictingthinkingfield (Fireworks rejects it alongsidereasoning_effort), and stopped strippingreasoning_contentfrom echoed assistant messages — Fireworks requires prior-turn reasoning re-fed for interleaved thinking across tool-call rounds. Auth moved toFIREWORKS_API_KEY. - V13 — system-prompt static/dynamic split for prefix caching.
getChatSystemPromptwas split intogetStaticChatSystemPrompt()(100% static, byte-identical every request) andbuildDynamicContext()(all per-request data, relocated to lead the trailing user turn). The static system- tool-list prefix now matches the provider's passive prefix cache across every
tool-loop iteration.
cached_tokensinstrumentation added torunNativeToolLoop.
- tool-list prefix now matches the provider's passive prefix cache across every
tool-loop iteration.
- V9–V12 — system-prompt slim, behavioral gating, latency cuts. Per-iteration
prompt cut ~64% (≈30k → ≈11k tokens): the timeline excludes chat turns
(duplicated by
conversation_history), architecture prose deleted, JSON entity dumps replaced with one-line text. HOUSE_STATE_SNAPSHOT gated behindHOUSE_STATUS_TRIGGER_RE. House topology moved behind the zero-argget_house_topologytool.MAX_TURNSreduced 10→8. Post-tool fast-return: a single simple action (light/switch/lock/scene) synthesizes "Done — X" server-side and skips the wrap-up LLM call. Fast-path reordered to emit the SSE reply before bookkeeping (~150ms → ~80-100ms perceived). - V7–V8 — phase-timing instrumentation. Per-phase timing, token counts, and
intent_tagadded tochat_timing_msrows; aDUMP_SYSTEM_PROMPT-gated preflight breakdown of the model request body to ground the prompt trim against literal token counts. - report_bug storage architecture fix + agent error-confabulation guardrail.
The old
report_bughandler wrote the entire bugs list as a singlebugsarray key on DO storage. Each entry carriedlast_chat_turns,last_log_entries, and fullentity_states(raw attribute trees), so a handful of entries blew the 128 KiB DO value cap. Writes failed silently, and worse, the chat agent confabulated"Logged bug #..."replies after the error returns. The v5 migration splits storage into per-id keys (bug:<id>plus a smallbug_idsindex — FIFO 200) and trims every field. ATOOL ERROR HANDLING — NO CONFABULATIONblock was added to the chat system prompt. - Forensic noise-filter expansion + observation-vector orphan cleanup. A live
D1 audit found
_summation_deliveredleaking 1,400+ rows/hr despite a source-level hard-deny rule (the rule had been committed after a class rename, so the running DO never picked it up). The v4 rename forced a fresh isolate;_shouldLogStateChangegained animage.*domain skip, roborock cleaning-telemetry suffixes, and a 2V voltage deadband. The same deploy rewrote/admin/reindex-observationsto delete observation vectors orphaned by earlier ref_id schema changes. - Autonomous monitor excision (3-deploy refactor). The 60-second
heartbeat-driven autonomous LLM monitor was removed entirely after ~30 days of
no useful proactive intervention. In its place, every HA event now lands in D1
and the chat agent gained three forensic query tools plus a
FORENSIC MEMORYprompt section. See Background. - (retired) D1 ai_log + observation cutover.
save_observationandai_logwrites moved exclusively to D1 with one-time migrations retiring the DO storage keys. ThedailyAiLogRetentioncron prunes >30-day rows. - (retired) Multi-kind knowledge index (
ha-knowledge). Replaced the entity-onlyha-entitiesindex with the unified nine-kind index; added thevector_searchnative tool,topic_tag+min_scorefilters, friendly-name dedup, observation time-decay, and orphan diff onforce=1rebuilds. - (retired) HOUSE_STATE_SNAPSHOT + TRUTHFULNESS rules. The snapshot is the ground-truth read on security/climate/presence; TRUTHFULNESS forbids unverified state claims.
- (retired) Cover-command fast path. Deterministic sub-500ms cover open/close that bypasses the LLM.
git log --oneline walks back further; anything older than the multi-kind index
migration is foundational and unlikely to need re-reading.
Bugs caught during use are captured in chat — saying things like "that's a bug",
"save to debug log", or "log this as broken". The chat agent calls report_bug,
which writes a structured entry to DO bugs storage (description + last 4 chat
turns + last 10 ai_log entries + cited entity state).
At iteration time, fold captured bugs into BUGS.md and clear the bucket:
# Pull as Markdown
& "C:\Program Files (x86)\cloudflared\cloudflared.exe" access curl `
"https://ha-mcp-gateway.obert-john.workers.dev/admin/bugs?format=markdown" `
> "$env:TEMP\new-bugs.md"
# Prepend $env:TEMP\new-bugs.md to BUGS.md, commit, then clear:
& "C:\Program Files (x86)\cloudflared\cloudflared.exe" access curl `
"https://ha-mcp-gateway.obert-john.workers.dev/admin/bugs/clear" -X POSTTriage one at a time. After fixing, strike through the heading in BUGS.md
(## ~~#abc12345 — …~~) and add Fixed: <commit-sha> under the body — don't
delete entries, the history is the audit trail.
Open items, roughly in order of leverage:
- Daily / weekly digest cron. A scheduled chat call once a day that queries
the forensic log over the last 24 hours and writes a digest to a push
notification or
ai_log. One model call, narrative output. - Backfill of
automation_runsandservice_callson reconnect. HA REST history doesn't expose these reliably, so today only state changes are backfilled. If a long outage ever matters, dig into HA's alternative endpoints. - Fast-path generalization. Cover-only today. Lights and locks have similar shapes; each new domain needs its own no-op guard, disqualifier list, and question-bail tests.
- HOUSE_STATE_SNAPSHOT bytecount. If the snapshot grows past ~60 lines, consider a per-room trim driven by query intent.
- Service-auth bypass for ops endpoints. A service token with an Access
policy bypass for
/admin/*would let ops scripts run withoutcloudflared.