This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
HA Assist (Voice/Text) → HA Custom Component (Python) → Home Mind Server (Express/TS) → LLM API (Anthropic/OpenAI/Ollama) + Shodh Memory + HA REST API
Single path, no fallbacks. All interactions flow through home-mind-server. Shodh Memory is the only memory backend (required, no SQLite fallback). LLM provider is selected via LLM_PROVIDER env var (default: anthropic).
TopologyScanner (ha/topology-scanner.ts) runs at startup and every 30 minutes. It uses the HA template API (POST /api/template) with a single Jinja2 query that calls floors(), floor_name(), floor_areas(), area_name(), area_entities(), and areas() (available since HA 2024.4). Unassigned areas are derived by exclusion — areas not returned by any floor_areas() call.
Builds a compact floor → room → [entity_ids] text section and injects it into every system prompt alongside the device cheat sheet. This gives the LLM spatial awareness without tool calls — it knows which floor and room every device belongs to before reasoning begins.
If the template API fails (older HA, network error), the scanner logs a warning and injects nothing — the rest of the system works normally.
DeviceScanner (ha/device-scanner.ts) runs at startup and every 30 minutes. It fetches all light.* entities, reads supported_color_modes attributes, and builds DeviceCapabilityProfile objects with pre-computed whiteMethod and colorMethod. These are formatted as a markdown cheat sheet and injected into every system prompt via buildSystemPrompt() / buildSystemPromptText().
White method precedence: rgbw/rgbww → rgbw_color: [0,0,0,255]; color_temp → color_temp_kelvin; rgb/xy/hs → rgb_color: [255,255,255]; else → none.
DEVICE_OVERRIDES env var (JSON) allows per-entity overrides for devices with incorrect HA-reported modes (e.g. Gledopto GL-C-008P always reports color_temp+xy regardless of wiring). Overrides are applied after auto-detection. Fields: whiteMethod and/or colorMethod.
- Load user's facts from Shodh via semantic search (query = current message)
- Refresh DeviceScanner + TopologyScanner if stale (both run in parallel via
Promise.all) - Build system prompt: static part (cached via
cache_control: ephemeralfor Anthropic, plain string for OpenAI) + dynamic part (facts + datetime + home layout + device cheat sheet) - Load conversation history from
IConversationStore(keyed by conversationId) - Stream response with tool loop (parallel tool execution)
- Fire-and-forget fact extraction (extracts facts, replaces conflicting old ones)
- Return response to caller
- Chat:
IChatEngine— handles conversation + HA tool calls. Implementations:LLMClient(Anthropic),OpenAIChatEngine(OpenAI/Ollama) - Extraction:
IFactExtractor— extracts facts from conversation (async, non-blocking). Implementations:FactExtractor(Anthropic),OpenAIFactExtractor(OpenAI/Ollama)
Provider is selected at startup by llm/factory.ts based on LLM_PROVIDER config.
- Long-term facts: Shodh Memory (external service, semantic search, Hebbian learning, natural decay)
- Conversation history:
IConversationStorewith two backends:InMemoryConversationStore(default, lost on restart) andSqliteConversationStore(persistent viabetter-sqlite3). Controlled byCONVERSATION_STORAGEenv var (memory|sqlite). Max 20 messages/conversation. - Known users live in their own
userstable (sqlite) /knownUsersset (memory) — deliberately not derived from conversation rows, whichcleanupOldConversations()prunes after 24h. Deriving them from messages is what silently disabledMemoryCleanupJobbefore 0.15.7. - Entity cache: 10-second TTL in HomeAssistantClient (invalidated after service calls)
- Fact categories: baseline, preference, identity, device, pattern, correction
- Smart replacement: Extractor identifies existing facts that new facts supersede (via
replacesfield)
All commands run from src/home-mind-server/:
npm run dev # tsx watch (hot reload), starts at localhost:3100
npm run build # tsc → dist/
npm run typecheck # tsc --noEmit
npm run lint # eslint src/
npm test # vitest run
npm run test:watch # vitest (watch mode)
npm run test:coverage # vitest with v8 coverage
# Single test file
npm test -- src/memory/shodh-client.test.ts
# Single test by name
npm test -- -t "can check health"Requires Shodh Memory running at SHODH_URL. For local dev: cp .env.example .env and fill in credentials.
Vitest with globals: true. Tests mock constructors using class syntax in vi.mock() factories (not vi.fn().mockImplementation()) because the code uses new. Config tests use vi.resetModules() + dynamic import() to test different env var configurations.
Integration tests (e.g., shodh-client.test.ts) only run when SHODH_TEST_URL and SHODH_TEST_API_KEY are set — otherwise describe.skip.
ES Modules with .js extensions in TypeScript imports (required by moduleResolution: "NodeNext" in tsconfig):
import { loadConfig } from "./config.js";Zod validation for all config and request schemas. Config loads from env vars via loadConfig() in config.ts — exits process on validation failure. Uses emptyToUndefined() helper because Docker Compose sets empty strings (not undefined) for unset env vars.
HA tool definitions are provider-neutral ToolDefinition[] in llm/tool-definitions.ts, converted to provider format via toAnthropicTools() / toOpenAITools(). Six tools: get_state, get_entities, search_entities, call_service, get_history, forget_memory. Shared execution logic in llm/tool-handler.ts. (The Nives fork has eleven — it adds the automation lifecycle and list_services. Those are deliberately not here.)
Tool loop is capped at MAX_TOOL_ITERATIONS (8) in both engines. On the final iteration the request is re-issued with tool calling disabled (tool_choice: "none" / {type: "none"}) so a stuck model produces a written answer instead of running to the client's 120s timeout. Tool arguments are parsed defensively — malformed JSON becomes a tool error the model can retry, never an exception that fails the request.
Prompt caching: System prompt split into static (cached) + dynamic (facts/datetime) blocks in llm/prompts.ts. Two variants: regular and voice (shorter). Custom prompt replaces the default identity line (opening sentence) rather than appending — this gives it maximum authority over persona. Dynamic block includes both human-readable datetime with UTC offset (e.g., 10:15 PM CET (UTC+1)) and a raw ISO timestamp for unambiguous tool use.
Timestamp normalization: normalizeTimestamp() in tool-handler.ts appends Z to bare ISO timestamps (no timezone suffix) before passing them to HA. This prevents HA from misinterpreting timezone-naive timestamps from the LLM. All tool calls are debug-logged with [tool] prefix showing name, input, and elapsed time.
HA light service data fields: brightness (0-255), rgb_color ([R,G,B] each 0-255), color_temp_kelvin (2000-6500; 2700=warm white, 4000=neutral, 6500=daylight), hs_color ([hue 0-360, saturation 0-100]), rgbw_color ([R,G,B,W] each 0-255). For white light on RGBW strips (WLED etc.), use rgbw_color: [0,0,0,255] — the dedicated white LED channel. color_temp_kelvin is accepted by HA but WLED doesn't render it correctly on RGBW strips. For non-RGBW lights, color_temp_kelvin works fine. Check supported_color_modes in entity attributes to determine which mode to use. There is no separate set_color service — use light.turn_on with data fields. These are documented in call_service tool description in tool-definitions.ts.
White light mode selection (by supported_color_modes): rgbw → rgbw_color: [0,0,0,255]; color_temp only → color_temp_kelvin; xy/hs/rgb (RGB-only, no white channel) → rgb_color: [255,255,255]. RGB-only lights (e.g. Gledopto GL-C-008P) cannot render color_temp_kelvin even though HA accepts it — they need explicit RGB values.
Shodh type mapping: Our fact categories map to Shodh memory types (e.g., baseline → Observation, preference → Preference) in shodh-client.ts.
Self-signed TLS: HA client uses undici Agent with rejectUnauthorized: false when HA_SKIP_TLS_VERIFY=true. Note: when the server runs in Docker and connects to HA over LAN, use http:// in HA_URL — HA typically only serves HTTPS via add-ons or reverse proxies (e.g. Tailscale), not on the raw LAN IP. Using https:// against a plain HTTP endpoint causes SSL handshake failures even with HA_SKIP_TLS_VERIFY=true.
Token estimation: Uses content.length / 4 as rough token count (4 chars ≈ 1 token) for fact budget limiting.
JSON from LLMs: Both fact extractors strip markdown code fences (```json ... ```) before JSON.parse() — LLMs sometimes wrap JSON responses.
Startup sequence: Server checks Shodh health (memory.isHealthy()) and exits with process.exit(1) if unhealthy, before Express starts listening. Graceful shutdown handles SIGTERM/SIGINT and calls memory.close().
SSE streaming: /api/chat/stream sends event: chunk for text, event: done with full response, event: error on failure. Calls res.flushHeaders() immediately. Both /api/chat and /api/chat/stream use the same llm.chat() internally — non-streaming just omits the onChunk callback.
HA conversation agent uses intent.IntentResponse (not conversation.IntentResponse):
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_speech(response)
return ConversationResult(response=intent_response)OptionsFlow has no __init__ — passing config_entry to the superclass constructor breaks in newer HA versions (fixed in v0.9.1).
Voice detection: Detected via user_input.agent_id is not None (not HA metadata), sets isVoice=true on server request.
Conversation IDs: Uses ulid.ulid_now() (not UUID).
120-second timeout: DEFAULT_TIMEOUT = 120 for API calls because tool-using LLM responses can take 60+ seconds.
Server requires: HA_URL, HA_TOKEN, SHODH_URL, SHODH_API_KEY, plus the API key for the selected provider (not needed for Ollama).
LLM config:
LLM_PROVIDER—anthropic(default),openai, orollamaLLM_MODEL— model ID (default:claude-haiku-4-5-20251001; must be set explicitly for Ollama)ANTHROPIC_API_KEY— required whenLLM_PROVIDER=anthropicOPENAI_API_KEY— required whenLLM_PROVIDER=openaiOPENAI_BASE_URL— optional, for OpenAI-compatible APIs (Azure, local proxies)OPENAI_RESPONSE_FORMAT— optional,json_object. Sendsresponse_format: {type:"json_object"}on fact-extraction calls only (chat returns prose and ignores it). Off by default because not every OpenAI-compatible provider accepts the field. Fixes empty extractor responses on strict endpoints — qwen3.x in particular. Shipped v0.15.4 (#21)OPENAI_MAX_TOKENS— optional completion cap. The default is tight enough to truncate some local models; #21 raised it to 2048 in practiceOLLAMA_BASE_URL— optional, Ollama API endpoint (default:http://localhost:11434/v1)
Optional: PORT (default 3100), API_TOKEN (bearer token for auth — when set, all endpoints except health require it), HA_SKIP_TLS_VERIFY, MEMORY_TOKEN_LIMIT (default 3000), LOG_LEVEL, CONVERSATION_STORAGE (memory | sqlite, default memory), CONVERSATION_DB_PATH (default /data/conversations.db, only used when CONVERSATION_STORAGE=sqlite), CUSTOM_PROMPT (server-level default custom system prompt), TZ (timezone for the Docker container, default Europe/Prague in docker-compose; Node.js uses this for toLocaleString() so the LLM sees correct local time)
The server works against any OpenAI-compatible endpoint: set LLM_PROVIDER=openai + OPENAI_BASE_URL=<endpoint>/v1 + OPENAI_API_KEY=<key>. It doesn't know or care what's on the other end — OpenRouter, a local shim, LM Studio, a gateway. This is by design and is what keeps this repo model-agnostic.
Historical note: this section used to describe a metering proxy for the paid product. That proxy was retired and nothing in this repo ever depended on it. Nothing here should grow provider-, tier-, or preset-specific logic either — this repo is model-agnostic by design, and how the paid product is powered is deliberately not its concern. See
PRODUCT_SEPARATION.mdin the project hub for the specifics.
STT (optional): STT_PROVIDER (openai | none, default none), STT_API_KEY (overrides OPENAI_API_KEY), STT_BASE_URL (custom Whisper-compatible endpoint), STT_MODEL (default whisper-1)
TTS (optional): TTS_PROVIDER (openai | none, default none), TTS_API_KEY (overrides OPENAI_API_KEY), TTS_BASE_URL (custom OpenAI-compatible endpoint), TTS_MODEL (default tts-1), TTS_VOICE (default alloy)
Integration tests: SHODH_TEST_URL, SHODH_TEST_API_KEY
Optional bearer token auth via API_TOKEN env var. When set, all endpoints except /api/health require Authorization: Bearer <token>. When unset, all requests pass through (backward compat with existing HA integrations).
- Health endpoint (
/api/health) is always public, even when auth is enabled - Timing-safe comparison prevents timing attacks on the token
- HACS config flow validates tokens against
/api/memory/{userId}(not/api/health, since health bypasses auth)
POST /api/chat— Full response (uses streaming internally). Body:{ message, userId?, conversationId?, isVoice?, customPrompt? }POST /api/chat/stream— SSE streaming (event: chunkthenevent: done). Same body as/api/chatPOST /api/stt— Transcribe audio. Multipartaudiofield. Returns{ text }. 501 ifSTT_PROVIDER=none.POST /api/tts— Synthesize speech. Body{ text, language? }. Returnsaudio/mpeg. 501 ifTTS_PROVIDER=none.GET /api/health— Health check (always public, bypasses auth)GET /api/memory/:userId— List user's factsPOST /api/memory/:userId/facts— Add fact manuallyDELETE /api/memory/:userId— Clear all factsDELETE /api/memory/:userId/facts/:factId— Delete specific factGET /api/conversations/:userId— List conversationsGET /api/conversations/:userId/:conversationId— Get conversation messagesDELETE /api/conversations/:userId/:conversationId— Delete conversationGET /api/admin/conversations— All users + conversation summaries (auth required)
Docker Compose (root docker-compose.yml) runs two services: shodh (memory backend, port 3030) and server (API, port 3100). Server depends on Shodh healthcheck.
Shodh Docker: Thin wrapper around the official varunshodh/shodh-memory:latest image. The wrapper (docker/shodh/Dockerfile) adds a custom entrypoint for volume permission migration. No more manual binary/library copying needed. deploy.sh auto-generates SHODH_API_KEY via openssl rand -hex 32 if not set.
HA custom component installed via HACS from https://github.com/hoornet/home-mind-hacs or manually copied to /config/custom_components/home_mind/.
This project has no CI and no published artifact: the git tag and the GitHub release are the only way a user learns a version exists. There is nothing else to notice. That makes the tag load-bearing here in a way it isn't for the Nives add-on, where HA updates from config.yaml regardless.
It has already gone wrong. On 2026-08-09 main was on 0.16.1 while GitHub's Latest release still read 0.15.7 — four versions, including forget_memory (the headline feature of 0.16.0), invisible to everyone who wasn't reading commits. A Reddit post titled "Home Mind 0.16" would have sent people to a Releases page showing 0.15.x.
Every version bump, same commit or immediately after:
- Bump
src/home-mind-server/package.json(andpackage-lock.json) and add theCHANGELOG.mdentry. The README needs no edit: its version badge readspackage.jsonover raw.githubusercontent, and Project Status points at the badge — both derive from the source, so neither can drift. (They used to be hardcoded, and did drift; don't reintroduce a literal.) npm test— this is the only gate; there is no CI to catch a mistake.git tag -a v<version> -m "…"on the commit that is that version, not on whatevermainhappens to be later. If several versions were pushed untagged, tag each on its own commit —git log --onelineand match the CHANGELOG.git push origin v<version>thengh release create v<version> --title "v<version> — <what changed, in plain words>" --notes-file ….
Release notes are for someone deciding whether to upgrade: lead with why they'd care, say plainly if it's security-only, and end with whether any configuration changes are needed.
Sanity check any time you touch this repo: gh release list --limit 1 against package.json. If they disagree, that's the bug.
- Single-user only (multi-user via OIDC planned)
- Conversation history lost on server restart by default (set
CONVERSATION_STORAGE=sqlitefor persistence) - Both chat and extraction use the same model (configured via
LLM_MODEL) - Fact extraction sometimes stores transient state or LLM hallucinations (improvement planned)