{"owner":"homeassistant-ai","repo":"ha-mcp","hasSkills":true,"hasMcp":true,"mcpConfig":{"mcpServers":{"ha-mcp":{"command":"npx","args":["-y","@modelcontextprotocol/server-ha-mcp"]}}},"found":["AGENTS.md"],"skills":{"AGENTS.md":"# CLAUDE.md\n\nGuidance for Claude Code when working with this repository.\n\n## Repository Structure\n\nThis repository uses a worktree-based development workflow.\n\n**Documentation Setup:**\n- This file is `AGENTS.md` (the canonical source)\n- `CLAUDE.md` is a symlink pointing to `AGENTS.md`\n- Read either file - they're the same content\n- Commit changes to `AGENTS.md`, the symlink will automatically reflect them\n\n**Directory Structure:**\n```\n<repo-root>/                           # Main repository (checkout master here)\n├── AGENTS.md                          # This file (canonical source)\n├── CLAUDE.md -> AGENTS.md             # Symlink for convenience\n├── worktree/                          # Git worktrees (gitignored)\n│   ├── issue-42/                      # Feature branch worktree\n│   └── fix-something/                 # Fix branch worktree\n├── local/                             # Scratch work (gitignored)\n└── .claude/skills/                    # Slash-command skills\n```\n\n**Quick command:** Use `/wt <branch-name>` skill to create worktree automatically.\n\n## Worktree Workflow\n\n### Creating Worktrees\n\n**ALWAYS create worktrees in the `worktree/` subdirectory**, not at the repository root.\n\n```bash\ngit worktree add worktree/issue-42 -b issue-42\ngit worktree add worktree/feat-new-feature -b feat/new-feature\n```\n\n**Cleanup:** `git worktree remove worktree/<name>` or `git worktree prune` for stale references.\n\n### Skills\n\nAll workflow automation is implemented as skills in `.claude/skills/` and invoked with `/skill-name <args>`:\n\n| Skill | Command | Purpose |\n|-------|---------|---------|\n| **issue-analysis** | `/issue-analysis <number>` | Deep issue analysis — codebase exploration, implementation planning, architectural assessment. Posts structured comment and applies labels. |\n| **issue-to-pr-resolver** | `/issue-to-pr-resolver <number>` | End-to-end issue implementation: worktree creation → implementation with tests → draft PR → iterative CI/review resolution until merge-ready. |\n| **my-pr-checker** | `/my-pr-checker <number>` | Review and manage YOUR OWN PRs — check CI, resolve review threads, fix issues, iterate until all checks pass. |\n| **contrib-pr-review** | `/contrib-pr-review <number>` | Review external contributor PRs for safety, quality, and readiness. |\n| **contributors-update** | `/contributors-update` | Find merged PR authors missing from README and update the contributors list after approval. |\n| **wt** | `/wt <branch-name>` | Create git worktree in `worktree/` subdirectory with up-to-date master. |\n| **bat-adhoc** | `/bat-adhoc [scenario]` | Ad-hoc bot acceptance testing with dynamically generated scenarios. |\n| **bat-story-eval** | `/bat-story-eval --baseline v6.6.1` | Diff-based story evaluation: two-version comparison, regression detection. |\n\n## Project Overview\n\n**Home Assistant MCP Server** - A production MCP server enabling AI assistants to control Home Assistant smart homes. Provides tools for entity control, automations, device management, and more.\n\n- **Repo**: `homeassistant-ai/ha-mcp`\n- **Package**: `ha-mcp` on PyPI\n- **Python**: 3.13 only\n\n## Security\n\nSee [SECURITY.md](SECURITY.md) for the threat model, scope, and reporting\ninstructions. The threat model section documents the key design decisions that\ndefine what ha-mcp does and doesn't defend against (trusted MCP clients, local\nnetwork boundary, OAuth Bearer token design, single-tenant standard mode, HA\npermission scope).\n\n**Security advisories:** The API exposes an advisory's body but not its\ndiscussion thread, where the maintainer disposition (dismiss-vs-fix and the\nagreed fix scope) lives. Confirm the scope from that thread (GitHub UI or ask a\nmaintainer) before writing the fix.\n\n## External Documentation\n\nWhen implementing features or debugging, consult these resources:\n\n| Resource | URL | Use For |\n|----------|-----|---------|\n| **Home Assistant REST API** | https://developers.home-assistant.io/docs/api/rest | Entity states, services, config |\n| **Home Assistant WebSocket API** | https://developers.home-assistant.io/docs/api/websocket | Real-time events, subscriptions |\n| **HA Core Source** | `gh api /search/code -f q=\"... repo:home-assistant/core\"` | Undocumented APIs (don't clone) |\n| **HA Add-on Development** | https://developers.home-assistant.io/docs/add-ons | Add-on packaging, config.yaml |\n| **FastMCP Documentation** | https://gofastmcp.com/getting-started/welcome | MCP server framework |\n| **MCP Specification** | https://modelcontextprotocol.io/docs | Protocol details |\n\n## Issue & PR Management\n\n### Automated Code Review\n\n**Codex** reviews PRs automatically (`pr-codex-review-request.yml` /\n`pr-codex-review-delivery.yml`; posts as `chatgpt-codex-connector[bot]`).\nGemini Code Assist is retired — Google sunset its GitHub review activities and\nthe app now only posts sunset-notice banners, so `.gemini/config.yaml`\ndisables it fully. `.gemini/styleguide.md` remains the repo's review-criteria\ndocument (code quality, test coverage, security patterns, MCP conventions,\nsafety annotation accuracy): the `@codex review` request comment points Codex\nat it explicitly, and the Claude review skills below apply it.\n\n**CodeRabbit** (GitHub app, posts as `coderabbitai[bot]`) reviews drafts too —\n`.coderabbit.yaml` sets `reviews.auto_review.drafts: true`, since every PR here\nopens as a draft, and `auto_pause_after_reviewed_commits: 0` so it keeps\nreviewing every push instead of going quiet after five. That spends the\nper-developer hourly review allowance faster; a rate-limited push says so in a\ncomment and never blocks merge, and CodeRabbit's `rate limit` command reports\nwhether reviews are available without consuming one. It auto-detects `AGENTS.md` as review criteria;\n`.gemini/styleguide.md` is added through\n`knowledge_base.code_guidelines.filePatterns` (see the comment there). Repo YAML\noutranks the UI settings (only org/workspace Global Overrides beat it) and does\nnot merge with them — any key it omits falls back to CodeRabbit's schema\ndefaults, not to UI values. A change to `.coderabbit.yaml` never applies to the\nPR making it: on open-source repos CodeRabbit honours only the base branch's\nconfig, so the PR reports `Configuration used: defaults` and the change takes\neffect on merge.\n\n**Bot-authored PRs are excluded from automatic review by both tools** —\nDependabot, Renovate, and the `github-actions[bot]` webhook-proxy promote PRs\n(dev → stable copies whose content was already reviewed in their dev PRs).\nEnforced in `.coderabbit.yaml` `ignore_usernames` and the `pull_request_target`\nadmission list in `pr-codex-review-request.yml`, pinned to each other by\n`test_coderabbit_config.py`. A maintainer can still summon a review on a\npromote PR: `@coderabbitai review`, or for Codex a comment that is exactly\n`/review` (or `@ghhamcp review`) — the `issue_comment` admission list\ndeliberately omits `github-actions[bot]` to keep that lever.\n\n**Division of Labor:**\n- **Codex (automatic)**: Code quality, test coverage, generic security, MCP conventions\n- **CodeRabbit (automatic, drafts included)**: Line-level review against `AGENTS.md` and `.gemini/styleguide.md`, PR walkthrough and summary\n- **Claude `/contrib-pr-review` (on-demand)**: Repo-specific security (AGENTS.md, .github/, .claude/), detailed test analysis, PR size assessment, issue linkage\n- **Claude `/my-pr-checker` (lifecycle)**: Resolve threads, fix issues, monitor CI, create improvement PRs\n\n### Issue Labels\n\n**Triage-state labels** (applied during manual triage):\n\n| Label | Meaning |\n|-------|---------|\n| `ready-to-implement` | Clear path, no decisions needed |\n| `needs-choices` | Multiple approaches, needs stakeholder input |\n| `needs-info` | Awaiting clarification from reporter. `close-needs-info.yml` clocks from the label event: reminders on days 3/5/6, auto-close on day 7 without an author reply; an author reply removes the label |\n| `priority: high/medium/low` | Relative priority |\n| `triaged` | Automated triage complete (historical — applied by the retired `issue-triage.yml` bot) |\n| `triage-failed` | Automated triage failed (historical — applied by the retired `issue-triage.yml` bot) |\n| `issue-analyzed` | Deep Claude analysis complete |\n\n**Bug-class labels** (applied via `.github/ISSUE_TEMPLATE/` form selection, CodeRabbit auto-labeling, or manual triage):\n\n| Label | Meaning |\n|-------|---------|\n| `runtime-bug` | Bug occurring during normal operation (post-startup) |\n| `startup-bug` | Bug during startup, install, or connect |\n| `agent-behavior` | AI agent behavior or workflow feedback (tool selection, prompt drift, etc.) |\n\n**Scope labels** (manually applied during triage; orthogonal to bug-class — an issue can carry both `runtime-bug` AND a scope marker):\n\n| Label | Meaning |\n|-------|---------|\n| `addon` | Issue is specific to the Home Assistant Add-on deployment (`homeassistant-addon/`, Supervisor ingress) |\n| `docker` | Issue is specific to the Docker / containerized deployment (`Dockerfile`, container env) |\n| `javascript` | Issue concerns the project website / Astro app (TypeScript) under `site/` |\n\n**Lifecycle labels** (manually applied; do not double as close-reasons):\n\n| Label | Meaning |\n|-------|---------|\n| `wontfix` | Issue is valid but will not be addressed. Typically used when closing an issue to record the rejection rationale. |\n| `blocked` | Forward progress depends on an unresolved external item (upstream HA change, a sibling PR, a pending design decision). Recorded so a sweeper search can find what's waiting |\n\n**Tracking / automation labels** (applied by tooling):\n\n| Label | Meaning |\n|-------|---------|\n| `python-upgrade` | Auto-attached to every Renovate-managed PR (including non-Python dependency updates) via `renovate.json` global `labels` array. |\n\n### Issue Analysis Workflow\n\n- **Automated Triage (CodeRabbit)**: `issue_enrichment` in `.coderabbit.yaml`. On new and edited issues CodeRabbit posts an enrichment comment (possible duplicates, related issues and PRs, suggested assignees) and auto-applies labels per `labeling_instructions`. Plans are manual: comment `@coderabbitai plan` on an issue, or tick the Create Plan checkbox in the enrichment comment. (Replaces the retired GitHub Models `issue-triage.yml` bot.)\n- **Deep Analysis (Claude)**: When user says \"analyze issues\", list issues missing `issue-analyzed` label, then invoke `/issue-analysis <number>` for each sequentially (the skill drafts analysis for user approval before posting).\n\n```bash\ngh issue list --state open --json number,title,labels --jq '.[] | select(.labels | map(.name) | contains([\"issue-analyzed\"]) | not) | \"#\\(.number): \\(.title)\"'\n```\n\n### PR Review Comments\n\n**Always check for comments after pushing to a PR.** They come from bots\n(Codex, CodeRabbit, Copilot) or humans. Address human comments with highest\npriority; treat bot comments as suggestions to assess, not commands.\n\n**Reply, then resolve.** After addressing an inline comment, reply on its\nthread documenting the fix, then mark the thread resolved. When a review has\ninline comments, do both: reply per-thread *and* post one PR-level summary\ncomment. Leave a thread open only when the reply asks the reviewer for\nclarification. Unresolved threads block merge even after approval: the merge\nbutton stays disabled until every thread is resolved.\n\nThe `/my-pr-checker` skill carries the exact commands (the inline-reply\n`pulls/<PR>/comments/<id>/replies` endpoint, the PR-level review, and the\n`resolveReviewThread` GraphQL mutation, whose input field is `threadId`, not\n`pullRequestReviewThreadId`).\n\n## Git & PR Policies\n\n**CRITICAL - Never commit directly to master, except for documentation-only adjustments.**\n\nYou are STRICTLY PROHIBITED from committing to `master` or `main` branch. Always use worktrees for feature work:\n\n```bash\n# Use /wt skill or manually:\ngit worktree add worktree/<branch-name> -b <branch-name>\ncd worktree/<branch-name>\n```\n\n**Before any commit, verify:**\n1. Current branch: `git rev-parse --abbrev-ref HEAD` (must NOT be master/main)\n2. In worktree: `pwd` (must be in `worktree/` subdirectory)\n\n**Never push or create PRs without user permission.**\n\n**Always create PRs as draft.** Use `gh pr create --draft`. Only mark a PR as ready for review (`gh pr ready <PR>`) when explicitly requested by the user. **Before marking ready, update the PR description** to reflect all changes made since the PR was created.\n\n### PR Workflow\n\n**After creating or updating a PR, always follow this workflow:**\n\n1. **Update tests if needed**\n2. **Commit and push**\n3. **Wait for CI** (~3 min for tests to start and complete):\n   ```bash\n   sleep 180\n   ```\n4. **Check CI status**:\n   ```bash\n   gh pr checks <PR>\n   ```\n5. **Check for review comments** (see \"PR Review Comments\" section above)\n6. **Fix any failures**:\n   ```bash\n   # View failed run logs\n   gh run view <run-id> --log-failed\n\n   # Or find the run ID from PR\n   gh pr checks <PR> --json | jq '.[] | select(.conclusion == \"failure\") | .detailsUrl'\n   ```\n7. **Address review comments** if any (prioritize human comments)\n8. **Update PR description** if the scope changed (only when PR is already marked as ready)\n9. **Repeat steps 2-8 until:**\n   - ✅ All CI checks green\n   - ✅ All comments addressed\n   - ✅ PR ready for merge\n\n### PR Execution Philosophy\n\n**Work autonomously during PR implementation:**\n- Don't ask the user about every small choice or decision during implementation\n- Make reasonable technical decisions based on codebase patterns and best practices\n- Fix unrelated test failures encountered during CI (even if time-consuming)\n- Document choices for final summary\n\n**Making implementation choices:**\n- **DO NOT** choose based on what's faster to implement\n- **DO** consider long-term codebase health - refactoring that benefits maintainability is valid\n- **For non-obvious choices with consequences**: Create 2 mutually exclusive PRs (one for each approach) and let user choose\n- **For obvious choices**: Implement and document in final summary\n\n**When you notice an improvement during a PR**: fix it in place by default. See [Boy Scout Rule — Handling Discovered Improvements](#boy-scout-rule--handling-discovered-improvements) below for the deferral scale.\n\n**Final reporting:** Once the PR is ready, post an Implementation Summary comment on the PR (choices made, problems encountered) and give the user a short summary.\n\n### Boy Scout Rule — Handling Discovered Improvements\n\n**IMPORTANT — Default is fix-in-place.** \"Boy Scout Rule\" means leave touched code better than you found it. \"Improve incrementally\" means commit-by-commit within *this* PR — not across follow-up PRs. Deferral is the exception, not the default. Weigh fix-in-place sweeps against regression risk: if a sweep would meaningfully expand the diff or change the review surface, treat it as Mid-sized and ask the user.\n\n**Never open a follow-up PR or issue without explicit user approval.**\n\nWhen you notice something while working on a PR, apply this scale:\n\n| What you find | Action |\n|---|---|\n| **Small** — a few lines, clearly in scope (see examples below) | **Fix in this PR** as a separate commit. No mention in PR description. |\n| **Mid-sized** — meaningful effort, worth doing but out of scope (e.g. adding a new helper module that doesn't exist yet, a gap that needs non-trivial new test scaffolding, a code-quality issue that's *not* really low) | **Pause before pushing.** Ask the user whether to bundle. |\n| **Large / unrelated** — many files, design decisions, different subsystem (e.g. would double the diff size or change the review surface, code quality is *really* low / technical debt) | Mention in PR description only if the user confirms. Open a separate issue **only if** the user asks AND you can state a concrete benefit in one sentence. |\n\n**\"Small\" examples — fix these inline, no mention needed:**\n\n- Typo, dead import, misnamed local\n- Stale docstring/comment or stale reference\n- 1–N line cleanup of code in this diff\n- Multi-site sweep of the same pattern you can grep for\n- Missing test for code you're touching (add the test without refactoring the surrounding code)\n- Low coverage for the area you're working in\n- Straightforward test-quality fix (better assertions, clearer names, removing duplication)\n- \"Mirror X parity onto Y\" where Y is in the diff\n- Migrating a singular→list or similar shape-consistency fix\n- Drift between docs and live state you can fix by reading both\n\n**When to ask the user about bundling.** ~200 lines is a *should-I-ask* heuristic, not a bundling cap. Under ~200 lines: bundle without asking. Over ~200 lines: ask the user whether to bundle — but **bundling at any size is fine if the work is not grossly out of scope**. The 200-line mark exists so the user hears about large bundled changes before they land, not to push large work out of the PR. Estimate honestly; do not inflate to manufacture a reason to defer.\n\n**Anti-noise gate — before filing any follow-up issue or PR, all three must be true:**\n\n1. The work is genuinely too large to bundle (i.e. truly out of scope, not just over the ~200-line ask-heuristic above). **All three sub-tests must pass:**\n   (a) It cannot be done by mirroring an existing sibling pattern in the same file or a closely-related file.\n   (b) You can name the actual design choice in one sentence with two named alternatives, **OR** the work is a genuinely large mechanical migration (e.g. *\"replace `requests` with `httpx` across 40 sites\"*) that exceeds this PR's scope by size alone.\n   (c) It would meaningfully change this PR's review surface, not just add to it.\n2. You can name a concrete end-user-facing or maintainer benefit in one sentence.\n3. A maintainer reading the issue 6 months later would act on it, not close as stale.\n\nIf any are false: fix it now, or let it go. **Do not file an issue to \"track\" it.**\n\n**Scope is the user's call, not yours.** Before deferring anything, explicitly ask with a specific reason: *\"I think this is out of scope because [X]. Fix here or defer?\"* — do not silently drop it.\n\nThe following phrases are red flags that you're making a scope decision unilaterally (list is non-exhaustive — match on intent, not exact string): \"post-merge follow-up\", \"follow-up consideration\", \"forward-looking note\", \"nice to have\", \"Happy to file an issue\", \"out of scope for this PR\", \"not blocking this PR\", \"pre-existing — not touching it\" (pre-existing is not a reason to skip; addressing pre-existing things is the point of this rule), \"real design work, not N lines\", \"worth tracking as a follow-up issue\".\n\n**Code-review bot suggestions** (Codex, CodeRabbit, Copilot non-blocking nits): apply inline or dismiss. Never spawn a follow-up issue from a bot suggestion unless the user explicitly confirms it's a large, out-of-scope change. See `.gemini/styleguide.md` § *Non-Blocking Suggestions and Scope* for the bot-side rule.\n\n### Urgent Release Process\n\nCritical fixes follow the normal development flow: branch from `master`, merge\nthe fix to `master`, then manually dispatch `semver-release.yml` from `master`.\nUse its `force` input only when a release is required without a releasable\n`feat`, `fix`, `perf`, `refactor`, breaking `!`, or `BREAKING CHANGE` commit\nsince the previous stable tag.\n\n### Test Coverage Requirements\n\n**When tests ARE required:**\n- New MCP tools in `src/ha_mcp/tools/` without any E2E tests\n- Tools that previously had NO tests — add E2E tests even if not part of current PR\n- Core functionality changes in `client/`, `server.py`, or `errors.py` without coverage\n- Bug fixes — use TDD: write the failing regression test first, then fix the code so the test passes\n\n**When tests may NOT be required:**\n- Refactoring with existing comprehensive test coverage\n- Documentation-only changes (`*.md` files)\n- Minor parameter additions to well-tested tools\n- Internal utilities already covered by E2E tests\n\n**When to open an issue instead:** See § *Boy Scout Rule — Handling Discovered Improvements* for the gate. Never open without explicit user approval.\n\n## CI/CD Workflows\n\n| Workflow | Trigger | Purpose |\n|----------|---------|---------|\n| `pr.yml` | PR opened | Lint, type check |\n| `e2e-tests.yml` | PR to master | Full E2E tests (~3 min) |\n| `publish-dev.yml` | Push to master | Dev release `.devN` |\n| `notify-dev-channel.yml` | Push to master (src/) | Comment on PRs/issues with dev testing instructions |\n| `semver-release.yml` | Biweekly Wed 10:00 UTC or manual dispatch | Stable release (cuts version tag + GitHub release) |\n| `release-publish.yml` | After SemVer Release (`workflow_run`) or manual dispatch | Publish stable Docker image (`:latest` + `:stable` + semver) + MCP registry |\n| `build-binary.yml` | Release | Linux/macOS/Windows binaries |\n| `addon-publish.yml` | Release | HA add-on update |\n| `sync-tool-docs.yml` | Push to master (`src/ha_mcp/tools/`, `scripts/extract_tools.py`) | Regenerate `tools.json`, README, DOCS.md |\n| `locale-sync.yml` | Daily schedule + manual dispatch | Machine-translate stale/missing strings post-merge and push them straight to master |\n\n**Docker image tags** (`ghcr.io/homeassistant-ai/ha-mcp`): stable releases push `:latest` + `:stable` + semver tags (`release-publish.yml`); dev builds push only `:dev` + `:dev-<sha>` (`publish-dev.yml`) — **never `:latest`**, which is reserved for stable. The HA add-on images live in separate repos (`-addon-{arch}`, `-addon-dev-{arch}`) and are selected by an explicit `version:` pin, not by `:latest`.\n\n## Development Commands\n\n### Setup\n```bash\nuv sync --group dev        # Install with dev dependencies\nuv run ha-mcp              # Run MCP server (stdio; needs interactive stdin)\nuv run ha-mcp-web          # Run HTTP server; web settings UI at http://localhost:8086/mcp/settings (see src/ha_mcp/settings_ui/AGENTS.md)\ncp .env.example .env       # Configure HA connection\n```\n\n### Testing\nE2E tests are in `tests/src/e2e/` (not `tests/e2e/`). Tests use **testcontainers** to spin up\nan isolated Docker HA instance — Docker daemon must be running.\n\n```bash\n# Run FULL E2E suite (required before claiming all tests pass)\n# -n2 is optimal locally (each worker spins up its own HA container;\n# more workers add memory pressure without proportional speedup).\n# CI uses -n3 tuned for 2-vCPU GitHub runners with 15GB RAM.\ncd tests && uv run pytest src/e2e/ -n2 --dist loadscope -v --tb=short\n\n# Run specific file (partial coverage only — never substitute for full suite)\ncd tests && uv run pytest src/e2e/workflows/automation/test_lifecycle.py -v\n\n# Interactive test environment\nuv run hamcp-test-env                    # Interactive mode\nuv run hamcp-test-env --no-interactive   # For automation\n```\n\n**CRITICAL RULES:**\n- Always run from the `tests/` directory so pytest picks up the correct `conftest.py`\n- Always run the **full suite** before declaring tests pass\n- `tests/.env.test` contains placeholder values only; testcontainers sets the real URL dynamically\n- Never set `HOMEASSISTANT_URL` manually in your shell before running tests\n- **Always run relevant e2e tests after making changes**, without waiting to be asked. Identify the relevant test file(s) for the area you changed and run them. Do not assume Docker is unavailable or prerequisites are missing — just run them and let pytest report what is skipped and why.\n\nTest token centralized in `tests/test_constants.py`.\n\n### Code Quality\n\nC901 (mccabe complexity ≤10) is enforced repo-wide with zero per-file exemptions (issue #925 cleared the grandfathered list) — never reintroduce a `[\"C901\"]` per-file-ignore; extract helpers instead.\n\n```bash\nuv run ruff check src/ tests/ --fix\n# Note: --fix removes unused imports from non-__init__ modules (lefthook runs it on commit with\n# stage_fixed). When adding an import, include its first use in the same change or it gets stripped.\nuv run mypy src/\n```\n\n### Docker\n```bash\n# Stdio mode (Claude Desktop) — local-only, no network exposure\ndocker run --rm -i \\\n  -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\\n  ghcr.io/homeassistant-ai/ha-mcp:latest\n\n# HTTP mode (loopback only, same-host LLM client)\n# Connect URL: http://127.0.0.1:8086/mcp  (default MCP_SECRET_PATH)\ndocker run -d -p 127.0.0.1:8086:8086 \\\n  -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\\n  ghcr.io/homeassistant-ai/ha-mcp:latest ha-mcp-web\n\n# HTTP mode (LAN-reachable) — generate the secret first so you can configure the MCP client with it\nMCP_SECRET=\"/private_$(python3 -c 'import secrets; print(secrets.token_urlsafe(16))')\"\necho \"MCP_SECRET_PATH=$MCP_SECRET\"\ndocker run -d -p 8086:8086 \\\n  -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\\n  -e MCP_SECRET_PATH=\"$MCP_SECRET\" \\\n  ghcr.io/homeassistant-ai/ha-mcp:latest ha-mcp-web\n```\n\nSee [SECURITY.md](SECURITY.md) for authentication and network binding details.\n\n## Architecture\n\n```\nsrc/ha_mcp/\n├── server.py          # Main server with FastMCP\n├── __main__.py        # Entrypoint (CLI handlers)\n├── config.py          # Pydantic settings management\n├── errors.py          # 38 structured error codes\n├── client/\n│   ├── rest_client.py       # HTTP REST API client\n│   ├── websocket_client.py  # Real-time state monitoring\n│   └── websocket_listener.py\n├── auth/\n│   ├── provider.py          # OAuth provider (HTTP mode)\n│   └── consent_form.py      # OAuth consent screen\n├── tools/             # 36 modules, auto-discovered\n│   ├── registry.py          # Lazy auto-discovery\n│   ├── smart_search/        # Fuzzy entity search\n│   ├── device_control.py    # WebSocket-verified control\n│   ├── best_practice_checker.py # Reactive HA config validator (warns + embeds skill content)\n│   ├── tools_*.py           # Domain-specific tools\n│   └── util_helpers.py      # Shared utilities\n├── utils/\n│   ├── fuzzy_search.py      # textdistance-based matching\n│   ├── domain_handlers.py   # HA domain logic\n│   ├── operation_manager.py # Async operation tracking\n│   ├── skill_loader.py      # Skills-vendor file loader (used by ha_get_skill_guide and write tools)\n│   ├── usage_logger.py      # Per-tool usage telemetry\n│   ├── data_paths.py        # Canonical data directory paths\n│   ├── python_sandbox.py    # Sandboxed Python-expression eval for python_transform on config tools\n│   ├── kill_signal_diagnostics.py # Kill-signal (SIGTERM/SIGINT/SIGHUP) shutdown diagnostics\n│   └── config_hash.py       # Shared optimistic-locking hash (automation/script/scene/dashboard/energy)\n└── resources/\n    ├── card_types.json\n    └── dashboard_guide.md\n```\n\n### Key Patterns\n\n**Tools Registry**: Auto-discovers `tools_*.py` modules with `register_*_tools()` functions. No changes needed when adding new modules.\n\n**Lazy Initialization**: Server, client, and tools created on-demand for fast startup.\n\n**Service Layer**: Business logic in `smart_search/`, `device_control.py` separate from tool modules.\n\n**WebSocket Verification**: Device operations verified via real-time state changes.\n\n**Tool Completion Semantics**: Tools should wait for operations to complete before returning, with optional `wait` parameter for control.\n\n## Writing MCP Tools\n\n### Naming Convention\n`ha_<verb>_<noun>`:\n- `get` — single item (`ha_get_state`)\n- `list` — collections (`ha_list_services`)\n- `search` — filtered queries (`ha_search`)\n- `set` — create/update (`ha_config_set_helper`)\n- `delete` — delete dashboards, config entries, or files (`ha_config_delete_dashboard`, `ha_delete_file`)\n- `remove` — remove registry items (`ha_remove_entity`, `ha_remove_area_or_floor`)\n- `call` — execute (`ha_call_service`, `ha_call_event`)\n- `manage` — multi-modal tools combining several operations behind one interface (`ha_manage_addon`)\n\n**Namespace prefixes**: An optional `<namespace>_` prefix between `ha_` and the verb is allowed for grouped tool families that share a domain. The full shape becomes `ha_<namespace>_<verb>_<noun>`:\n- `ha_config_<verb>_<noun>` — config-management tools (`ha_config_set_helper`, `ha_config_set_automation`, `ha_config_remove_automation`, `ha_config_delete_dashboard`)\n- `ha_dev_<verb>_<noun>` — developer-mode tools (`ha_dev_manage_server`, `ha_dev_manage_settings`); registered only when the `enable_dev_mode` setting is on (Developer section at the bottom of the web settings UI's Server Settings tab)\n\n**Accepted exceptions**: A small set of tools name a single, distinct operation where forcing a `<verb>_<noun>` shape would read worse than the natural name. These are accepted as-is and should not be flagged:\n- `ha_restart`, `ha_reload_core`, `ha_eval_template`\n- `ha_report_issue`, `ha_import_blueprint`\n- `ha_read_file`, `ha_write_file`, `ha_bulk_control`\n\n**Adding new verbs**: When no existing verb fits a new tool's purpose, add the verb to the approved-verbs list above rather than forcing a poor fit. `.gemini/styleguide.md` points back to this section as the single source of truth, so updates here propagate automatically.\n\n### Tool Structure\nCreate `tools_<domain>.py` in `src/ha_mcp/tools/`. Registry auto-discovers it.\n\n```python\nfrom fastmcp.tools import tool\nfrom .helpers import log_tool_usage, register_tool_methods\n\nclass DomainTools:\n    def __init__(self, client):\n        self._client = client\n\n    @tool(name=\"ha_<verb>_<noun>\", tags={\"Category Name\"}, annotations={\"readOnlyHint\": True, \"idempotentHint\": True})\n    @log_tool_usage\n    async def ha_<verb>_<noun>(self, param: str) -> dict[str, Any]:\n        \"\"\"<Action verb> <what this tool does -- one sentence>.\n\n        <Optional: second sentence for key behavioral distinction or modes>\n        \"\"\"\n        # Add to the docstring above only when genuinely needed:\n        # RELATED TOOLS: ha_next(): why to call this after (workflow-entry tools only)\n        # EXAMPLES: ha_<verb>_<noun>(\"realistic_value\")  -- non-obvious call patterns only\n        # When NOT to use: route to preferred alternatives\n        # Caveats: destructive side-effects, non-obvious gotchas\n        # For complex schemas: use ha_get_skill_guide\n\ndef register_<domain>_tools(mcp, client, **kwargs):\n    register_tool_methods(mcp, DomainTools(client))\n```\n\n`@tool` (from `fastmcp.tools`) attaches metadata to the method. `@tool` must be the outermost decorator (above `@log_tool_usage`) so that `__fastmcp__` is present on the final method object. `register_tool_methods()` auto-discovers all `@tool`-decorated methods and calls `mcp.add_tool()` for each. The registry discovers `register_*_tools` functions by convention.\n\n### Tool Docstrings\n\nThe single-line template is the default -- extend it only where it genuinely helps.\n\n**Required for every tool:**\n- Starts with an action verb (`Get`, `List`, `Search`, `Create`, `Update`, `Delete`, `Remove`, `Execute`, `Call`, `Manage`)\n- One sentence describing what the tool does (not how)\n\n**Add `RELATED TOOLS` when** the tool is a workflow entry point and the natural next step is not obvious.\nExample: `ha_search` hints at `ha_get_state`.\n\n**Add `EXAMPLES` when** the tool has multiple modes or non-obvious parameters.\nOmit when a single required parameter makes the call self-evident.\n\n**For multi-line docstrings, follow this structure** (based on\n[Anthropic's tool design guidance](https://www.anthropic.com/engineering/writing-tools-for-agents)):\n1. What the tool does (required first sentence, action verb)\n2. When NOT to use it — name the preferred alternatives\n3. When to use it — valid use cases\n4. Caveats — consequences, post-actions, destructive side-effects\n\nConsequence statements are plain prose: \"This permanently deletes the dashboard.\nA backup is created before every edit.\" Route safety concerns through `annotations`\n(`destructiveHint`, `idempotentHint`, `readOnlyHint`), not docstring keywords.\n\n**Defer complex schemas** instead of embedding them:\n`# For complex schemas: use ha_get_skill_guide`\n\n**What NOT to include:** full parameter documentation, type descriptions already in the\nsignature, HA domain internals the model already knows, or motivational prose.\n\n\n### Tool Tags\n\nEvery tool needs `tags={\"Category Name\"}` (native FastMCP parameter). Drives the README table, `site/src/data/tools.json`, and `homeassistant-addon/DOCS.md`. These are auto-regenerated on merge by `sync-tool-docs.yml` — no manual regeneration needed. For local testing: `python scripts/extract_tools.py`\n\n### Safety Annotations\n| Annotation | Default | Use For |\n|------------|---------|--------|\n| `readOnlyHint: True` | `False` | Tool does not modify its environment |\n| `destructiveHint: True` | `True` | Tool may perform destructive updates (only meaningful when `readOnlyHint` is false). Set to `False` for non-destructive writes (e.g., creating a record) |\n| `idempotentHint: True` | `False` | Repeated calls with same args have no additional effect (only meaningful when `readOnlyHint` is false) |\n| `openWorldHint: True` | `True` | Tool reaches an external, third-party-authored world (HACS store, add-on repositories, GitHub release feeds, arbitrary import URLs). Set to `False` when the tool's domain is the local Home Assistant instance. A tool is also open-world if its output carries externally-authored content back to the client, even when a local integration (HACS, Supervisor, HA Core) makes the actual network call on its behalf — `ha_get_overview` and `ha_get_system_health` embed the update-check field that reaches PyPI / the Supervisor store, while `ha_get_blueprint` and `ha_config_list_dashboard_resources` return externally-authored content from purely local reads. Required on every tool — the default is `true`, so an omitted value silently marks a local tool as open-world |\n\n**Version baseline:** annotations describe a tool's behavior against current\nupstream versions of any external engine or component it drives; a side effect\nthat exists only in outdated external builds does not demote the tool to\nwrite-classified — document the update requirement in the tool's docs instead\n(e.g. the screenshot engine's old `settheme` write, #1991).\n\n### Error Handling\n\n**Always use the dedicated error functions** from `errors.py` and `helpers.py`. Never construct raw error dicts manually — the helpers ensure consistent structure, error codes, and suggestions across all tools.\n\n**All tool-level failures must raise `ToolError`** (sets `isError=true` per MCP spec). Batch item failures within result arrays are the only exception — those return structured dicts without raising.\n\n**Pattern A — Exception blocks** (most common): call `exception_to_structured_error` without `return` — it raises `ToolError` by default:\n```python\nfrom .helpers import exception_to_structured_error, raise_tool_error\nfrom fastmcp.exceptions import ToolError\n\ntry:\n    # ... tool logic ...\nexcept ToolError:\n    raise  # must re-raise; prevents ToolError being swallowed by outer except\nexcept Exception as e:\n    exception_to_structured_error(\n        e,\n        context={\"entity_id\": entity_id},\n        suggestions=[\"Verify entity exists\", \"Check HA connection\"],\n    )\n```\n\nThe `except ToolError: raise` guard is required whenever `raise_tool_error()` or validation errors are called inside the same `try` block — without it, `except Exception` catches the `ToolError` and re-maps it to `INTERNAL_ERROR`.\n\n**Pattern B — Input validation errors**: use `raise_tool_error(create_error_response(ErrorCode.VALIDATION_INVALID_PARAMETER, message, context={...}, suggestions=[...]))`.\n\n**Pattern C — Service call failures**: check `result.get(\"success\")` and raise with `ErrorCode.SERVICE_CALL_FAILED` using `result.get(\"error\", \"Operation failed\")` as the message.\n\n**Pattern D — Batch item failures** (items inside a results list — do NOT raise):\n```python\nresults.append(create_error_response(\n    ErrorCode.SERVICE_CALL_FAILED,\n    str(e),\n    context={\"entity_id\": eid},\n))\n```\n\nOnly use `raise_error=False` on `exception_to_structured_error` when you need to mutate the dict before raising. Never add `add_timezone_metadata` to errors.\n\n`exception_to_structured_error` auto-classifies 404s, auth errors, timeouts by exception type. Pass `context={\"entity_id\": ...}` for automatic `ENTITY_NOT_FOUND` on 404s. Available helpers: `create_entity_not_found_error`, `create_connection_error`, `create_auth_error`, `create_service_error`, `create_validation_error`, `create_config_error`, `create_timeout_error`, `create_error_response`.\n\n### Return Values\n```python\n{\"success\": True, \"data\": result}                     # Success\n{\"success\": True, \"data\": result, \"warnings\": [...]}  # Degraded (top-level list[str], omit when empty)\nraise ToolError(json.dumps({...}))                    # Tool-level failure (isError=true)\n{\"success\": False, \"error\": {...}}                    # Batch item failure only (in results list)\n```\n\n`warnings` is always a top-level `list[str]`, never nested inside `data` and never a singular `\"warning\": \"...\"` string. See `tools_config_helpers.py::HelperResponse` / `_helper_response` for the canonical shape and `tests/src/unit/test_helper_response_shape.py` for the contract assertions.\n\n### Tool Consolidation\nWhen a tool's functionality is fully covered by another tool, **remove** the redundant tool rather than deprecating it. Fewer tools reduces cognitive load for AI agents and improves decision-making. Do not add deprecation notices or shims — just delete the tool and update any docstring references to point to the replacement.\n\nThis project's tool count exceeds the [10-20 tool threshold](https://ai.google.dev/gemini-api/docs/function-calling) where selection accuracy degrades. Reducing count is a priority — combine frequently chained operations into one tool and ensure each tool has a clear, distinct purpose. See [Anthropic's tool design blog](https://www.anthropic.com/engineering/writing-tools-for-agents) for guidance.\n\n| Pattern | Example | Guideline |\n|---------|---------|-----------|\n| Tool A is a strict subset of Tool B | `ha_dashboard_find_card` fully covered by `ha_config_get_dashboard` | Consolidate (remove A) |\n| Frequently chained operations | Multi-step workflows combined into one tool | Consolidate — reduces round-trips |\n\n**Breaking changes**: only removing functionality with no alternative requires a major bump. Consolidation and renaming are not breaking.\n\n**Context engineering**: provide minimum context; let models fetch more via `ha_get_skill_guide`. Favor statelessness and content-derived hashes for optimistic locking.\n\n### Module Size\n\nKeep modules focused. Past ~1000 lines (Pylint's `max-module-lines` default) a module usually spans multiple concerns and is worth splitting along those concerns. Pick whatever decomposition fits and fix the internal imports (and any test patch targets) that reference the moved code as part of the move. There's no external import contract to preserve: it's one project, and MCP tools are resolved dynamically at runtime by name (and renaming isn't breaking either, see Tool Consolidation).\n\n## Tool Waiting Behavior\n\n**Principle**: MCP tools should wait for operations to complete before returning, not just acknowledge API success.\n\nTools have an optional `wait` parameter (default `True`) that polls for completion. Use `wait=False` for bulk operations, then batch-verify. Categories:\n- **Config ops** (automations, helpers, scripts): Wait by default (poll until entity queryable/removed)\n- **Service calls** (lights, switches): Wait for state change on state-changing services (turn_on, turn_off, toggle, etc.)\n- **Async ops** (automation triggers, external integrations): Return immediately (not state-changing)\n- **Query ops** (get_state, search): Return immediately (no `wait` parameter)\n\n**Shared utilities** in `src/ha_mcp/tools/util_helpers.py`:\n- `wait_for_entity_registered(client, entity_id)` — polls until entity accessible via state API\n- `wait_for_entity_removed(client, entity_id)` — polls until entity no longer accessible\n- `wait_for_state_change(client, entity_id, expected_state)` — polls until state changes\n\n## Custom Component\n\nThe `custom_components/ha_mcp_tools/` integration ships separately from the\n`ha-mcp` server package (it reaches the HA instance via HACS), so CI cannot\nfully validate a component change before merge.\n\n- **Version bumps ride the stable release cycle — do not bump per PR or per\n  push.** The component version (`manifest.json` `version` + `COMPONENT_VERSION`\n  in `const.py`, kept in lockstep by the parity test) should lead the last\n  **stable** release by exactly one pending version, so everything merged since\n  the last stable cut ships together under one number on the next stable\n  release. Check the pending state with `git show\n  stable:custom_components/ha_mcp_tools/const.py | grep COMPONENT_VERSION` vs\n  master, then:\n  - **Level with stable** (no pending version yet): bump once — patch by\n    default — to open the pending version.\n  - **Already ahead of stable** (a pending version exists): do **not** bump;\n    your change rides under the existing pending version.\n  - Raise the pending version further only to **escalate the bump level** — e.g.\n    the pending version is a patch but your change warrants a minor — and then\n    go straight to that minor, not an extra patch. Never go past the current\n    pending version otherwise; per-revision bumps skip never-shipped numbers and\n    desync the version from the release cycle.\n  - CI enforces the level-with-stable case twice: the PR-level **Component\n    Version Gate** fails a component change whose manifest version does not\n    strictly lead the mirror's released stable (equal = bump to open the\n    pending version; behind = a stale tree or bad merge resurrected an old\n    version), and the mirror sync's stable tag step fails loud\n    when an already-tagged version's component content has drifted (changes\n    merged onto a shipped version would otherwise strand with no installable\n    release — the gap is a PR opened while a version is pending that merges\n    only after that version goes stable, which re-runs no PR checks).\n- **When the change adds a service or argument the server depends on**, this PR\n  must **open a fresh pending component version** (bump `manifest.json` +\n  `COMPONENT_VERSION`) and raise `MIN_COMPONENT_VERSION` in\n  `src/ha_mcp/tools/tools_filesystem.py` to that same new version. This is the\n  one case that **overrides** the \"already ahead of stable → do not bump\" rule\n  above: bump here even if a pending version already exists. `get_caller_token`\n  reports the manifest version and the server gates on it, so without the gate\n  the old and new component are indistinguishable: a caller on the old version\n  passes the check and then hits raw \"service not found\" errors instead of an\n  actionable \"update\" prompt. **Never floor at a version any build lacking the\n  behaviour also reports** – an already-shipped version, or a pending version\n  that was opened *before* this behaviour landed. Such a build passes the gate\n  yet lacks the behaviour, defeating the gate (#1946: the floor was set to a\n  1.1.0 that had already shipped without the gated behaviours, so 1.1.0 builds\n  split into with/without and the gate could not tell them apart).\n- **Keep the component backward-compatible with the released server.** The\n  component (HACS) and the server (add-on / PyPI / Docker) follow the same\n  release cycle but are updated independently per install, so a new component\n  can run against an *older* server. Never remove or tighten an existing service\n  schema (e.g. dropping a param from a strict `vol.Schema`) without a shim the\n  prior server still satisfies; the version gate can't protect this direction\n  (the old server is the caller). Remove the shim once the matching\n  `MIN_COMPONENT_VERSION` server is the floor.\n- **Live-test on the dev server immediately after merge**, before the next\n  stable cut. The component path cannot be fully exercised by CI pre-merge.\n\n## Translations\n\n**One canonical store, generated projections, automated retranslation**\n(issue #2083). The settings UI catalogs\n(`src/ha_mcp/settings_ui/locales/<code>.json`) are the canonical store for\nevery string except the component's config flow: the add-on option strings\nlive there under `addon.<key>.*` (plus `features.<key>.*` for options the\nsettings UI also shows, and `addon_stable.<key>.*` for a stable-flavor\nwording deviation). Both add-on flavors' `translations/*.yaml` and the\n`FEATURE_META` block in `settings.js` are **generated** from that store by\n`scripts/generate_locales.py` — never edit them by hand;\n`test_derived_catalogs_match_the_canonical_store` fails until you regenerate.\nEach flavor's key list is its own `config.yaml` `schema:`, so the two YAMLs\nare different projections of the one store, and cross-surface wording\nidentity holds by construction.\n\nA language ships on all four surfaces or not at all —\n`tests/src/unit/test_locale_parity.py` enforces it. The same Home Assistant\nlanguage code (`cs`, `de`, `eo`, `es`, `fr`, `it`, `nl`, `pl`, `ru`, `sv`, `zh-Hans`) names every file:\n`src/ha_mcp/settings_ui/locales/<code>.json`,\n`custom_components/ha_mcp_tools/translations/<code>.json`, and\n`homeassistant-addon{,-dev}/translations/<code>.yaml`.\nThat list of codes is itself pinned by\n`test_agents_md_lists_every_shipped_locale`: adding a language means adding its\ncode here, in the same PR, or the suite goes red. To add a language, add the\ntwo authored catalogs (settings UI + component), regenerate, and let the\ntranslation pipeline below fill the strings. The component catalog may start\nempty; the settings one may not start `meta`-only, because four ungated checks\nread the shipped catalogs themselves: every decided `Decision` outcome and\nevery `PredicateOp` operator needs a translated word\n(`policies.pending.decision.*`, `policies.operators.*` — a value that still\nspells the backend literal counts as untranslated), so does\n`policies.pending.already_decided`, the sentence those words are interpolated\ninto, and at least one translated key must have English that addresses the\nreader in the second person, which is where `scripts/translate_locales.py`\nreads the catalog's address register.\n`policies.operators.exists_long` is the trap in that list: the condition editor\nrenders it as its own dropdown label, but it is UI-only rather than a\n`PredicateOp` member, so no enum-derived check asks for it and a catalog\nwithout it reads English there until the sync fills it. Each surface reads that\nregister from its own catalog, so a component catalog left at a key or two\nrests on whichever of them addresses the reader — losing it costs the engine\nthe register for every later string of that language and says so only on\nstderr, which is why\n`test_every_shipped_component_catalog_gets_reader_addressing_samples` pins it.\nAuthor two such keys rather than one: a run that rewords one of them queues it,\nand queued keys are dropped from the sample candidates, so a surface resting on\na single anchor is anchorless in precisely the run that rewrites it —\n`test_component_samples_survive_their_own_anchor_being_queued` pins that.\n`src/ha_mcp/settings_ui/locales/README.md` names the tests — including the one\nthat skips locally until `tests/js/` has its npm dependencies.\n\nSettings UI catalogs are auto-discovered (no registration). Their `messages` may\nomit keys — English is the per-key fallback — but may not carry one `en.json`\nlacks: nothing renders it. `tool_groups` and `tools` may do neither: each locale\nmust carry exactly the renderable group headings and every tool name, no key\nmore and none fewer. The check derives the tool set from\nthe sources (`scripts/extract_tools.py`), not from the committed\n`site/src/data/tools.json` — the check must not depend on a generated\nartifact that a separate post-merge workflow keeps current. Separately from\nthose key rules, both authored surfaces cap how much *text* a catalog\nmay leave byte-identical to English or omit outright, so a stub cannot ride the\nfallbacks: 5% for the settings UI `messages`, its `tools` titles and\ndescriptions, and each generated add-on projection (per flavor, computed from\nthe canonical store), and 15% for the component catalogs,\nwhich carry the product names as keys of their own. On top of that share, a\n`tools` entry whose title *and* description are *both* byte-identical to English\nfails by name however small its share — for feature-gated tools against either\nEnglish rendering, the `FEATURE_GATED_TOOLS` stub or the parsed docstring.\nComponent catalogs need every `strings.json` key with identical\n`{placeholders}`.\n\n**Changing an English string is a one-place edit** (`en.json` `messages`, a\ntool docstring, or `strings.json` + component `en.json`), and the machine\ntranslates the rest: `scripts/translate_locales.py` reads the English-source\nbaseline diff (`tests/src/unit/locale_source_baseline.json`), retranslates\nthe changed or missing keys in every language via the Gemini API\n(`GEMINI_API_KEY`; free tier), validates placeholders and markup, regenerates\nthe derived catalogs, and repins the baseline. The `locale-sync.yml` workflow\nruns it AFTER merge, on a daily schedule, and pushes the result straight to\nmaster with the release App credential (the same pattern as the version-bump\nbots and `sync-tool-docs.yml`) — so any PR, fork or same-repo, merges\nwithout owing translations, and one sync run picks up everything merged\nsince the last one. The checks that police translated content (missing or\norphaned keys, staleness against the baseline, cross-surface shared wording,\nthe untranslated-share ceilings, filled tool sections) are gated behind\n`LOCALE_COMPLETENESS_CHECKS=1` and run in that workflow, not in PR CI —\n`test_locale_sync_gate_shape.py` pins the wiring. What a PR still owes is\ndeterministic and engine-free: regenerate the derived catalogs\n(`python scripts/generate_locales.py`) when a canonical English string\nchanges, and placeholder parity on component keys whose English is current.\nTo choose the wording yourself, translate in your own PR **and run\n`python scripts/update_locale_baseline.py` in it** — the repinned baseline\nis what tells the next sync your wording already covers the changed English\n(hand-edits win); without the repin the sync retranslates the key and\noverwrites you. Run `scripts/translate_locales.py` locally instead to\nmachine-fill in-PR or to use a different engine (it repins for you).\nThe baseline pins the English each translation was written against, because\nkey parity cannot see a string whose meaning changed: #1993 flipped a policy\nstring from ALL-match to ANY-match and left the Chinese text asserting the\nopposite. `python scripts/update_locale_baseline.py` repins it manually after\na hand-translation pass.\n\n**A catalog that lands after a reword is never queued for the keys it missed.**\nThe pin moves when the English does, so a language whose PR was open across\nthat reword merges with the older wording already translated, and the sync sees\na hash that matches: no plan, no correction, indefinitely. Nothing else catches\nit either — key parity sees a value and the share ceiling sees a translated\none. When a locale PR spans an English change, diff the affected keys against\nthat surface's own English before merging — `en.json` for `messages`, the tool\ndefinitions for `tools` (`en.json` ships that section empty), and\n`custom_components/ha_mcp_tools/strings.json` for the component catalog.\nNumbers and code-ish literals are the cheap tell, since a reword usually moves\none, and `test_translations_keep_english_numbers_and_identifiers` checks\nexactly that across all three. Repinning is not the repair — it\nwrites the same hash and queues nothing. Deleting the stale value is: the\nplanner treats a missing key as work for that locale alone.\n\n**A tool docstring is one of those English strings.** `en.json` ships `tools`\nempty, so the English a `tools` entry translates is read from the tool\ndefinition in `src/ha_mcp/tools/` — the `title=` kwarg and the summary\nparagraph of the docstring, or the `FEATURE_GATED_TOOLS` stub where a gated\ntool shows one instead. Editing that summary moves the English out from under\nsix catalogs; the pipeline retranslates them. One deliberate exception: a\nchange to a feature-gated tool's PARSED docstring (its stub unchanged) is\nstub-review work, not translation work — the pipeline holds that baseline key\nstale, and the locale-sync run stays red until a human confirms the stub\nstill describes the tool and runs `python scripts/update_locale_baseline.py`.\n\n**Rate limits and outages degrade loudly, never silently.** Engine calls are\npaced under the free-tier request rate and retry transient errors (429/5xx,\ntimeouts) with backoff; a request that keeps failing marks its strings failed\nand the run continues, and two consecutive dead batches stop the run early\ninstead of burning the remaining quota. A partial run — a daily-quota hit,\nan outage — still commits every finished translation plus\n`tests/src/unit/locale_sync_progress.json`, which the next run reads to\nresume where it stopped: **re-running the workflow — or just waiting for the\nnext day's cron — is the entire recovery procedure.** Only a fully\nsuccessful run repins the baseline and deletes the progress file, so the\nsync runs stay red until every string is translated and nothing\nunvalidated ever ships. **The fallback when the engine is down is a human**:\nanyone can hand-translate the strings the dry-run\nlists, run `python scripts/generate_locales.py` and\n`python scripts/update_locale_baseline.py`, and open an ordinary PR — the\nnext sync run no-ops (it also cleans up any committed progress file).\nHand-edits always win; the machine only ever touches strings whose English\nchanged. The engine itself is one function (`_call_gemini`) with\n`GEMINI_API_URL` / `GEMINI_MODEL` / `GEMINI_API_KEY` overrides for any\nGemini-compatible endpoint, so replacing the provider stays a one-function\nchange.\n\nThe Webhook Proxy add-on and its bundled integration stay **English-only by\ndecision** — not worth the upkeep. The test records that, so any other new\ncatalog directory fails until it is either translated everywhere or listed as\nEnglish-only alongside them.\n\n## Home Assistant Add-on\n\n**Required files:**\n- `repository.yaml` (root) - For HA add-on store recognition\n- `homeassistant-addon/config.yaml` - Must match `pyproject.toml` version\n\n**Two add-on flavors:** `homeassistant-addon/` (stable, slug `ha_mcp`) and\n`homeassistant-addon-dev/` (dev channel, slug `ha_mcp_dev`) are *separate*\nadd-ons with *separate* `config.yaml` files.\n\n**Functional config is NOT auto-synced between them.** The release pipeline\nonly syncs the *version* (the `update-addon-config` job) and the *changelog*\n(the `Copy changelog to addon directory` step in `semver-release.yml`) into\n`homeassistant-addon/`. Functional keys — `ingress`, `ports`,\n`host_network`, `options`/`schema`, etc. — must be edited **by hand** in each\nflavor. When you add a non-beta capability to the dev add-on that should also\nship on stable (e.g. `ingress` for the web Settings UI / \"Open Web UI\" button),\nmirror it into `homeassistant-addon/config.yaml` **in the same PR**. Assuming\n\"the release pipeline handles it\" is what kept `ingress` off the stable add-on.\nBeta-only keys are the deliberate exception — see the NOTE in\n`homeassistant-addon/config.yaml` and `docs/beta.md`.\n\n### Webhook Proxy add-on: dev-first, promote-only\n\n**Any work on the Webhook Proxy add-on must start by reading\n[`homeassistant-addon-webhook-proxy/AGENTS.md`](homeassistant-addon-webhook-proxy/AGENTS.md)**\n— it owns the full flow (flavors, versioning guard, promotion, testing).\nThe short version: `homeassistant-addon-webhook-proxy/` (stable) is never\nedited directly by a PR in regular operation; every change (code *and* docs)\nlands on `homeassistant-addon-webhook-proxy-dev/` with a version bump, and\nstable is updated only via the manual promote workflow.\n\n**Docs**: https://developers.home-assistant.io/docs/add-ons\n\n## API Research\n\nSearch HA Core without cloning (500MB+ repo):\n```bash\n# Search for patterns\ngh search code \"use_blueprint\" --repo home-assistant/core path:tests --json path --limit 10\n\n# Fetch file contents (base64 encoded)\ngh api /repos/home-assistant/core/contents/homeassistant/components/automation/config.py \\\n  --jq '.content' | base64 -d > /tmp/ha_config.py\n```\n\n## Release Process\n\nUses [semantic-release](https://python-semantic-release.readthedocs.io/) with conventional commits.\n\n| Prefix | Bump | Changelog |\n|--------|------|-----------|\n| `fix:`, `perf:`, `refactor:` | Patch | User-facing |\n| `feat:` | Minor | User-facing |\n| `feat!:` or `BREAKING CHANGE:` | Major | User-facing |\n| `chore:`, `ci:`, `test:` | No release | Internal |\n| `docs:` | No release | User-facing |\n| `*:(internal)` | Same as type | Internal |\n\n**Use `(internal)` scope** for changes that aren't user-facing:\n```bash\nfeat(internal): Log package version on startup  # Internal, not in user changelog\nfeat: Add dark mode                             # User-facing\n```\n\n| Channel | When Updated |\n|---------|--------------|\n| Dev (`.devN`) | Every master commit |\n| Stable | Biweekly (Wednesday 10:00 UTC) |\n\nManual release: Actions > SemVer Release > Run workflow.\n"},"files":{"AGENTS.md":"# CLAUDE.md\n\nGuidance for Claude Code when working with this repository.\n\n## Repository Structure\n\nThis repository uses a worktree-based development workflow.\n\n**Documentation Setup:**\n- This file is `AGENTS.md` (the canonical source)\n- `CLAUDE.md` is a symlink pointing to `AGENTS.md`\n- Read either file - they're the same content\n- Commit changes to `AGENTS.md`, the symlink will automatically reflect them\n\n**Directory Structure:**\n```\n<repo-root>/                           # Main repository (checkout master here)\n├── AGENTS.md                          # This file (canonical source)\n├── CLAUDE.md -> AGENTS.md             # Symlink for convenience\n├── worktree/                          # Git worktrees (gitignored)\n│   ├── issue-42/                      # Feature branch worktree\n│   └── fix-something/                 # Fix branch worktree\n├── local/                             # Scratch work (gitignored)\n└── .claude/skills/                    # Slash-command skills\n```\n\n**Quick command:** Use `/wt <branch-name>` skill to create worktree automatically.\n\n## Worktree Workflow\n\n### Creating Worktrees\n\n**ALWAYS create worktrees in the `worktree/` subdirectory**, not at the repository root.\n\n```bash\ngit worktree add worktree/issue-42 -b issue-42\ngit worktree add worktree/feat-new-feature -b feat/new-feature\n```\n\n**Cleanup:** `git worktree remove worktree/<name>` or `git worktree prune` for stale references.\n\n### Skills\n\nAll workflow automation is implemented as skills in `.claude/skills/` and invoked with `/skill-name <args>`:\n\n| Skill | Command | Purpose |\n|-------|---------|---------|\n| **issue-analysis** | `/issue-analysis <number>` | Deep issue analysis — codebase exploration, implementation planning, architectural assessment. Posts structured comment and applies labels. |\n| **issue-to-pr-resolver** | `/issue-to-pr-resolver <number>` | End-to-end issue implementation: worktree creation → implementation with tests → draft PR → iterative CI/review resolution until merge-ready. |\n| **my-pr-checker** | `/my-pr-checker <number>` | Review and manage YOUR OWN PRs — check CI, resolve review threads, fix issues, iterate until all checks pass. |\n| **contrib-pr-review** | `/contrib-pr-review <number>` | Review external contributor PRs for safety, quality, and readiness. |\n| **contributors-update** | `/contributors-update` | Find merged PR authors missing from README and update the contributors list after approval. |\n| **wt** | `/wt <branch-name>` | Create git worktree in `worktree/` subdirectory with up-to-date master. |\n| **bat-adhoc** | `/bat-adhoc [scenario]` | Ad-hoc bot acceptance testing with dynamically generated scenarios. |\n| **bat-story-eval** | `/bat-story-eval --baseline v6.6.1` | Diff-based story evaluation: two-version comparison, regression detection. |\n\n## Project Overview\n\n**Home Assistant MCP Server** - A production MCP server enabling AI assistants to control Home Assistant smart homes. Provides tools for entity control, automations, device management, and more.\n\n- **Repo**: `homeassistant-ai/ha-mcp`\n- **Package**: `ha-mcp` on PyPI\n- **Python**: 3.13 only\n\n## Security\n\nSee [SECURITY.md](SECURITY.md) for the threat model, scope, and reporting\ninstructions. The threat model section documents the key design decisions that\ndefine what ha-mcp does and doesn't defend against (trusted MCP clients, local\nnetwork boundary, OAuth Bearer token design, single-tenant standard mode, HA\npermission scope).\n\n**Security advisories:** The API exposes an advisory's body but not its\ndiscussion thread, where the maintainer disposition (dismiss-vs-fix and the\nagreed fix scope) lives. Confirm the scope from that thread (GitHub UI or ask a\nmaintainer) before writing the fix.\n\n## External Documentation\n\nWhen implementing features or debugging, consult these resources:\n\n| Resource | URL | Use For |\n|----------|-----|---------|\n| **Home Assistant REST API** | https://developers.home-assistant.io/docs/api/rest | Entity states, services, config |\n| **Home Assistant WebSocket API** | https://developers.home-assistant.io/docs/api/websocket | Real-time events, subscriptions |\n| **HA Core Source** | `gh api /search/code -f q=\"... repo:home-assistant/core\"` | Undocumented APIs (don't clone) |\n| **HA Add-on Development** | https://developers.home-assistant.io/docs/add-ons | Add-on packaging, config.yaml |\n| **FastMCP Documentation** | https://gofastmcp.com/getting-started/welcome | MCP server framework |\n| **MCP Specification** | https://modelcontextprotocol.io/docs | Protocol details |\n\n## Issue & PR Management\n\n### Automated Code Review\n\n**Codex** reviews PRs automatically (`pr-codex-review-request.yml` /\n`pr-codex-review-delivery.yml`; posts as `chatgpt-codex-connector[bot]`).\nGemini Code Assist is retired — Google sunset its GitHub review activities and\nthe app now only posts sunset-notice banners, so `.gemini/config.yaml`\ndisables it fully. `.gemini/styleguide.md` remains the repo's review-criteria\ndocument (code quality, test coverage, security patterns, MCP conventions,\nsafety annotation accuracy): the `@codex review` request comment points Codex\nat it explicitly, and the Claude review skills below apply it.\n\n**CodeRabbit** (GitHub app, posts as `coderabbitai[bot]`) reviews drafts too —\n`.coderabbit.yaml` sets `reviews.auto_review.drafts: true`, since every PR here\nopens as a draft, and `auto_pause_after_reviewed_commits: 0` so it keeps\nreviewing every push instead of going quiet after five. That spends the\nper-developer hourly review allowance faster; a rate-limited push says so in a\ncomment and never blocks merge, and CodeRabbit's `rate limit` command reports\nwhether reviews are available without consuming one. It auto-detects `AGENTS.md` as review criteria;\n`.gemini/styleguide.md` is added through\n`knowledge_base.code_guidelines.filePatterns` (see the comment there). Repo YAML\noutranks the UI settings (only org/workspace Global Overrides beat it) and does\nnot merge with them — any key it omits falls back to CodeRabbit's schema\ndefaults, not to UI values. A change to `.coderabbit.yaml` never applies to the\nPR making it: on open-source repos CodeRabbit honours only the base branch's\nconfig, so the PR reports `Configuration used: defaults` and the change takes\neffect on merge.\n\n**Bot-authored PRs are excluded from automatic review by both tools** —\nDependabot, Renovate, and the `github-actions[bot]` webhook-proxy promote PRs\n(dev → stable copies whose content was already reviewed in their dev PRs).\nEnforced in `.coderabbit.yaml` `ignore_usernames` and the `pull_request_target`\nadmission list in `pr-codex-review-request.yml`, pinned to each other by\n`test_coderabbit_config.py`. A maintainer can still summon a review on a\npromote PR: `@coderabbitai review`, or for Codex a comment that is exactly\n`/review` (or `@ghhamcp review`) — the `issue_comment` admission list\ndeliberately omits `github-actions[bot]` to keep that lever.\n\n**Division of Labor:**\n- **Codex (automatic)**: Code quality, test coverage, generic security, MCP conventions\n- **CodeRabbit (automatic, drafts included)**: Line-level review against `AGENTS.md` and `.gemini/styleguide.md`, PR walkthrough and summary\n- **Claude `/contrib-pr-review` (on-demand)**: Repo-specific security (AGENTS.md, .github/, .claude/), detailed test analysis, PR size assessment, issue linkage\n- **Claude `/my-pr-checker` (lifecycle)**: Resolve threads, fix issues, monitor CI, create improvement PRs\n\n### Issue Labels\n\n**Triage-state labels** (applied during manual triage):\n\n| Label | Meaning |\n|-------|---------|\n| `ready-to-implement` | Clear path, no decisions needed |\n| `needs-choices` | Multiple approaches, needs stakeholder input |\n| `needs-info` | Awaiting clarification from reporter. `close-needs-info.yml` clocks from the label event: reminders on days 3/5/6, auto-close on day 7 without an author reply; an author reply removes the label |\n| `priority: high/medium/low` | Relative priority |\n| `triaged` | Automated triage complete (historical — applied by the retired `issue-triage.yml` bot) |\n| `triage-failed` | Automated triage failed (historical — applied by the retired `issue-triage.yml` bot) |\n| `issue-analyzed` | Deep Claude analysis complete |\n\n**Bug-class labels** (applied via `.github/ISSUE_TEMPLATE/` form selection, CodeRabbit auto-labeling, or manual triage):\n\n| Label | Meaning |\n|-------|---------|\n| `runtime-bug` | Bug occurring during normal operation (post-startup) |\n| `startup-bug` | Bug during startup, install, or connect |\n| `agent-behavior` | AI agent behavior or workflow feedback (tool selection, prompt drift, etc.) |\n\n**Scope labels** (manually applied during triage; orthogonal to bug-class — an issue can carry both `runtime-bug` AND a scope marker):\n\n| Label | Meaning |\n|-------|---------|\n| `addon` | Issue is specific to the Home Assistant Add-on deployment (`homeassistant-addon/`, Supervisor ingress) |\n| `docker` | Issue is specific to the Docker / containerized deployment (`Dockerfile`, container env) |\n| `javascript` | Issue concerns the project website / Astro app (TypeScript) under `site/` |\n\n**Lifecycle labels** (manually applied; do not double as close-reasons):\n\n| Label | Meaning |\n|-------|---------|\n| `wontfix` | Issue is valid but will not be addressed. Typically used when closing an issue to record the rejection rationale. |\n| `blocked` | Forward progress depends on an unresolved external item (upstream HA change, a sibling PR, a pending design decision). Recorded so a sweeper search can find what's waiting |\n\n**Tracking / automation labels** (applied by tooling):\n\n| Label | Meaning |\n|-------|---------|\n| `python-upgrade` | Auto-attached to every Renovate-managed PR (including non-Python dependency updates) via `renovate.json` global `labels` array. |\n\n### Issue Analysis Workflow\n\n- **Automated Triage (CodeRabbit)**: `issue_enrichment` in `.coderabbit.yaml`. On new and edited issues CodeRabbit posts an enrichment comment (possible duplicates, related issues and PRs, suggested assignees) and auto-applies labels per `labeling_instructions`. Plans are manual: comment `@coderabbitai plan` on an issue, or tick the Create Plan checkbox in the enrichment comment. (Replaces the retired GitHub Models `issue-triage.yml` bot.)\n- **Deep Analysis (Claude)**: When user says \"analyze issues\", list issues missing `issue-analyzed` label, then invoke `/issue-analysis <number>` for each sequentially (the skill drafts analysis for user approval before posting).\n\n```bash\ngh issue list --state open --json number,title,labels --jq '.[] | select(.labels | map(.name) | contains([\"issue-analyzed\"]) | not) | \"#\\(.number): \\(.title)\"'\n```\n\n### PR Review Comments\n\n**Always check for comments after pushing to a PR.** They come from bots\n(Codex, CodeRabbit, Copilot) or humans. Address human comments with highest\npriority; treat bot comments as suggestions to assess, not commands.\n\n**Reply, then resolve.** After addressing an inline comment, reply on its\nthread documenting the fix, then mark the thread resolved. When a review has\ninline comments, do both: reply per-thread *and* post one PR-level summary\ncomment. Leave a thread open only when the reply asks the reviewer for\nclarification. Unresolved threads block merge even after approval: the merge\nbutton stays disabled until every thread is resolved.\n\nThe `/my-pr-checker` skill carries the exact commands (the inline-reply\n`pulls/<PR>/comments/<id>/replies` endpoint, the PR-level review, and the\n`resolveReviewThread` GraphQL mutation, whose input field is `threadId`, not\n`pullRequestReviewThreadId`).\n\n## Git & PR Policies\n\n**CRITICAL - Never commit directly to master, except for documentation-only adjustments.**\n\nYou are STRICTLY PROHIBITED from committing to `master` or `main` branch. Always use worktrees for feature work:\n\n```bash\n# Use /wt skill or manually:\ngit worktree add worktree/<branch-name> -b <branch-name>\ncd worktree/<branch-name>\n```\n\n**Before any commit, verify:**\n1. Current branch: `git rev-parse --abbrev-ref HEAD` (must NOT be master/main)\n2. In worktree: `pwd` (must be in `worktree/` subdirectory)\n\n**Never push or create PRs without user permission.**\n\n**Always create PRs as draft.** Use `gh pr create --draft`. Only mark a PR as ready for review (`gh pr ready <PR>`) when explicitly requested by the user. **Before marking ready, update the PR description** to reflect all changes made since the PR was created.\n\n### PR Workflow\n\n**After creating or updating a PR, always follow this workflow:**\n\n1. **Update tests if needed**\n2. **Commit and push**\n3. **Wait for CI** (~3 min for tests to start and complete):\n   ```bash\n   sleep 180\n   ```\n4. **Check CI status**:\n   ```bash\n   gh pr checks <PR>\n   ```\n5. **Check for review comments** (see \"PR Review Comments\" section above)\n6. **Fix any failures**:\n   ```bash\n   # View failed run logs\n   gh run view <run-id> --log-failed\n\n   # Or find the run ID from PR\n   gh pr checks <PR> --json | jq '.[] | select(.conclusion == \"failure\") | .detailsUrl'\n   ```\n7. **Address review comments** if any (prioritize human comments)\n8. **Update PR description** if the scope changed (only when PR is already marked as ready)\n9. **Repeat steps 2-8 until:**\n   - ✅ All CI checks green\n   - ✅ All comments addressed\n   - ✅ PR ready for merge\n\n### PR Execution Philosophy\n\n**Work autonomously during PR implementation:**\n- Don't ask the user about every small choice or decision during implementation\n- Make reasonable technical decisions based on codebase patterns and best practices\n- Fix unrelated test failures encountered during CI (even if time-consuming)\n- Document choices for final summary\n\n**Making implementation choices:**\n- **DO NOT** choose based on what's faster to implement\n- **DO** consider long-term codebase health - refactoring that benefits maintainability is valid\n- **For non-obvious choices with consequences**: Create 2 mutually exclusive PRs (one for each approach) and let user choose\n- **For obvious choices**: Implement and document in final summary\n\n**When you notice an improvement during a PR**: fix it in place by default. See [Boy Scout Rule — Handling Discovered Improvements](#boy-scout-rule--handling-discovered-improvements) below for the deferral scale.\n\n**Final reporting:** Once the PR is ready, post an Implementation Summary comment on the PR (choices made, problems encountered) and give the user a short summary.\n\n### Boy Scout Rule — Handling Discovered Improvements\n\n**IMPORTANT — Default is fix-in-place.** \"Boy Scout Rule\" means leave touched code better than you found it. \"Improve incrementally\" means commit-by-commit within *this* PR — not across follow-up PRs. Deferral is the exception, not the default. Weigh fix-in-place sweeps against regression risk: if a sweep would meaningfully expand the diff or change the review surface, treat it as Mid-sized and ask the user.\n\n**Never open a follow-up PR or issue without explicit user approval.**\n\nWhen you notice something while working on a PR, apply this scale:\n\n| What you find | Action |\n|---|---|\n| **Small** — a few lines, clearly in scope (see examples below) | **Fix in this PR** as a separate commit. No mention in PR description. |\n| **Mid-sized** — meaningful effort, worth doing but out of scope (e.g. adding a new helper module that doesn't exist yet, a gap that needs non-trivial new test scaffolding, a code-quality issue that's *not* really low) | **Pause before pushing.** Ask the user whether to bundle. |\n| **Large / unrelated** — many files, design decisions, different subsystem (e.g. would double the diff size or change the review surface, code quality is *really* low / technical debt) | Mention in PR description only if the user confirms. Open a separate issue **only if** the user asks AND you can state a concrete benefit in one sentence. |\n\n**\"Small\" examples — fix these inline, no mention needed:**\n\n- Typo, dead import, misnamed local\n- Stale docstring/comment or stale reference\n- 1–N line cleanup of code in this diff\n- Multi-site sweep of the same pattern you can grep for\n- Missing test for code you're touching (add the test without refactoring the surrounding code)\n- Low coverage for the area you're working in\n- Straightforward test-quality fix (better assertions, clearer names, removing duplication)\n- \"Mirror X parity onto Y\" where Y is in the diff\n- Migrating a singular→list or similar shape-consistency fix\n- Drift between docs and live state you can fix by reading both\n\n**When to ask the user about bundling.** ~200 lines is a *should-I-ask* heuristic, not a bundling cap. Under ~200 lines: bundle without asking. Over ~200 lines: ask the user whether to bundle — but **bundling at any size is fine if the work is not grossly out of scope**. The 200-line mark exists so the user hears about large bundled changes before they land, not to push large work out of the PR. Estimate honestly; do not inflate to manufacture a reason to defer.\n\n**Anti-noise gate — before filing any follow-up issue or PR, all three must be true:**\n\n1. The work is genuinely too large to bundle (i.e. truly out of scope, not just over the ~200-line ask-heuristic above). **All three sub-tests must pass:**\n   (a) It cannot be done by mirroring an existing sibling pattern in the same file or a closely-related file.\n   (b) You can name the actual design choice in one sentence with two named alternatives, **OR** the work is a genuinely large mechanical migration (e.g. *\"replace `requests` with `httpx` across 40 sites\"*) that exceeds this PR's scope by size alone.\n   (c) It would meaningfully change this PR's review surface, not just add to it.\n2. You can name a concrete end-user-facing or maintainer benefit in one sentence.\n3. A maintainer reading the issue 6 months later would act on it, not close as stale.\n\nIf any are false: fix it now, or let it go. **Do not file an issue to \"track\" it.**\n\n**Scope is the user's call, not yours.** Before deferring anything, explicitly ask with a specific reason: *\"I think this is out of scope because [X]. Fix here or defer?\"* — do not silently drop it.\n\nThe following phrases are red flags that you're making a scope decision unilaterally (list is non-exhaustive — match on intent, not exact string): \"post-merge follow-up\", \"follow-up consideration\", \"forward-looking note\", \"nice to have\", \"Happy to file an issue\", \"out of scope for this PR\", \"not blocking this PR\", \"pre-existing — not touching it\" (pre-existing is not a reason to skip; addressing pre-existing things is the point of this rule), \"real design work, not N lines\", \"worth tracking as a follow-up issue\".\n\n**Code-review bot suggestions** (Codex, CodeRabbit, Copilot non-blocking nits): apply inline or dismiss. Never spawn a follow-up issue from a bot suggestion unless the user explicitly confirms it's a large, out-of-scope change. See `.gemini/styleguide.md` § *Non-Blocking Suggestions and Scope* for the bot-side rule.\n\n### Urgent Release Process\n\nCritical fixes follow the normal development flow: branch from `master`, merge\nthe fix to `master`, then manually dispatch `semver-release.yml` from `master`.\nUse its `force` input only when a release is required without a releasable\n`feat`, `fix`, `perf`, `refactor`, breaking `!`, or `BREAKING CHANGE` commit\nsince the previous stable tag.\n\n### Test Coverage Requirements\n\n**When tests ARE required:**\n- New MCP tools in `src/ha_mcp/tools/` without any E2E tests\n- Tools that previously had NO tests — add E2E tests even if not part of current PR\n- Core functionality changes in `client/`, `server.py`, or `errors.py` without coverage\n- Bug fixes — use TDD: write the failing regression test first, then fix the code so the test passes\n\n**When tests may NOT be required:**\n- Refactoring with existing comprehensive test coverage\n- Documentation-only changes (`*.md` files)\n- Minor parameter additions to well-tested tools\n- Internal utilities already covered by E2E tests\n\n**When to open an issue instead:** See § *Boy Scout Rule — Handling Discovered Improvements* for the gate. Never open without explicit user approval.\n\n## CI/CD Workflows\n\n| Workflow | Trigger | Purpose |\n|----------|---------|---------|\n| `pr.yml` | PR opened | Lint, type check |\n| `e2e-tests.yml` | PR to master | Full E2E tests (~3 min) |\n| `publish-dev.yml` | Push to master | Dev release `.devN` |\n| `notify-dev-channel.yml` | Push to master (src/) | Comment on PRs/issues with dev testing instructions |\n| `semver-release.yml` | Biweekly Wed 10:00 UTC or manual dispatch | Stable release (cuts version tag + GitHub release) |\n| `release-publish.yml` | After SemVer Release (`workflow_run`) or manual dispatch | Publish stable Docker image (`:latest` + `:stable` + semver) + MCP registry |\n| `build-binary.yml` | Release | Linux/macOS/Windows binaries |\n| `addon-publish.yml` | Release | HA add-on update |\n| `sync-tool-docs.yml` | Push to master (`src/ha_mcp/tools/`, `scripts/extract_tools.py`) | Regenerate `tools.json`, README, DOCS.md |\n| `locale-sync.yml` | Daily schedule + manual dispatch | Machine-translate stale/missing strings post-merge and push them straight to master |\n\n**Docker image tags** (`ghcr.io/homeassistant-ai/ha-mcp`): stable releases push `:latest` + `:stable` + semver tags (`release-publish.yml`); dev builds push only `:dev` + `:dev-<sha>` (`publish-dev.yml`) — **never `:latest`**, which is reserved for stable. The HA add-on images live in separate repos (`-addon-{arch}`, `-addon-dev-{arch}`) and are selected by an explicit `version:` pin, not by `:latest`.\n\n## Development Commands\n\n### Setup\n```bash\nuv sync --group dev        # Install with dev dependencies\nuv run ha-mcp              # Run MCP server (stdio; needs interactive stdin)\nuv run ha-mcp-web          # Run HTTP server; web settings UI at http://localhost:8086/mcp/settings (see src/ha_mcp/settings_ui/AGENTS.md)\ncp .env.example .env       # Configure HA connection\n```\n\n### Testing\nE2E tests are in `tests/src/e2e/` (not `tests/e2e/`). Tests use **testcontainers** to spin up\nan isolated Docker HA instance — Docker daemon must be running.\n\n```bash\n# Run FULL E2E suite (required before claiming all tests pass)\n# -n2 is optimal locally (each worker spins up its own HA container;\n# more workers add memory pressure without proportional speedup).\n# CI uses -n3 tuned for 2-vCPU GitHub runners with 15GB RAM.\ncd tests && uv run pytest src/e2e/ -n2 --dist loadscope -v --tb=short\n\n# Run specific file (partial coverage only — never substitute for full suite)\ncd tests && uv run pytest src/e2e/workflows/automation/test_lifecycle.py -v\n\n# Interactive test environment\nuv run hamcp-test-env                    # Interactive mode\nuv run hamcp-test-env --no-interactive   # For automation\n```\n\n**CRITICAL RULES:**\n- Always run from the `tests/` directory so pytest picks up the correct `conftest.py`\n- Always run the **full suite** before declaring tests pass\n- `tests/.env.test` contains placeholder values only; testcontainers sets the real URL dynamically\n- Never set `HOMEASSISTANT_URL` manually in your shell before running tests\n- **Always run relevant e2e tests after making changes**, without waiting to be asked. Identify the relevant test file(s) for the area you changed and run them. Do not assume Docker is unavailable or prerequisites are missing — just run them and let pytest report what is skipped and why.\n\nTest token centralized in `tests/test_constants.py`.\n\n### Code Quality\n\nC901 (mccabe complexity ≤10) is enforced repo-wide with zero per-file exemptions (issue #925 cleared the grandfathered list) — never reintroduce a `[\"C901\"]` per-file-ignore; extract helpers instead.\n\n```bash\nuv run ruff check src/ tests/ --fix\n# Note: --fix removes unused imports from non-__init__ modules (lefthook runs it on commit with\n# stage_fixed). When adding an import, include its first use in the same change or it gets stripped.\nuv run mypy src/\n```\n\n### Docker\n```bash\n# Stdio mode (Claude Desktop) — local-only, no network exposure\ndocker run --rm -i \\\n  -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\\n  ghcr.io/homeassistant-ai/ha-mcp:latest\n\n# HTTP mode (loopback only, same-host LLM client)\n# Connect URL: http://127.0.0.1:8086/mcp  (default MCP_SECRET_PATH)\ndocker run -d -p 127.0.0.1:8086:8086 \\\n  -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\\n  ghcr.io/homeassistant-ai/ha-mcp:latest ha-mcp-web\n\n# HTTP mode (LAN-reachable) — generate the secret first so you can configure the MCP client with it\nMCP_SECRET=\"/private_$(python3 -c 'import secrets; print(secrets.token_urlsafe(16))')\"\necho \"MCP_SECRET_PATH=$MCP_SECRET\"\ndocker run -d -p 8086:8086 \\\n  -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\\n  -e MCP_SECRET_PATH=\"$MCP_SECRET\" \\\n  ghcr.io/homeassistant-ai/ha-mcp:latest ha-mcp-web\n```\n\nSee [SECURITY.md](SECURITY.md) for authentication and network binding details.\n\n## Architecture\n\n```\nsrc/ha_mcp/\n├── server.py          # Main server with FastMCP\n├── __main__.py        # Entrypoint (CLI handlers)\n├── config.py          # Pydantic settings management\n├── errors.py          # 38 structured error codes\n├── client/\n│   ├── rest_client.py       # HTTP REST API client\n│   ├── websocket_client.py  # Real-time state monitoring\n│   └── websocket_listener.py\n├── auth/\n│   ├── provider.py          # OAuth provider (HTTP mode)\n│   └── consent_form.py      # OAuth consent screen\n├── tools/             # 36 modules, auto-discovered\n│   ├── registry.py          # Lazy auto-discovery\n│   ├── smart_search/        # Fuzzy entity search\n│   ├── device_control.py    # WebSocket-verified control\n│   ├── best_practice_checker.py # Reactive HA config validator (warns + embeds skill content)\n│   ├── tools_*.py           # Domain-specific tools\n│   └── util_helpers.py      # Shared utilities\n├── utils/\n│   ├── fuzzy_search.py      # textdistance-based matching\n│   ├── domain_handlers.py   # HA domain logic\n│   ├── operation_manager.py # Async operation tracking\n│   ├── skill_loader.py      # Skills-vendor file loader (used by ha_get_skill_guide and write tools)\n│   ├── usage_logger.py      # Per-tool usage telemetry\n│   ├── data_paths.py        # Canonical data directory paths\n│   ├── python_sandbox.py    # Sandboxed Python-expression eval for python_transform on config tools\n│   ├── kill_signal_diagnostics.py # Kill-signal (SIGTERM/SIGINT/SIGHUP) shutdown diagnostics\n│   └── config_hash.py       # Shared optimistic-locking hash (automation/script/scene/dashboard/energy)\n└── resources/\n    ├── card_types.json\n    └── dashboard_guide.md\n```\n\n### Key Patterns\n\n**Tools Registry**: Auto-discovers `tools_*.py` modules with `register_*_tools()` functions. No changes needed when adding new modules.\n\n**Lazy Initialization**: Server, client, and tools created on-demand for fast startup.\n\n**Service Layer**: Business logic in `smart_search/`, `device_control.py` separate from tool modules.\n\n**WebSocket Verification**: Device operations verified via real-time state changes.\n\n**Tool Completion Semantics**: Tools should wait for operations to complete before returning, with optional `wait` parameter for control.\n\n## Writing MCP Tools\n\n### Naming Convention\n`ha_<verb>_<noun>`:\n- `get` — single item (`ha_get_state`)\n- `list` — collections (`ha_list_services`)\n- `search` — filtered queries (`ha_search`)\n- `set` — create/update (`ha_config_set_helper`)\n- `delete` — delete dashboards, config entries, or files (`ha_config_delete_dashboard`, `ha_delete_file`)\n- `remove` — remove registry items (`ha_remove_entity`, `ha_remove_area_or_floor`)\n- `call` — execute (`ha_call_service`, `ha_call_event`)\n- `manage` — multi-modal tools combining several operations behind one interface (`ha_manage_addon`)\n\n**Namespace prefixes**: An optional `<namespace>_` prefix between `ha_` and the verb is allowed for grouped tool families that share a domain. The full shape becomes `ha_<namespace>_<verb>_<noun>`:\n- `ha_config_<verb>_<noun>` — config-management tools (`ha_config_set_helper`, `ha_config_set_automation`, `ha_config_remove_automation`, `ha_config_delete_dashboard`)\n- `ha_dev_<verb>_<noun>` — developer-mode tools (`ha_dev_manage_server`, `ha_dev_manage_settings`); registered only when the `enable_dev_mode` setting is on (Developer section at the bottom of the web settings UI's Server Settings tab)\n\n**Accepted exceptions**: A small set of tools name a single, distinct operation where forcing a `<verb>_<noun>` shape would read worse than the natural name. These are accepted as-is and should not be flagged:\n- `ha_restart`, `ha_reload_core`, `ha_eval_template`\n- `ha_report_issue`, `ha_import_blueprint`\n- `ha_read_file`, `ha_write_file`, `ha_bulk_control`\n\n**Adding new verbs**: When no existing verb fits a new tool's purpose, add the verb to the approved-verbs list above rather than forcing a poor fit. `.gemini/styleguide.md` points back to this section as the single source of truth, so updates here propagate automatically.\n\n### Tool Structure\nCreate `tools_<domain>.py` in `src/ha_mcp/tools/`. Registry auto-discovers it.\n\n```python\nfrom fastmcp.tools import tool\nfrom .helpers import log_tool_usage, register_tool_methods\n\nclass DomainTools:\n    def __init__(self, client):\n        self._client = client\n\n    @tool(name=\"ha_<verb>_<noun>\", tags={\"Category Name\"}, annotations={\"readOnlyHint\": True, \"idempotentHint\": True})\n    @log_tool_usage\n    async def ha_<verb>_<noun>(self, param: str) -> dict[str, Any]:\n        \"\"\"<Action verb> <what this tool does -- one sentence>.\n\n        <Optional: second sentence for key behavioral distinction or modes>\n        \"\"\"\n        # Add to the docstring above only when genuinely needed:\n        # RELATED TOOLS: ha_next(): why to call this after (workflow-entry tools only)\n        # EXAMPLES: ha_<verb>_<noun>(\"realistic_value\")  -- non-obvious call patterns only\n        # When NOT to use: route to preferred alternatives\n        # Caveats: destructive side-effects, non-obvious gotchas\n        # For complex schemas: use ha_get_skill_guide\n\ndef register_<domain>_tools(mcp, client, **kwargs):\n    register_tool_methods(mcp, DomainTools(client))\n```\n\n`@tool` (from `fastmcp.tools`) attaches metadata to the method. `@tool` must be the outermost decorator (above `@log_tool_usage`) so that `__fastmcp__` is present on the final method object. `register_tool_methods()` auto-discovers all `@tool`-decorated methods and calls `mcp.add_tool()` for each. The registry discovers `register_*_tools` functions by convention.\n\n### Tool Docstrings\n\nThe single-line template is the default -- extend it only where it genuinely helps.\n\n**Required for every tool:**\n- Starts with an action verb (`Get`, `List`, `Search`, `Create`, `Update`, `Delete`, `Remove`, `Execute`, `Call`, `Manage`)\n- One sentence describing what the tool does (not how)\n\n**Add `RELATED TOOLS` when** the tool is a workflow entry point and the natural next step is not obvious.\nExample: `ha_search` hints at `ha_get_state`.\n\n**Add `EXAMPLES` when** the tool has multiple modes or non-obvious parameters.\nOmit when a single required parameter makes the call self-evident.\n\n**For multi-line docstrings, follow this structure** (based on\n[Anthropic's tool design guidance](https://www.anthropic.com/engineering/writing-tools-for-agents)):\n1. What the tool does (required first sentence, action verb)\n2. When NOT to use it — name the preferred alternatives\n3. When to use it — valid use cases\n4. Caveats — consequences, post-actions, destructive side-effects\n\nConsequence statements are plain prose: \"This permanently deletes the dashboard.\nA backup is created before every edit.\" Route safety concerns through `annotations`\n(`destructiveHint`, `idempotentHint`, `readOnlyHint`), not docstring keywords.\n\n**Defer complex schemas** instead of embedding them:\n`# For complex schemas: use ha_get_skill_guide`\n\n**What NOT to include:** full parameter documentation, type descriptions already in the\nsignature, HA domain internals the model already knows, or motivational prose.\n\n\n### Tool Tags\n\nEvery tool needs `tags={\"Category Name\"}` (native FastMCP parameter). Drives the README table, `site/src/data/tools.json`, and `homeassistant-addon/DOCS.md`. These are auto-regenerated on merge by `sync-tool-docs.yml` — no manual regeneration needed. For local testing: `python scripts/extract_tools.py`\n\n### Safety Annotations\n| Annotation | Default | Use For |\n|------------|---------|--------|\n| `readOnlyHint: True` | `False` | Tool does not modify its environment |\n| `destructiveHint: True` | `True` | Tool may perform destructive updates (only meaningful when `readOnlyHint` is false). Set to `False` for non-destructive writes (e.g., creating a record) |\n| `idempotentHint: True` | `False` | Repeated calls with same args have no additional effect (only meaningful when `readOnlyHint` is false) |\n| `openWorldHint: True` | `True` | Tool reaches an external, third-party-authored world (HACS store, add-on repositories, GitHub release feeds, arbitrary import URLs). Set to `False` when the tool's domain is the local Home Assistant instance. A tool is also open-world if its output carries externally-authored content back to the client, even when a local integration (HACS, Supervisor, HA Core) makes the actual network call on its behalf — `ha_get_overview` and `ha_get_system_health` embed the update-check field that reaches PyPI / the Supervisor store, while `ha_get_blueprint` and `ha_config_list_dashboard_resources` return externally-authored content from purely local reads. Required on every tool — the default is `true`, so an omitted value silently marks a local tool as open-world |\n\n**Version baseline:** annotations describe a tool's behavior against current\nupstream versions of any external engine or component it drives; a side effect\nthat exists only in outdated external builds does not demote the tool to\nwrite-classified — document the update requirement in the tool's docs instead\n(e.g. the screenshot engine's old `settheme` write, #1991).\n\n### Error Handling\n\n**Always use the dedicated error functions** from `errors.py` and `helpers.py`. Never construct raw error dicts manually — the helpers ensure consistent structure, error codes, and suggestions across all tools.\n\n**All tool-level failures must raise `ToolError`** (sets `isError=true` per MCP spec). Batch item failures within result arrays are the only exception — those return structured dicts without raising.\n\n**Pattern A — Exception blocks** (most common): call `exception_to_structured_error` without `return` — it raises `ToolError` by default:\n```python\nfrom .helpers import exception_to_structured_error, raise_tool_error\nfrom fastmcp.exceptions import ToolError\n\ntry:\n    # ... tool logic ...\nexcept ToolError:\n    raise  # must re-raise; prevents ToolError being swallowed by outer except\nexcept Exception as e:\n    exception_to_structured_error(\n        e,\n        context={\"entity_id\": entity_id},\n        suggestions=[\"Verify entity exists\", \"Check HA connection\"],\n    )\n```\n\nThe `except ToolError: raise` guard is required whenever `raise_tool_error()` or validation errors are called inside the same `try` block — without it, `except Exception` catches the `ToolError` and re-maps it to `INTERNAL_ERROR`.\n\n**Pattern B — Input validation errors**: use `raise_tool_error(create_error_response(ErrorCode.VALIDATION_INVALID_PARAMETER, message, context={...}, suggestions=[...]))`.\n\n**Pattern C — Service call failures**: check `result.get(\"success\")` and raise with `ErrorCode.SERVICE_CALL_FAILED` using `result.get(\"error\", \"Operation failed\")` as the message.\n\n**Pattern D — Batch item failures** (items inside a results list — do NOT raise):\n```python\nresults.append(create_error_response(\n    ErrorCode.SERVICE_CALL_FAILED,\n    str(e),\n    context={\"entity_id\": eid},\n))\n```\n\nOnly use `raise_error=False` on `exception_to_structured_error` when you need to mutate the dict before raising. Never add `add_timezone_metadata` to errors.\n\n`exception_to_structured_error` auto-classifies 404s, auth errors, timeouts by exception type. Pass `context={\"entity_id\": ...}` for automatic `ENTITY_NOT_FOUND` on 404s. Available helpers: `create_entity_not_found_error`, `create_connection_error`, `create_auth_error`, `create_service_error`, `create_validation_error`, `create_config_error`, `create_timeout_error`, `create_error_response`.\n\n### Return Values\n```python\n{\"success\": True, \"data\": result}                     # Success\n{\"success\": True, \"data\": result, \"warnings\": [...]}  # Degraded (top-level list[str], omit when empty)\nraise ToolError(json.dumps({...}))                    # Tool-level failure (isError=true)\n{\"success\": False, \"error\": {...}}                    # Batch item failure only (in results list)\n```\n\n`warnings` is always a top-level `list[str]`, never nested inside `data` and never a singular `\"warning\": \"...\"` string. See `tools_config_helpers.py::HelperResponse` / `_helper_response` for the canonical shape and `tests/src/unit/test_helper_response_shape.py` for the contract assertions.\n\n### Tool Consolidation\nWhen a tool's functionality is fully covered by another tool, **remove** the redundant tool rather than deprecating it. Fewer tools reduces cognitive load for AI agents and improves decision-making. Do not add deprecation notices or shims — just delete the tool and update any docstring references to point to the replacement.\n\nThis project's tool count exceeds the [10-20 tool threshold](https://ai.google.dev/gemini-api/docs/function-calling) where selection accuracy degrades. Reducing count is a priority — combine frequently chained operations into one tool and ensure each tool has a clear, distinct purpose. See [Anthropic's tool design blog](https://www.anthropic.com/engineering/writing-tools-for-agents) for guidance.\n\n| Pattern | Example | Guideline |\n|---------|---------|-----------|\n| Tool A is a strict subset of Tool B | `ha_dashboard_find_card` fully covered by `ha_config_get_dashboard` | Consolidate (remove A) |\n| Frequently chained operations | Multi-step workflows combined into one tool | Consolidate — reduces round-trips |\n\n**Breaking changes**: only removing functionality with no alternative requires a major bump. Consolidation and renaming are not breaking.\n\n**Context engineering**: provide minimum context; let models fetch more via `ha_get_skill_guide`. Favor statelessness and content-derived hashes for optimistic locking.\n\n### Module Size\n\nKeep modules focused. Past ~1000 lines (Pylint's `max-module-lines` default) a module usually spans multiple concerns and is worth splitting along those concerns. Pick whatever decomposition fits and fix the internal imports (and any test patch targets) that reference the moved code as part of the move. There's no external import contract to preserve: it's one project, and MCP tools are resolved dynamically at runtime by name (and renaming isn't breaking either, see Tool Consolidation).\n\n## Tool Waiting Behavior\n\n**Principle**: MCP tools should wait for operations to complete before returning, not just acknowledge API success.\n\nTools have an optional `wait` parameter (default `True`) that polls for completion. Use `wait=False` for bulk operations, then batch-verify. Categories:\n- **Config ops** (automations, helpers, scripts): Wait by default (poll until entity queryable/removed)\n- **Service calls** (lights, switches): Wait for state change on state-changing services (turn_on, turn_off, toggle, etc.)\n- **Async ops** (automation triggers, external integrations): Return immediately (not state-changing)\n- **Query ops** (get_state, search): Return immediately (no `wait` parameter)\n\n**Shared utilities** in `src/ha_mcp/tools/util_helpers.py`:\n- `wait_for_entity_registered(client, entity_id)` — polls until entity accessible via state API\n- `wait_for_entity_removed(client, entity_id)` — polls until entity no longer accessible\n- `wait_for_state_change(client, entity_id, expected_state)` — polls until state changes\n\n## Custom Component\n\nThe `custom_components/ha_mcp_tools/` integration ships separately from the\n`ha-mcp` server package (it reaches the HA instance via HACS), so CI cannot\nfully validate a component change before merge.\n\n- **Version bumps ride the stable release cycle — do not bump per PR or per\n  push.** The component version (`manifest.json` `version` + `COMPONENT_VERSION`\n  in `const.py`, kept in lockstep by the parity test) should lead the last\n  **stable** release by exactly one pending version, so everything merged since\n  the last stable cut ships together under one number on the next stable\n  release. Check the pending state with `git show\n  stable:custom_components/ha_mcp_tools/const.py | grep COMPONENT_VERSION` vs\n  master, then:\n  - **Level with stable** (no pending version yet): bump once — patch by\n    default — to open the pending version.\n  - **Already ahead of stable** (a pending version exists): do **not** bump;\n    your change rides under the existing pending version.\n  - Raise the pending version further only to **escalate the bump level** — e.g.\n    the pending version is a patch but your change warrants a minor — and then\n    go straight to that minor, not an extra patch. Never go past the current\n    pending version otherwise; per-revision bumps skip never-shipped numbers and\n    desync the version from the release cycle.\n  - CI enforces the level-with-stable case twice: the PR-level **Component\n    Version Gate** fails a component change whose manifest version does not\n    strictly lead the mirror's released stable (equal = bump to open the\n    pending version; behind = a stale tree or bad merge resurrected an old\n    version), and the mirror sync's stable tag step fails loud\n    when an already-tagged version's component content has drifted (changes\n    merged onto a shipped version would otherwise strand with no installable\n    release — the gap is a PR opened while a version is pending that merges\n    only after that version goes stable, which re-runs no PR checks).\n- **When the change adds a service or argument the server depends on**, this PR\n  must **open a fresh pending component version** (bump `manifest.json` +\n  `COMPONENT_VERSION`) and raise `MIN_COMPONENT_VERSION` in\n  `src/ha_mcp/tools/tools_filesystem.py` to that same new version. This is the\n  one case that **overrides** the \"already ahead of stable → do not bump\" rule\n  above: bump here even if a pending version already exists. `get_caller_token`\n  reports the manifest version and the server gates on it, so without the gate\n  the old and new component are indistinguishable: a caller on the old version\n  passes the check and then hits raw \"service not found\" errors instead of an\n  actionable \"update\" prompt. **Never floor at a version any build lacking the\n  behaviour also reports** – an already-shipped version, or a pending version\n  that was opened *before* this behaviour landed. Such a build passes the gate\n  yet lacks the behaviour, defeating the gate (#1946: the floor was set to a\n  1.1.0 that had already shipped without the gated behaviours, so 1.1.0 builds\n  split into with/without and the gate could not tell them apart).\n- **Keep the component backward-compatible with the released server.** The\n  component (HACS) and the server (add-on / PyPI / Docker) follow the same\n  release cycle but are updated independently per install, so a new component\n  can run against an *older* server. Never remove or tighten an existing service\n  schema (e.g. dropping a param from a strict `vol.Schema`) without a shim the\n  prior server still satisfies; the version gate can't protect this direction\n  (the old server is the caller). Remove the shim once the matching\n  `MIN_COMPONENT_VERSION` server is the floor.\n- **Live-test on the dev server immediately after merge**, before the next\n  stable cut. The component path cannot be fully exercised by CI pre-merge.\n\n## Translations\n\n**One canonical store, generated projections, automated retranslation**\n(issue #2083). The settings UI catalogs\n(`src/ha_mcp/settings_ui/locales/<code>.json`) are the canonical store for\nevery string except the component's config flow: the add-on option strings\nlive there under `addon.<key>.*` (plus `features.<key>.*` for options the\nsettings UI also shows, and `addon_stable.<key>.*` for a stable-flavor\nwording deviation). Both add-on flavors' `translations/*.yaml` and the\n`FEATURE_META` block in `settings.js` are **generated** from that store by\n`scripts/generate_locales.py` — never edit them by hand;\n`test_derived_catalogs_match_the_canonical_store` fails until you regenerate.\nEach flavor's key list is its own `config.yaml` `schema:`, so the two YAMLs\nare different projections of the one store, and cross-surface wording\nidentity holds by construction.\n\nA language ships on all four surfaces or not at all —\n`tests/src/unit/test_locale_parity.py` enforces it. The same Home Assistant\nlanguage code (`cs`, `de`, `eo`, `es`, `fr`, `it`, `nl`, `pl`, `ru`, `sv`, `zh-Hans`) names every file:\n`src/ha_mcp/settings_ui/locales/<code>.json`,\n`custom_components/ha_mcp_tools/translations/<code>.json`, and\n`homeassistant-addon{,-dev}/translations/<code>.yaml`.\nThat list of codes is itself pinned by\n`test_agents_md_lists_every_shipped_locale`: adding a language means adding its\ncode here, in the same PR, or the suite goes red. To add a language, add the\ntwo authored catalogs (settings UI + component), regenerate, and let the\ntranslation pipeline below fill the strings. The component catalog may start\nempty; the settings one may not start `meta`-only, because four ungated checks\nread the shipped catalogs themselves: every decided `Decision` outcome and\nevery `PredicateOp` operator needs a translated word\n(`policies.pending.decision.*`, `policies.operators.*` — a value that still\nspells the backend literal counts as untranslated), so does\n`policies.pending.already_decided`, the sentence those words are interpolated\ninto, and at least one translated key must have English that addresses the\nreader in the second person, which is where `scripts/translate_locales.py`\nreads the catalog's address register.\n`policies.operators.exists_long` is the trap in that list: the condition editor\nrenders it as its own dropdown label, but it is UI-only rather than a\n`PredicateOp` member, so no enum-derived check asks for it and a catalog\nwithout it reads English there until the sync fills it. Each surface reads that\nregister from its own catalog, so a component catalog left at a key or two\nrests on whichever of them addresses the reader — losing it costs the engine\nthe register for every later string of that language and says so only on\nstderr, which is why\n`test_every_shipped_component_catalog_gets_reader_addressing_samples` pins it.\nAuthor two such keys rather than one: a run that rewords one of them queues it,\nand queued keys are dropped from the sample candidates, so a surface resting on\na single anchor is anchorless in precisely the run that rewrites it —\n`test_component_samples_survive_their_own_anchor_being_queued` pins that.\n`src/ha_mcp/settings_ui/locales/README.md` names the tests — including the one\nthat skips locally until `tests/js/` has its npm dependencies.\n\nSettings UI catalogs are auto-discovered (no registration). Their `messages` may\nomit keys — English is the per-key fallback — but may not carry one `en.json`\nlacks: nothing renders it. `tool_groups` and `tools` may do neither: each locale\nmust carry exactly the renderable group headings and every tool name, no key\nmore and none fewer. The check derives the tool set from\nthe sources (`scripts/extract_tools.py`), not from the committed\n`site/src/data/tools.json` — the check must not depend on a generated\nartifact that a separate post-merge workflow keeps current. Separately from\nthose key rules, both authored surfaces cap how much *text* a catalog\nmay leave byte-identical to English or omit outright, so a stub cannot ride the\nfallbacks: 5% for the settings UI `messages`, its `tools` titles and\ndescriptions, and each generated add-on projection (per flavor, computed from\nthe canonical store), and 15% for the component catalogs,\nwhich carry the product names as keys of their own. On top of that share, a\n`tools` entry whose title *and* description are *both* byte-identical to English\nfails by name however small its share — for feature-gated tools against either\nEnglish rendering, the `FEATURE_GATED_TOOLS` stub or the parsed docstring.\nComponent catalogs need every `strings.json` key with identical\n`{placeholders}`.\n\n**Changing an English string is a one-place edit** (`en.json` `messages`, a\ntool docstring, or `strings.json` + component `en.json`), and the machine\ntranslates the rest: `scripts/translate_locales.py` reads the English-source\nbaseline diff (`tests/src/unit/locale_source_baseline.json`), retranslates\nthe changed or missing keys in every language via the Gemini API\n(`GEMINI_API_KEY`; free tier), validates placeholders and markup, regenerates\nthe derived catalogs, and repins the baseline. The `locale-sync.yml` workflow\nruns it AFTER merge, on a daily schedule, and pushes the result straight to\nmaster with the release App credential (the same pattern as the version-bump\nbots and `sync-tool-docs.yml`) — so any PR, fork or same-repo, merges\nwithout owing translations, and one sync run picks up everything merged\nsince the last one. The checks that police translated content (missing or\norphaned keys, staleness against the baseline, cross-surface shared wording,\nthe untranslated-share ceilings, filled tool sections) are gated behind\n`LOCALE_COMPLETENESS_CHECKS=1` and run in that workflow, not in PR CI —\n`test_locale_sync_gate_shape.py` pins the wiring. What a PR still owes is\ndeterministic and engine-free: regenerate the derived catalogs\n(`python scripts/generate_locales.py`) when a canonical English string\nchanges, and placeholder parity on component keys whose English is current.\nTo choose the wording yourself, translate in your own PR **and run\n`python scripts/update_locale_baseline.py` in it** — the repinned baseline\nis what tells the next sync your wording already covers the changed English\n(hand-edits win); without the repin the sync retranslates the key and\noverwrites you. Run `scripts/translate_locales.py` locally instead to\nmachine-fill in-PR or to use a different engine (it repins for you).\nThe baseline pins the English each translation was written against, because\nkey parity cannot see a string whose meaning changed: #1993 flipped a policy\nstring from ALL-match to ANY-match and left the Chinese text asserting the\nopposite. `python scripts/update_locale_baseline.py` repins it manually after\na hand-translation pass.\n\n**A catalog that lands after a reword is never queued for the keys it missed.**\nThe pin moves when the English does, so a language whose PR was open across\nthat reword merges with the older wording already translated, and the sync sees\na hash that matches: no plan, no correction, indefinitely. Nothing else catches\nit either — key parity sees a value and the share ceiling sees a translated\none. When a locale PR spans an English change, diff the affected keys against\nthat surface's own English before merging — `en.json` for `messages`, the tool\ndefinitions for `tools` (`en.json` ships that section empty), and\n`custom_components/ha_mcp_tools/strings.json` for the component catalog.\nNumbers and code-ish literals are the cheap tell, since a reword usually moves\none, and `test_translations_keep_english_numbers_and_identifiers` checks\nexactly that across all three. Repinning is not the repair — it\nwrites the same hash and queues nothing. Deleting the stale value is: the\nplanner treats a missing key as work for that locale alone.\n\n**A tool docstring is one of those English strings.** `en.json` ships `tools`\nempty, so the English a `tools` entry translates is read from the tool\ndefinition in `src/ha_mcp/tools/` — the `title=` kwarg and the summary\nparagraph of the docstring, or the `FEATURE_GATED_TOOLS` stub where a gated\ntool shows one instead. Editing that summary moves the English out from under\nsix catalogs; the pipeline retranslates them. One deliberate exception: a\nchange to a feature-gated tool's PARSED docstring (its stub unchanged) is\nstub-review work, not translation work — the pipeline holds that baseline key\nstale, and the locale-sync run stays red until a human confirms the stub\nstill describes the tool and runs `python scripts/update_locale_baseline.py`.\n\n**Rate limits and outages degrade loudly, never silently.** Engine calls are\npaced under the free-tier request rate and retry transient errors (429/5xx,\ntimeouts) with backoff; a request that keeps failing marks its strings failed\nand the run continues, and two consecutive dead batches stop the run early\ninstead of burning the remaining quota. A partial run — a daily-quota hit,\nan outage — still commits every finished translation plus\n`tests/src/unit/locale_sync_progress.json`, which the next run reads to\nresume where it stopped: **re-running the workflow — or just waiting for the\nnext day's cron — is the entire recovery procedure.** Only a fully\nsuccessful run repins the baseline and deletes the progress file, so the\nsync runs stay red until every string is translated and nothing\nunvalidated ever ships. **The fallback when the engine is down is a human**:\nanyone can hand-translate the strings the dry-run\nlists, run `python scripts/generate_locales.py` and\n`python scripts/update_locale_baseline.py`, and open an ordinary PR — the\nnext sync run no-ops (it also cleans up any committed progress file).\nHand-edits always win; the machine only ever touches strings whose English\nchanged. The engine itself is one function (`_call_gemini`) with\n`GEMINI_API_URL` / `GEMINI_MODEL` / `GEMINI_API_KEY` overrides for any\nGemini-compatible endpoint, so replacing the provider stays a one-function\nchange.\n\nThe Webhook Proxy add-on and its bundled integration stay **English-only by\ndecision** — not worth the upkeep. The test records that, so any other new\ncatalog directory fails until it is either translated everywhere or listed as\nEnglish-only alongside them.\n\n## Home Assistant Add-on\n\n**Required files:**\n- `repository.yaml` (root) - For HA add-on store recognition\n- `homeassistant-addon/config.yaml` - Must match `pyproject.toml` version\n\n**Two add-on flavors:** `homeassistant-addon/` (stable, slug `ha_mcp`) and\n`homeassistant-addon-dev/` (dev channel, slug `ha_mcp_dev`) are *separate*\nadd-ons with *separate* `config.yaml` files.\n\n**Functional config is NOT auto-synced between them.** The release pipeline\nonly syncs the *version* (the `update-addon-config` job) and the *changelog*\n(the `Copy changelog to addon directory` step in `semver-release.yml`) into\n`homeassistant-addon/`. Functional keys — `ingress`, `ports`,\n`host_network`, `options`/`schema`, etc. — must be edited **by hand** in each\nflavor. When you add a non-beta capability to the dev add-on that should also\nship on stable (e.g. `ingress` for the web Settings UI / \"Open Web UI\" button),\nmirror it into `homeassistant-addon/config.yaml` **in the same PR**. Assuming\n\"the release pipeline handles it\" is what kept `ingress` off the stable add-on.\nBeta-only keys are the deliberate exception — see the NOTE in\n`homeassistant-addon/config.yaml` and `docs/beta.md`.\n\n### Webhook Proxy add-on: dev-first, promote-only\n\n**Any work on the Webhook Proxy add-on must start by reading\n[`homeassistant-addon-webhook-proxy/AGENTS.md`](homeassistant-addon-webhook-proxy/AGENTS.md)**\n— it owns the full flow (flavors, versioning guard, promotion, testing).\nThe short version: `homeassistant-addon-webhook-proxy/` (stable) is never\nedited directly by a PR in regular operation; every change (code *and* docs)\nlands on `homeassistant-addon-webhook-proxy-dev/` with a version bump, and\nstable is updated only via the manual promote workflow.\n\n**Docs**: https://developers.home-assistant.io/docs/add-ons\n\n## API Research\n\nSearch HA Core without cloning (500MB+ repo):\n```bash\n# Search for patterns\ngh search code \"use_blueprint\" --repo home-assistant/core path:tests --json path --limit 10\n\n# Fetch file contents (base64 encoded)\ngh api /repos/home-assistant/core/contents/homeassistant/components/automation/config.py \\\n  --jq '.content' | base64 -d > /tmp/ha_config.py\n```\n\n## Release Process\n\nUses [semantic-release](https://python-semantic-release.readthedocs.io/) with conventional commits.\n\n| Prefix | Bump | Changelog |\n|--------|------|-----------|\n| `fix:`, `perf:`, `refactor:` | Patch | User-facing |\n| `feat:` | Minor | User-facing |\n| `feat!:` or `BREAKING CHANGE:` | Major | User-facing |\n| `chore:`, `ci:`, `test:` | No release | Internal |\n| `docs:` | No release | User-facing |\n| `*:(internal)` | Same as type | Internal |\n\n**Use `(internal)` scope** for changes that aren't user-facing:\n```bash\nfeat(internal): Log package version on startup  # Internal, not in user changelog\nfeat: Add dark mode                             # User-facing\n```\n\n| Channel | When Updated |\n|---------|--------------|\n| Dev (`.devN`) | Every master commit |\n| Stable | Biweekly (Wednesday 10:00 UTC) |\n\nManual release: Actions > SemVer Release > Run workflow.\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","title":"AGENTS.md","content":"# CLAUDE.md\n\nGuidance for Claude Code when working with this repository.\n\n## Repository Structure\n\nThis repository uses a worktree-based development workflow.\n\n**Documentation Setup:**\n- This file is `AGENTS.md` (the canonical source)\n- `CLAUDE.md` is a symlink pointing to `AGENTS.md`\n- Read either file - they're the same content\n- Commit changes to `AGENTS.md`, the symlink will automatically reflect them\n\n**Directory Structure:**\n```\n<repo-root>/                           # Main repository (checkout master here)\n├── AGENTS.md                          # This file (canonical source)\n├── CLAUDE.md -> AGENTS.md             # Symlink for convenience\n├── worktree/                          # Git worktrees (gitignored)\n│   ├── issue-42/                      # Feature branch worktree\n│   └── fix-something/                 # Fix branch worktree\n├── local/                             # Scratch work (gitignored)\n└── .claude/skills/                    # Slash-command skills\n```\n\n**Quick command:** Use `/wt <branch-name>` skill to create worktree automatically.\n\n## Worktree Workflow\n\n### Creating Worktrees\n\n**ALWAYS create worktrees in the `worktree/` subdirectory**, not at the repository root.\n\n```bash\ngit worktree add worktree/issue-42 -b issue-42\ngit worktree add worktree/feat-new-feature -b feat/new-feature\n```\n\n**Cleanup:** `git worktree remove worktree/<name>` or `git worktree prune` for stale references.\n\n### Skills\n\nAll workflow automation is implemented as skills in `.claude/skills/` and invoked with `/skill-name <args>`:\n\n| Skill | Command | Purpose |\n|-------|---------|---------|\n| **issue-analysis** | `/issue-analysis <number>` | Deep issue analysis — codebase exploration, implementation planning, architectural assessment. Posts structured comment and applies labels. |\n| **issue-to-pr-resolver** | `/issue-to-pr-resolver <number>` | End-to-end issue implementation: worktree creation → implementation with tests → draft PR → iterative CI/review resolution until merge-ready. |\n| **my-pr-checker** | `/my-pr-checker <number>` | Review and manage YOUR OWN PRs — check CI, resolve review threads, fix issues, iterate until all checks pass. |\n| **contrib-pr-review** | `/contrib-pr-review <number>` | Review external contributor PRs for safety, quality, and readiness. |\n| **contributors-update** | `/contributors-update` | Find merged PR authors missing from README and update the contributors list after approval. |\n| **wt** | `/wt <branch-name>` | Create git worktree in `worktree/` subdirectory with up-to-date master. |\n| **bat-adhoc** | `/bat-adhoc [scenario]` | Ad-hoc bot acceptance testing with dynamically generated scenarios. |\n| **bat-story-eval** | `/bat-story-eval --baseline v6.6.1` | Diff-based story evaluation: two-version comparison, regression detection. |\n\n## Project Overview\n\n**Home Assistant MCP Server** - A production MCP server enabling AI assistants to control Home Assistant smart homes. Provides tools for entity control, automations, device management, and more.\n\n- **Repo**: `homeassistant-ai/ha-mcp`\n- **Package**: `ha-mcp` on PyPI\n- **Python**: 3.13 only\n\n## Security\n\nSee [SECURITY.md](SECURITY.md) for the threat model, scope, and reporting\ninstructions. The threat model section documents the key design decisions that\ndefine what ha-mcp does and doesn't defend against (trusted MCP clients, local\nnetwork boundary, OAuth Bearer token design, single-tenant standard mode, HA\npermission scope).\n\n**Security advisories:** The API exposes an advisory's body but not its\ndiscussion thread, where the maintainer disposition (dismiss-vs-fix and the\nagreed fix scope) lives. Confirm the scope from that thread (GitHub UI or ask a\nmaintainer) before writing the fix.\n\n## External Documentation\n\nWhen implementing features or debugging, consult these resources:\n\n| Resource | URL | Use For |\n|----------|-----|---------|\n| **Home Assistant REST API** | https://developers.home-assistant.io/docs/api/rest | Entity states, services, config |\n| **Home Assistant WebSocket API** | https://developers.home-assistant.io/docs/api/websocket | Real-time events, subscriptions |\n| **HA Core Source** | `gh api /search/code -f q=\"... repo:home-assistant/core\"` | Undocumented APIs (don't clone) |\n| **HA Add-on Development** | https://developers.home-assistant.io/docs/add-ons | Add-on packaging, config.yaml |\n| **FastMCP Documentation** | https://gofastmcp.com/getting-started/welcome | MCP server framework |\n| **MCP Specification** | https://modelcontextprotocol.io/docs | Protocol details |\n\n## Issue & PR Management\n\n### Automated Code Review\n\n**Codex** reviews PRs automatically (`pr-codex-review-request.yml` /\n`pr-codex-review-delivery.yml`; posts as `chatgpt-codex-connector[bot]`).\nGemini Code Assist is retired — Google sunset its GitHub review activities and\nthe app now only posts sunset-notice banners, so `.gemini/config.yaml`\ndisables it fully. `.gemini/styleguide.md` remains the repo's review-criteria\ndocument (code quality, test coverage, security patterns, MCP conventions,\nsafety annotation accuracy): the `@codex review` request comment points Codex\nat it explicitly, and the Claude review skills below apply it.\n\n**CodeRabbit** (GitHub app, posts as `coderabbitai[bot]`) reviews drafts too —\n`.coderabbit.yaml` sets `reviews.auto_review.drafts: true`, since every PR here\nopens as a draft, and `auto_pause_after_reviewed_commits: 0` so it keeps\nreviewing every push instead of going quiet after five. That spends the\nper-developer hourly review allowance faster; a rate-limited push says so in a\ncomment and never blocks merge, and CodeRabbit's `rate limit` command reports\nwhether reviews are available without consuming one. It auto-detects `AGENTS.md` as review criteria;\n`.gemini/styleguide.md` is added through\n`knowledge_base.code_guidelines.filePatterns` (see the comment there). Repo YAML\noutranks the UI settings (only org/workspace Global Overrides beat it) and does\nnot merge with them — any key it omits falls back to CodeRabbit's schema\ndefaults, not to UI values. A change to `.coderabbit.yaml` never applies to the\nPR making it: on open-source repos CodeRabbit honours only the base branch's\nconfig, so the PR reports `Configuration used: defaults` and the change takes\neffect on merge.\n\n**Bot-authored PRs are excluded from automatic review by both tools** —\nDependabot, Renovate, and the `github-actions[bot]` webhook-proxy promote PRs\n(dev → stable copies whose content was already reviewed in their dev PRs).\nEnforced in `.coderabbit.yaml` `ignore_usernames` and the `pull_request_target`\nadmission list in `pr-codex-review-request.yml`, pinned to each other by\n`test_coderabbit_config.py`. A maintainer can still summon a review on a\npromote PR: `@coderabbitai review`, or for Codex a comment that is exactly\n`/review` (or `@ghhamcp review`) — the `issue_comment` admission list\ndeliberately omits `github-actions[bot]` to keep that lever.\n\n**Division of Labor:**\n- **Codex (automatic)**: Code quality, test coverage, generic security, MCP conventions\n- **CodeRabbit (automatic, drafts included)**: Line-level review against `AGENTS.md` and `.gemini/styleguide.md`, PR walkthrough and summary\n- **Claude `/contrib-pr-review` (on-demand)**: Repo-specific security (AGENTS.md, .github/, .claude/), detailed test analysis, PR size assessment, issue linkage\n- **Claude `/my-pr-checker` (lifecycle)**: Resolve threads, fix issues, monitor CI, create improvement PRs\n\n### Issue Labels\n\n**Triage-state labels** (applied during manual triage):\n\n| Label | Meaning |\n|-------|---------|\n| `ready-to-implement` | Clear path, no decisions needed |\n| `needs-choices` | Multiple approaches, needs stakeholder input |\n| `needs-info` | Awaiting clarification from reporter. `close-needs-info.yml` clocks from the label event: reminders on days 3/5/6, auto-close on day 7 without an author reply; an author reply removes the label |\n| `priority: high/medium/low` | Relative priority |\n| `triaged` | Automated triage complete (historical — applied by the retired `issue-triage.yml` bot) |\n| `triage-failed` | Automated triage failed (historical — applied by the retired `issue-triage.yml` bot) |\n| `issue-analyzed` | Deep Claude analysis complete |\n\n**Bug-class labels** (applied via `.github/ISSUE_TEMPLATE/` form selection, CodeRabbit auto-labeling, or manual triage):\n\n| Label | Meaning |\n|-------|---------|\n| `runtime-bug` | Bug occurring during normal operation (post-startup) |\n| `startup-bug` | Bug during startup, install, or connect |\n| `agent-behavior` | AI agent behavior or workflow feedback (tool selection, prompt drift, etc.) |\n\n**Scope labels** (manually applied during triage; orthogonal to bug-class — an issue can carry both `runtime-bug` AND a scope marker):\n\n| Label | Meaning |\n|-------|---------|\n| `addon` | Issue is specific to the Home Assistant Add-on deployment (`homeassistant-addon/`, Supervisor ingress) |\n| `docker` | Issue is specific to the Docker / containerized deployment (`Dockerfile`, container env) |\n| `javascript` | Issue concerns the project website / Astro app (TypeScript) under `site/` |\n\n**Lifecycle labels** (manually applied; do not double as close-reasons):\n\n| Label | Meaning |\n|-------|---------|\n| `wontfix` | Issue is valid but will not be addressed. Typically used when closing an issue to record the rejection rationale. |\n| `blocked` | Forward progress depends on an unresolved external item (upstream HA change, a sibling PR, a pending design decision). Recorded so a sweeper search can find what's waiting |\n\n**Tracking / automation labels** (applied by tooling):\n\n| Label | Meaning |\n|-------|---------|\n| `python-upgrade` | Auto-attached to every Renovate-managed PR (including non-Python dependency updates) via `renovate.json` global `labels` array. |\n\n### Issue Analysis Workflow\n\n- **Automated Triage (CodeRabbit)**: `issue_enrichment` in `.coderabbit.yaml`. On new and edited issues CodeRabbit posts an enrichment comment (possible duplicates, related issues and PRs, suggested assignees) and auto-applies labels per `labeling_instructions`. Plans are manual: comment `@coderabbitai plan` on an issue, or tick the Create Plan checkbox in the enrichment comment. (Replaces the retired GitHub Models `issue-triage.yml` bot.)\n- **Deep Analysis (Claude)**: When user says \"analyze issues\", list issues missing `issue-analyzed` label, then invoke `/issue-analysis <number>` for each sequentially (the skill drafts analysis for user approval before posting).\n\n```bash\ngh issue list --state open --json number,title,labels --jq '.[] | select(.labels | map(.name) | contains([\"issue-analyzed\"]) | not) | \"#\\(.number): \\(.title)\"'\n```\n\n### PR Review Comments\n\n**Always check for comments after pushing to a PR.** They come from bots\n(Codex, CodeRabbit, Copilot) or humans. Address human comments with highest\npriority; treat bot comments as suggestions to assess, not commands.\n\n**Reply, then resolve.** After addressing an inline comment, reply on its\nthread documenting the fix, then mark the thread resolved. When a review has\ninline comments, do both: reply per-thread *and* post one PR-level summary\ncomment. Leave a thread open only when the reply asks the reviewer for\nclarification. Unresolved threads block merge even after approval: the merge\nbutton stays disabled until every thread is resolved.\n\nThe `/my-pr-checker` skill carries the exact commands (the inline-reply\n`pulls/<PR>/comments/<id>/replies` endpoint, the PR-level review, and the\n`resolveReviewThread` GraphQL mutation, whose input field is `threadId`, not\n`pullRequestReviewThreadId`).\n\n## Git & PR Policies\n\n**CRITICAL - Never commit directly to master, except for documentation-only adjustments.**\n\nYou are STRICTLY PROHIBITED from committing to `master` or `main` branch. Always use worktrees for feature work:\n\n```bash\n# Use /wt skill or manually:\ngit worktree add worktree/<branch-name> -b <branch-name>\ncd worktree/<branch-name>\n```\n\n**Before any commit, verify:**\n1. Current branch: `git rev-parse --abbrev-ref HEAD` (must NOT be master/main)\n2. In worktree: `pwd` (must be in `worktree/` subdirectory)\n\n**Never push or create PRs without user permission.**\n\n**Always create PRs as draft.** Use `gh pr create --draft`. Only mark a PR as ready for review (`gh pr ready <PR>`) when explicitly requested by the user. **Before marking ready, update the PR description** to reflect all changes made since the PR was created.\n\n### PR Workflow\n\n**After creating or updating a PR, always follow this workflow:**\n\n1. **Update tests if needed**\n2. **Commit and push**\n3. **Wait for CI** (~3 min for tests to start and complete):\n   ```bash\n   sleep 180\n   ```\n4. **Check CI status**:\n   ```bash\n   gh pr checks <PR>\n   ```\n5. **Check for review comments** (see \"PR Review Comments\" section above)\n6. **Fix any failures**:\n   ```bash\n   # View failed run logs\n   gh run view <run-id> --log-failed\n\n   # Or find the run ID from PR\n   gh pr checks <PR> --json | jq '.[] | select(.conclusion == \"failure\") | .detailsUrl'\n   ```\n7. **Address review comments** if any (prioritize human comments)\n8. **Update PR description** if the scope changed (only when PR is already marked as ready)\n9. **Repeat steps 2-8 until:**\n   - ✅ All CI checks green\n   - ✅ All comments addressed\n   - ✅ PR ready for merge\n\n### PR Execution Philosophy\n\n**Work autonomously during PR implementation:**\n- Don't ask the user about every small choice or decision during implementation\n- Make reasonable technical decisions based on codebase patterns and best practices\n- Fix unrelated test failures encountered during CI (even if time-consuming)\n- Document choices for final summary\n\n**Making implementation choices:**\n- **DO NOT** choose based on what's faster to implement\n- **DO** consider long-term codebase health - refactoring that benefits maintainability is valid\n- **For non-obvious choices with consequences**: Create 2 mutually exclusive PRs (one for each approach) and let user choose\n- **For obvious choices**: Implement and document in final summary\n\n**When you notice an improvement during a PR**: fix it in place by default. See [Boy Scout Rule — Handling Discovered Improvements](#boy-scout-rule--handling-discovered-improvements) below for the deferral scale.\n\n**Final reporting:** Once the PR is ready, post an Implementation Summary comment on the PR (choices made, problems encountered) and give the user a short summary.\n\n### Boy Scout Rule — Handling Discovered Improvements\n\n**IMPORTANT — Default is fix-in-place.** \"Boy Scout Rule\" means leave touched code better than you found it. \"Improve incrementally\" means commit-by-commit within *this* PR — not across follow-up PRs. Deferral is the exception, not the default. Weigh fix-in-place sweeps against regression risk: if a sweep would meaningfully expand the diff or change the review surface, treat it as Mid-sized and ask the user.\n\n**Never open a follow-up PR or issue without explicit user approval.**\n\nWhen you notice something while working on a PR, apply this scale:\n\n| What you find | Action |\n|---|---|\n| **Small** — a few lines, clearly in scope (see examples below) | **Fix in this PR** as a separate commit. No mention in PR description. |\n| **Mid-sized** — meaningful effort, worth doing but out of scope (e.g. adding a new helper module that doesn't exist yet, a gap that needs non-trivial new test scaffolding, a code-quality issue that's *not* really low) | **Pause before pushing.** Ask the user whether to bundle. |\n| **Large / unrelated** — many files, design decisions, different subsystem (e.g. would double the diff size or change the review surface, code quality is *really* low / technical debt) | Mention in PR description only if the user confirms. Open a separate issue **only if** the user asks AND you can state a concrete benefit in one sentence. |\n\n**\"Small\" examples — fix these inline, no mention needed:**\n\n- Typo, dead import, misnamed local\n- Stale docstring/comment or stale reference\n- 1–N line cleanup of code in this diff\n- Multi-site sweep of the same pattern you can grep for\n- Missing test for code you're touching (add the test without refactoring the surrounding code)\n- Low coverage for the area you're working in\n- Straightforward test-quality fix (better assertions, clearer names, removing duplication)\n- \"Mirror X parity onto Y\" where Y is in the diff\n- Migrating a singular→list or similar shape-consistency fix\n- Drift between docs and live state you can fix by reading both\n\n**When to ask the user about bundling.** ~200 lines is a *should-I-ask* heuristic, not a bundling cap. Under ~200 lines: bundle without asking. Over ~200 lines: ask the user whether to bundle — but **bundling at any size is fine if the work is not grossly out of scope**. The 200-line mark exists so the user hears about large bundled changes before they land, not to push large work out of the PR. Estimate honestly; do not inflate to manufacture a reason to defer.\n\n**Anti-noise gate — before filing any follow-up issue or PR, all three must be true:**\n\n1. The work is genuinely too large to bundle (i.e. truly out of scope, not just over the ~200-line ask-heuristic above). **All three sub-tests must pass:**\n   (a) It cannot be done by mirroring an existing sibling pattern in the same file or a closely-related file.\n   (b) You can name the actual design choice in one sentence with two named alternatives, **OR** the work is a genuinely large mechanical migration (e.g. *\"replace `requests` with `httpx` across 40 sites\"*) that exceeds this PR's scope by size alone.\n   (c) It would meaningfully change this PR's review surface, not just add to it.\n2. You can name a concrete end-user-facing or maintainer benefit in one sentence.\n3. A maintainer reading the issue 6 months later would act on it, not close as stale.\n\nIf any are false: fix it now, or let it go. **Do not file an issue to \"track\" it.**\n\n**Scope is the user's call, not yours.** Before deferring anything, explicitly ask with a specific reason: *\"I think this is out of scope because [X]. Fix here or defer?\"* — do not silently drop it.\n\nThe following phrases are red flags that you're making a scope decision unilaterally (list is non-exhaustive — match on intent, not exact string): \"post-merge follow-up\", \"follow-up consideration\", \"forward-looking note\", \"nice to have\", \"Happy to file an issue\", \"out of scope for this PR\", \"not blocking this PR\", \"pre-existing — not touching it\" (pre-existing is not a reason to skip; addressing pre-existing things is the point of this rule), \"real design work, not N lines\", \"worth tracking as a follow-up issue\".\n\n**Code-review bot suggestions** (Codex, CodeRabbit, Copilot non-blocking nits): apply inline or dismiss. Never spawn a follow-up issue from a bot suggestion unless the user explicitly confirms it's a large, out-of-scope change. See `.gemini/styleguide.md` § *Non-Blocking Suggestions and Scope* for the bot-side rule.\n\n### Urgent Release Process\n\nCritical fixes follow the normal development flow: branch from `master`, merge\nthe fix to `master`, then manually dispatch `semver-release.yml` from `master`.\nUse its `force` input only when a release is required without a releasable\n`feat`, `fix`, `perf`, `refactor`, breaking `!`, or `BREAKING CHANGE` commit\nsince the previous stable tag.\n\n### Test Coverage Requirements\n\n**When tests ARE required:**\n- New MCP tools in `src/ha_mcp/tools/` without any E2E tests\n- Tools that previously had NO tests — add E2E tests even if not part of current PR\n- Core functionality changes in `client/`, `server.py`, or `errors.py` without coverage\n- Bug fixes — use TDD: write the failing regression test first, then fix the code so the test passes\n\n**When tests may NOT be required:**\n- Refactoring with existing comprehensive test coverage\n- Documentation-only changes (`*.md` files)\n- Minor parameter additions to well-tested tools\n- Internal utilities already covered by E2E tests\n\n**When to open an issue instead:** See § *Boy Scout Rule — Handling Discovered Improvements* for the gate. Never open without explicit user approval.\n\n## CI/CD Workflows\n\n| Workflow | Trigger | Purpose |\n|----------|---------|---------|\n| `pr.yml` | PR opened | Lint, type check |\n| `e2e-tests.yml` | PR to master | Full E2E tests (~3 min) |\n| `publish-dev.yml` | Push to master | Dev release `.devN` |\n| `notify-dev-channel.yml` | Push to master (src/) | Comment on PRs/issues with dev testing instructions |\n| `semver-release.yml` | Biweekly Wed 10:00 UTC or manual dispatch | Stable release (cuts version tag + GitHub release) |\n| `release-publish.yml` | After SemVer Release (`workflow_run`) or manual dispatch | Publish stable Docker image (`:latest` + `:stable` + semver) + MCP registry |\n| `build-binary.yml` | Release | Linux/macOS/Windows binaries |\n| `addon-publish.yml` | Release | HA add-on update |\n| `sync-tool-docs.yml` | Push to master (`src/ha_mcp/tools/`, `scripts/extract_tools.py`) | Regenerate `tools.json`, README, DOCS.md |\n| `locale-sync.yml` | Daily schedule + manual dispatch | Machine-translate stale/missing strings post-merge and push them straight to master |\n\n**Docker image tags** (`ghcr.io/homeassistant-ai/ha-mcp`): stable releases push `:latest` + `:stable` + semver tags (`release-publish.yml`); dev builds push only `:dev` + `:dev-<sha>` (`publish-dev.yml`) — **never `:latest`**, which is reserved for stable. The HA add-on images live in separate repos (`-addon-{arch}`, `-addon-dev-{arch}`) and are selected by an explicit `version:` pin, not by `:latest`.\n\n## Development Commands\n\n### Setup\n```bash\nuv sync --group dev        # Install with dev dependencies\nuv run ha-mcp              # Run MCP server (stdio; needs interactive stdin)\nuv run ha-mcp-web          # Run HTTP server; web settings UI at http://localhost:8086/mcp/settings (see src/ha_mcp/settings_ui/AGENTS.md)\ncp .env.example .env       # Configure HA connection\n```\n\n### Testing\nE2E tests are in `tests/src/e2e/` (not `tests/e2e/`). Tests use **testcontainers** to spin up\nan isolated Docker HA instance — Docker daemon must be running.\n\n```bash\n# Run FULL E2E suite (required before claiming all tests pass)\n# -n2 is optimal locally (each worker spins up its own HA container;\n# more workers add memory pressure without proportional speedup).\n# CI uses -n3 tuned for 2-vCPU GitHub runners with 15GB RAM.\ncd tests && uv run pytest src/e2e/ -n2 --dist loadscope -v --tb=short\n\n# Run specific file (partial coverage only — never substitute for full suite)\ncd tests && uv run pytest src/e2e/workflows/automation/test_lifecycle.py -v\n\n# Interactive test environment\nuv run hamcp-test-env                    # Interactive mode\nuv run hamcp-test-env --no-interactive   # For automation\n```\n\n**CRITICAL RULES:**\n- Always run from the `tests/` directory so pytest picks up the correct `conftest.py`\n- Always run the **full suite** before declaring tests pass\n- `tests/.env.test` contains placeholder values only; testcontainers sets the real URL dynamically\n- Never set `HOMEASSISTANT_URL` manually in your shell before running tests\n- **Always run relevant e2e tests after making changes**, without waiting to be asked. Identify the relevant test file(s) for the area you changed and run them. Do not assume Docker is unavailable or prerequisites are missing — just run them and let pytest report what is skipped and why.\n\nTest token centralized in `tests/test_constants.py`.\n\n### Code Quality\n\nC901 (mccabe complexity ≤10) is enforced repo-wide with zero per-file exemptions (issue #925 cleared the grandfathered list) — never reintroduce a `[\"C901\"]` per-file-ignore; extract helpers instead.\n\n```bash\nuv run ruff check src/ tests/ --fix\n# Note: --fix removes unused imports from non-__init__ modules (lefthook runs it on commit with\n# stage_fixed). When adding an import, include its first use in the same change or it gets stripped.\nuv run mypy src/\n```\n\n### Docker\n```bash\n# Stdio mode (Claude Desktop) — local-only, no network exposure\ndocker run --rm -i \\\n  -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\\n  ghcr.io/homeassistant-ai/ha-mcp:latest\n\n# HTTP mode (loopback only, same-host LLM client)\n# Connect URL: http://127.0.0.1:8086/mcp  (default MCP_SECRET_PATH)\ndocker run -d -p 127.0.0.1:8086:8086 \\\n  -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\\n  ghcr.io/homeassistant-ai/ha-mcp:latest ha-mcp-web\n\n# HTTP mode (LAN-reachable) — generate the secret first so you can configure the MCP client with it\nMCP_SECRET=\"/private_$(python3 -c 'import secrets; print(secrets.token_urlsafe(16))')\"\necho \"MCP_SECRET_PATH=$MCP_SECRET\"\ndocker run -d -p 8086:8086 \\\n  -e HOMEASSISTANT_URL=... -e HOMEASSISTANT_TOKEN=... \\\n  -e MCP_SECRET_PATH=\"$MCP_SECRET\" \\\n  ghcr.io/homeassistant-ai/ha-mcp:latest ha-mcp-web\n```\n\nSee [SECURITY.md](SECURITY.md) for authentication and network binding details.\n\n## Architecture\n\n```\nsrc/ha_mcp/\n├── server.py          # Main server with FastMCP\n├── __main__.py        # Entrypoint (CLI handlers)\n├── config.py          # Pydantic settings management\n├── errors.py          # 38 structured error codes\n├── client/\n│   ├── rest_client.py       # HTTP REST API client\n│   ├── websocket_client.py  # Real-time state monitoring\n│   └── websocket_listener.py\n├── auth/\n│   ├── provider.py          # OAuth provider (HTTP mode)\n│   └── consent_form.py      # OAuth consent screen\n├── tools/             # 36 modules, auto-discovered\n│   ├── registry.py          # Lazy auto-discovery\n│   ├── smart_search/        # Fuzzy entity search\n│   ├── device_control.py    # WebSocket-verified control\n│   ├── best_practice_checker.py # Reactive HA config validator (warns + embeds skill content)\n│   ├── tools_*.py           # Domain-specific tools\n│   └── util_helpers.py      # Shared utilities\n├── utils/\n│   ├── fuzzy_search.py      # textdistance-based matching\n│   ├── domain_handlers.py   # HA domain logic\n│   ├── operation_manager.py # Async operation tracking\n│   ├── skill_loader.py      # Skills-vendor file loader (used by ha_get_skill_guide and write tools)\n│   ├── usage_logger.py      # Per-tool usage telemetry\n│   ├── data_paths.py        # Canonical data directory paths\n│   ├── python_sandbox.py    # Sandboxed Python-expression eval for python_transform on config tools\n│   ├── kill_signal_diagnostics.py # Kill-signal (SIGTERM/SIGINT/SIGHUP) shutdown diagnostics\n│   └── config_hash.py       # Shared optimistic-locking hash (automation/script/scene/dashboard/energy)\n└── resources/\n    ├── card_types.json\n    └── dashboard_guide.md\n```\n\n### Key Patterns\n\n**Tools Registry**: Auto-discovers `tools_*.py` modules with `register_*_tools()` functions. No changes needed when adding new modules.\n\n**Lazy Initialization**: Server, client, and tools created on-demand for fast startup.\n\n**Service Layer**: Business logic in `smart_search/`, `device_control.py` separate from tool modules.\n\n**WebSocket Verification**: Device operations verified via real-time state changes.\n\n**Tool Completion Semantics**: Tools should wait for operations to complete before returning, with optional `wait` parameter for control.\n\n## Writing MCP Tools\n\n### Naming Convention\n`ha_<verb>_<noun>`:\n- `get` — single item (`ha_get_state`)\n- `list` — collections (`ha_list_services`)\n- `search` — filtered queries (`ha_search`)\n- `set` — create/update (`ha_config_set_helper`)\n- `delete` — delete dashboards, config entries, or files (`ha_config_delete_dashboard`, `ha_delete_file`)\n- `remove` — remove registry items (`ha_remove_entity`, `ha_remove_area_or_floor`)\n- `call` — execute (`ha_call_service`, `ha_call_event`)\n- `manage` — multi-modal tools combining several operations behind one interface (`ha_manage_addon`)\n\n**Namespace prefixes**: An optional `<namespace>_` prefix between `ha_` and the verb is allowed for grouped tool families that share a domain. The full shape becomes `ha_<namespace>_<verb>_<noun>`:\n- `ha_config_<verb>_<noun>` — config-management tools (`ha_config_set_helper`, `ha_config_set_automation`, `ha_config_remove_automation`, `ha_config_delete_dashboard`)\n- `ha_dev_<verb>_<noun>` — developer-mode tools (`ha_dev_manage_server`, `ha_dev_manage_settings`); registered only when the `enable_dev_mode` setting is on (Developer section at the bottom of the web settings UI's Server Settings tab)\n\n**Accepted exceptions**: A small set of tools name a single, distinct operation where forcing a `<verb>_<noun>` shape would read worse than the natural name. These are accepted as-is and should not be flagged:\n- `ha_restart`, `ha_reload_core`, `ha_eval_template`\n- `ha_report_issue`, `ha_import_blueprint`\n- `ha_read_file`, `ha_write_file`, `ha_bulk_control`\n\n**Adding new verbs**: When no existing verb fits a new tool's purpose, add the verb to the approved-verbs list above rather than forcing a poor fit. `.gemini/styleguide.md` points back to this section as the single source of truth, so updates here propagate automatically.\n\n### Tool Structure\nCreate `tools_<domain>.py` in `src/ha_mcp/tools/`. Registry auto-discovers it.\n\n```python\nfrom fastmcp.tools import tool\nfrom .helpers import log_tool_usage, register_tool_methods\n\nclass DomainTools:\n    def __init__(self, client):\n        self._client = client\n\n    @tool(name=\"ha_<verb>_<noun>\", tags={\"Category Name\"}, annotations={\"readOnlyHint\": True, \"idempotentHint\": True})\n    @log_tool_usage\n    async def ha_<verb>_<noun>(self, param: str) -> dict[str, Any]:\n        \"\"\"<Action verb> <what this tool does -- one sentence>.\n\n        <Optional: second sentence for key behavioral distinction or modes>\n        \"\"\"\n        # Add to the docstring above only when genuinely needed:\n        # RELATED TOOLS: ha_next(): why to call this after (workflow-entry tools only)\n        # EXAMPLES: ha_<verb>_<noun>(\"realistic_value\")  -- non-obvious call patterns only\n        # When NOT to use: route to preferred alternatives\n        # Caveats: destructive side-effects, non-obvious gotchas\n        # For complex schemas: use ha_get_skill_guide\n\ndef register_<domain>_tools(mcp, client, **kwargs):\n    register_tool_methods(mcp, DomainTools(client))\n```\n\n`@tool` (from `fastmcp.tools`) attaches metadata to the method. `@tool` must be the outermost decorator (above `@log_tool_usage`) so that `__fastmcp__` is present on the final method object. `register_tool_methods()` auto-discovers all `@tool`-decorated methods and calls `mcp.add_tool()` for each. The registry discovers `register_*_tools` functions by convention.\n\n### Tool Docstrings\n\nThe single-line template is the default -- extend it only where it genuinely helps.\n\n**Required for every tool:**\n- Starts with an action verb (`Get`, `List`, `Search`, `Create`, `Update`, `Delete`, `Remove`, `Execute`, `Call`, `Manage`)\n- One sentence describing what the tool does (not how)\n\n**Add `RELATED TOOLS` when** the tool is a workflow entry point and the natural next step is not obvious.\nExample: `ha_search` hints at `ha_get_state`.\n\n**Add `EXAMPLES` when** the tool has multiple modes or non-obvious parameters.\nOmit when a single required parameter makes the call self-evident.\n\n**For multi-line docstrings, follow this structure** (based on\n[Anthropic's tool design guidance](https://www.anthropic.com/engineering/writing-tools-for-agents)):\n1. What the tool does (required first sentence, action verb)\n2. When NOT to use it — name the preferred alternatives\n3. When to use it — valid use cases\n4. Caveats — consequences, post-actions, destructive side-effects\n\nConsequence statements are plain prose: \"This permanently deletes the dashboard.\nA backup is created before every edit.\" Route safety concerns through `annotations`\n(`destructiveHint`, `idempotentHint`, `readOnlyHint`), not docstring keywords.\n\n**Defer complex schemas** instead of embedding them:\n`# For complex schemas: use ha_get_skill_guide`\n\n**What NOT to include:** full parameter documentation, type descriptions already in the\nsignature, HA domain internals the model already knows, or motivational prose.\n\n\n### Tool Tags\n\nEvery tool needs `tags={\"Category Name\"}` (native FastMCP parameter). Drives the README table, `site/src/data/tools.json`, and `homeassistant-addon/DOCS.md`. These are auto-regenerated on merge by `sync-tool-docs.yml` — no manual regeneration needed. For local testing: `python scripts/extract_tools.py`\n\n### Safety Annotations\n| Annotation | Default | Use For |\n|------------|---------|--------|\n| `readOnlyHint: True` | `False` | Tool does not modify its environment |\n| `destructiveHint: True` | `True` | Tool may perform destructive updates (only meaningful when `readOnlyHint` is false). Set to `False` for non-destructive writes (e.g., creating a record) |\n| `idempotentHint: True` | `False` | Repeated calls with same args have no additional effect (only meaningful when `readOnlyHint` is false) |\n| `openWorldHint: True` | `True` | Tool reaches an external, third-party-authored world (HACS store, add-on repositories, GitHub release feeds, arbitrary import URLs). Set to `False` when the tool's domain is the local Home Assistant instance. A tool is also open-world if its output carries externally-authored content back to the client, even when a local integration (HACS, Supervisor, HA Core) makes the actual network call on its behalf — `ha_get_overview` and `ha_get_system_health` embed the update-check field that reaches PyPI / the Supervisor store, while `ha_get_blueprint` and `ha_config_list_dashboard_resources` return externally-authored content from purely local reads. Required on every tool — the default is `true`, so an omitted value silently marks a local tool as open-world |\n\n**Version baseline:** annotations describe a tool's behavior against current\nupstream versions of any external engine or component it drives; a side effect\nthat exists only in outdated external builds does not demote the tool to\nwrite-classified — document the update requirement in the tool's docs instead\n(e.g. the screenshot engine's old `settheme` write, #1991).\n\n### Error Handling\n\n**Always use the dedicated error functions** from `errors.py` and `helpers.py`. Never construct raw error dicts manually — the helpers ensure consistent structure, error codes, and suggestions across all tools.\n\n**All tool-level failures must raise `ToolError`** (sets `isError=true` per MCP spec). Batch item failures within result arrays are the only exception — those return structured dicts without raising.\n\n**Pattern A — Exception blocks** (most common): call `exception_to_structured_error` without `return` — it raises `ToolError` by default:\n```python\nfrom .helpers import exception_to_structured_error, raise_tool_error\nfrom fastmcp.exceptions import ToolError\n\ntry:\n    # ... tool logic ...\nexcept ToolError:\n    raise  # must re-raise; prevents ToolError being swallowed by outer except\nexcept Exception as e:\n    exception_to_structured_error(\n        e,\n        context={\"entity_id\": entity_id},\n        suggestions=[\"Verify entity exists\", \"Check HA connection\"],\n    )\n```\n\nThe `except ToolError: raise` guard is required whenever `raise_tool_error()` or validation errors are called inside the same `try` block — without it, `except Exception` catches the `ToolError` and re-maps it to `INTERNAL_ERROR`.\n\n**Pattern B — Input validation errors**: use `raise_tool_error(create_error_response(ErrorCode.VALIDATION_INVALID_PARAMETER, message, context={...}, suggestions=[...]))`.\n\n**Pattern C — Service call failures**: check `result.get(\"success\")` and raise with `ErrorCode.SERVICE_CALL_FAILED` using `result.get(\"error\", \"Operation failed\")` as the message.\n\n**Pattern D — Batch item failures** (items inside a results list — do NOT raise):\n```python\nresults.append(create_error_response(\n    ErrorCode.SERVICE_CALL_FAILED,\n    str(e),\n    context={\"entity_id\": eid},\n))\n```\n\nOnly use `raise_error=False` on `exception_to_structured_error` when you need to mutate the dict before raising. Never add `add_timezone_metadata` to errors.\n\n`exception_to_structured_error` auto-classifies 404s, auth errors, timeouts by exception type. Pass `context={\"entity_id\": ...}` for automatic `ENTITY_NOT_FOUND` on 404s. Available helpers: `create_entity_not_found_error`, `create_connection_error`, `create_auth_error`, `create_service_error`, `create_validation_error`, `create_config_error`, `create_timeout_error`, `create_error_response`.\n\n### Return Values\n```python\n{\"success\": True, \"data\": result}                     # Success\n{\"success\": True, \"data\": result, \"warnings\": [...]}  # Degraded (top-level list[str], omit when empty)\nraise ToolError(json.dumps({...}))                    # Tool-level failure (isError=true)\n{\"success\": False, \"error\": {...}}                    # Batch item failure only (in results list)\n```\n\n`warnings` is always a top-level `list[str]`, never nested inside `data` and never a singular `\"warning\": \"...\"` string. See `tools_config_helpers.py::HelperResponse` / `_helper_response` for the canonical shape and `tests/src/unit/test_helper_response_shape.py` for the contract assertions.\n\n### Tool Consolidation\nWhen a tool's functionality is fully covered by another tool, **remove** the redundant tool rather than deprecating it. Fewer tools reduces cognitive load for AI agents and improves decision-making. Do not add deprecation notices or shims — just delete the tool and update any docstring references to point to the replacement.\n\nThis project's tool count exceeds the [10-20 tool threshold](https://ai.google.dev/gemini-api/docs/function-calling) where selection accuracy degrades. Reducing count is a priority — combine frequently chained operations into one tool and ensure each tool has a clear, distinct purpose. See [Anthropic's tool design blog](https://www.anthropic.com/engineering/writing-tools-for-agents) for guidance.\n\n| Pattern | Example | Guideline |\n|---------|---------|-----------|\n| Tool A is a strict subset of Tool B | `ha_dashboard_find_card` fully covered by `ha_config_get_dashboard` | Consolidate (remove A) |\n| Frequently chained operations | Multi-step workflows combined into one tool | Consolidate — reduces round-trips |\n\n**Breaking changes**: only removing functionality with no alternative requires a major bump. Consolidation and renaming are not breaking.\n\n**Context engineering**: provide minimum context; let models fetch more via `ha_get_skill_guide`. Favor statelessness and content-derived hashes for optimistic locking.\n\n### Module Size\n\nKeep modules focused. Past ~1000 lines (Pylint's `max-module-lines` default) a module usually spans multiple concerns and is worth splitting along those concerns. Pick whatever decomposition fits and fix the internal imports (and any test patch targets) that reference the moved code as part of the move. There's no external import contract to preserve: it's one project, and MCP tools are resolved dynamically at runtime by name (and renaming isn't breaking either, see Tool Consolidation).\n\n## Tool Waiting Behavior\n\n**Principle**: MCP tools should wait for operations to complete before returning, not just acknowledge API success.\n\nTools have an optional `wait` parameter (default `True`) that polls for completion. Use `wait=False` for bulk operations, then batch-verify. Categories:\n- **Config ops** (automations, helpers, scripts): Wait by default (poll until entity queryable/removed)\n- **Service calls** (lights, switches): Wait for state change on state-changing services (turn_on, turn_off, toggle, etc.)\n- **Async ops** (automation triggers, external integrations): Return immediately (not state-changing)\n- **Query ops** (get_state, search): Return immediately (no `wait` parameter)\n\n**Shared utilities** in `src/ha_mcp/tools/util_helpers.py`:\n- `wait_for_entity_registered(client, entity_id)` — polls until entity accessible via state API\n- `wait_for_entity_removed(client, entity_id)` — polls until entity no longer accessible\n- `wait_for_state_change(client, entity_id, expected_state)` — polls until state changes\n\n## Custom Component\n\nThe `custom_components/ha_mcp_tools/` integration ships separately from the\n`ha-mcp` server package (it reaches the HA instance via HACS), so CI cannot\nfully validate a component change before merge.\n\n- **Version bumps ride the stable release cycle — do not bump per PR or per\n  push.** The component version (`manifest.json` `version` + `COMPONENT_VERSION`\n  in `const.py`, kept in lockstep by the parity test) should lead the last\n  **stable** release by exactly one pending version, so everything merged since\n  the last stable cut ships together under one number on the next stable\n  release. Check the pending state with `git show\n  stable:custom_components/ha_mcp_tools/const.py | grep COMPONENT_VERSION` vs\n  master, then:\n  - **Level with stable** (no pending version yet): bump once — patch by\n    default — to open the pending version.\n  - **Already ahead of stable** (a pending version exists): do **not** bump;\n    your change rides under the existing pending version.\n  - Raise the pending version further only to **escalate the bump level** — e.g.\n    the pending version is a patch but your change warrants a minor — and then\n    go straight to that minor, not an extra patch. Never go past the current\n    pending version otherwise; per-revision bumps skip never-shipped numbers and\n    desync the version from the release cycle.\n  - CI enforces the level-with-stable case twice: the PR-level **Component\n    Version Gate** fails a component change whose manifest version does not\n    strictly lead the mirror's released stable (equal = bump to open the\n    pending version; behind = a stale tree or bad merge resurrected an old\n    version), and the mirror sync's stable tag step fails loud\n    when an already-tagged version's component content has drifted (changes\n    merged onto a shipped version would otherwise strand with no installable\n    release — the gap is a PR opened while a version is pending that merges\n    only after that version goes stable, which re-runs no PR checks).\n- **When the change adds a service or argument the server depends on**, this PR\n  must **open a fresh pending component version** (bump `manifest.json` +\n  `COMPONENT_VERSION`) and raise `MIN_COMPONENT_VERSION` in\n  `src/ha_mcp/tools/tools_filesystem.py` to that same new version. This is the\n  one case that **overrides** the \"already ahead of stable → do not bump\" rule\n  above: bump here even if a pending version already exists. `get_caller_token`\n  reports the manifest version and the server gates on it, so without the gate\n  the old and new component are indistinguishable: a caller on the old version\n  passes the check and then hits raw \"service not found\" errors instead of an\n  actionable \"update\" prompt. **Never floor at a version any build lacking the\n  behaviour also reports** – an already-shipped version, or a pending version\n  that was opened *before* this behaviour landed. Such a build passes the gate\n  yet lacks the behaviour, defeating the gate (#1946: the floor was set to a\n  1.1.0 that had already shipped without the gated behaviours, so 1.1.0 builds\n  split into with/without and the gate could not tell them apart).\n- **Keep the component backward-compatible with the released server.** The\n  component (HACS) and the server (add-on / PyPI / Docker) follow the same\n  release cycle but are updated independently per install, so a new component\n  can run against an *older* server. Never remove or tighten an existing service\n  schema (e.g. dropping a param from a strict `vol.Schema`) without a shim the\n  prior server still satisfies; the version gate can't protect this direction\n  (the old server is the caller). Remove the shim once the matching\n  `MIN_COMPONENT_VERSION` server is the floor.\n- **Live-test on the dev server immediately after merge**, before the next\n  stable cut. The component path cannot be fully exercised by CI pre-merge.\n\n## Translations\n\n**One canonical store, generated projections, automated retranslation**\n(issue #2083). The settings UI catalogs\n(`src/ha_mcp/settings_ui/locales/<code>.json`) are the canonical store for\nevery string except the component's config flow: the add-on option strings\nlive there under `addon.<key>.*` (plus `features.<key>.*` for options the\nsettings UI also shows, and `addon_stable.<key>.*` for a stable-flavor\nwording deviation). Both add-on flavors' `translations/*.yaml` and the\n`FEATURE_META` block in `settings.js` are **generated** from that store by\n`scripts/generate_locales.py` — never edit them by hand;\n`test_derived_catalogs_match_the_canonical_store` fails until you regenerate.\nEach flavor's key list is its own `config.yaml` `schema:`, so the two YAMLs\nare different projections of the one store, and cross-surface wording\nidentity holds by construction.\n\nA language ships on all four surfaces or not at all —\n`tests/src/unit/test_locale_parity.py` enforces it. The same Home Assistant\nlanguage code (`cs`, `de`, `eo`, `es`, `fr`, `it`, `nl`, `pl`, `ru`, `sv`, `zh-Hans`) names every file:\n`src/ha_mcp/settings_ui/locales/<code>.json`,\n`custom_components/ha_mcp_tools/translations/<code>.json`, and\n`homeassistant-addon{,-dev}/translations/<code>.yaml`.\nThat list of codes is itself pinned by\n`test_agents_md_lists_every_shipped_locale`: adding a language means adding its\ncode here, in the same PR, or the suite goes red. To add a language, add the\ntwo authored catalogs (settings UI + component), regenerate, and let the\ntranslation pipeline below fill the strings. The component catalog may start\nempty; the settings one may not start `meta`-only, because four ungated checks\nread the shipped catalogs themselves: every decided `Decision` outcome and\nevery `PredicateOp` operator needs a translated word\n(`policies.pending.decision.*`, `policies.operators.*` — a value that still\nspells the backend literal counts as untranslated), so does\n`policies.pending.already_decided`, the sentence those words are interpolated\ninto, and at least one translated key must have English that addresses the\nreader in the second person, which is where `scripts/translate_locales.py`\nreads the catalog's address register.\n`policies.operators.exists_long` is the trap in that list: the condition editor\nrenders it as its own dropdown label, but it is UI-only rather than a\n`PredicateOp` member, so no enum-derived check asks for it and a catalog\nwithout it reads English there until the sync fills it. Each surface reads that\nregister from its own catalog, so a component catalog left at a key or two\nrests on whichever of them addresses the reader — losing it costs the engine\nthe register for every later string of that language and says so only on\nstderr, which is why\n`test_every_shipped_component_catalog_gets_reader_addressing_samples` pins it.\nAuthor two such keys rather than one: a run that rewords one of them queues it,\nand queued keys are dropped from the sample candidates, so a surface resting on\na single anchor is anchorless in precisely the run that rewrites it —\n`test_component_samples_survive_their_own_anchor_being_queued` pins that.\n`src/ha_mcp/settings_ui/locales/README.md` names the tests — including the one\nthat skips locally until `tests/js/` has its npm dependencies.\n\nSettings UI catalogs are auto-discovered (no registration). Their `messages` may\nomit keys — English is the per-key fallback — but may not carry one `en.json`\nlacks: nothing renders it. `tool_groups` and `tools` may do neither: each locale\nmust carry exactly the renderable group headings and every tool name, no key\nmore and none fewer. The check derives the tool set from\nthe sources (`scripts/extract_tools.py`), not from the committed\n`site/src/data/tools.json` — the check must not depend on a generated\nartifact that a separate post-merge workflow keeps current. Separately from\nthose key rules, both authored surfaces cap how much *text* a catalog\nmay leave byte-identical to English or omit outright, so a stub cannot ride the\nfallbacks: 5% for the settings UI `messages`, its `tools` titles and\ndescriptions, and each generated add-on projection (per flavor, computed from\nthe canonical store), and 15% for the component catalogs,\nwhich carry the product names as keys of their own. On top of that share, a\n`tools` entry whose title *and* description are *both* byte-identical to English\nfails by name however small its share — for feature-gated tools against either\nEnglish rendering, the `FEATURE_GATED_TOOLS` stub or the parsed docstring.\nComponent catalogs need every `strings.json` key with identical\n`{placeholders}`.\n\n**Changing an English string is a one-place edit** (`en.json` `messages`, a\ntool docstring, or `strings.json` + component `en.json`), and the machine\ntranslates the rest: `scripts/translate_locales.py` reads the English-source\nbaseline diff (`tests/src/unit/locale_source_baseline.json`), retranslates\nthe changed or missing keys in every language via the Gemini API\n(`GEMINI_API_KEY`; free tier), validates placeholders and markup, regenerates\nthe derived catalogs, and repins the baseline. The `locale-sync.yml` workflow\nruns it AFTER merge, on a daily schedule, and pushes the result straight to\nmaster with the release App credential (the same pattern as the version-bump\nbots and `sync-tool-docs.yml`) — so any PR, fork or same-repo, merges\nwithout owing translations, and one sync run picks up everything merged\nsince the last one. The checks that police translated content (missing or\norphaned keys, staleness against the baseline, cross-surface shared wording,\nthe untranslated-share ceilings, filled tool sections) are gated behind\n`LOCALE_COMPLETENESS_CHECKS=1` and run in that workflow, not in PR CI —\n`test_locale_sync_gate_shape.py` pins the wiring. What a PR still owes is\ndeterministic and engine-free: regenerate the derived catalogs\n(`python scripts/generate_locales.py`) when a canonical English string\nchanges, and placeholder parity on component keys whose English is current.\nTo choose the wording yourself, translate in your own PR **and run\n`python scripts/update_locale_baseline.py` in it** — the repinned baseline\nis what tells the next sync your wording already covers the changed English\n(hand-edits win); without the repin the sync retranslates the key and\noverwrites you. Run `scripts/translate_locales.py` locally instead to\nmachine-fill in-PR or to use a different engine (it repins for you).\nThe baseline pins the English each translation was written against, because\nkey parity cannot see a string whose meaning changed: #1993 flipped a policy\nstring from ALL-match to ANY-match and left the Chinese text asserting the\nopposite. `python scripts/update_locale_baseline.py` repins it manually after\na hand-translation pass.\n\n**A catalog that lands after a reword is never queued for the keys it missed.**\nThe pin moves when the English does, so a language whose PR was open across\nthat reword merges with the older wording already translated, and the sync sees\na hash that matches: no plan, no correction, indefinitely. Nothing else catches\nit either — key parity sees a value and the share ceiling sees a translated\none. When a locale PR spans an English change, diff the affected keys against\nthat surface's own English before merging — `en.json` for `messages`, the tool\ndefinitions for `tools` (`en.json` ships that section empty), and\n`custom_components/ha_mcp_tools/strings.json` for the component catalog.\nNumbers and code-ish literals are the cheap tell, since a reword usually moves\none, and `test_translations_keep_english_numbers_and_identifiers` checks\nexactly that across all three. Repinning is not the repair — it\nwrites the same hash and queues nothing. Deleting the stale value is: the\nplanner treats a missing key as work for that locale alone.\n\n**A tool docstring is one of those English strings.** `en.json` ships `tools`\nempty, so the English a `tools` entry translates is read from the tool\ndefinition in `src/ha_mcp/tools/` — the `title=` kwarg and the summary\nparagraph of the docstring, or the `FEATURE_GATED_TOOLS` stub where a gated\ntool shows one instead. Editing that summary moves the English out from under\nsix catalogs; the pipeline retranslates them. One deliberate exception: a\nchange to a feature-gated tool's PARSED docstring (its stub unchanged) is\nstub-review work, not translation work — the pipeline holds that baseline key\nstale, and the locale-sync run stays red until a human confirms the stub\nstill describes the tool and runs `python scripts/update_locale_baseline.py`.\n\n**Rate limits and outages degrade loudly, never silently.** Engine calls are\npaced under the free-tier request rate and retry transient errors (429/5xx,\ntimeouts) with backoff; a request that keeps failing marks its strings failed\nand the run continues, and two consecutive dead batches stop the run early\ninstead of burning the remaining quota. A partial run — a daily-quota hit,\nan outage — still commits every finished translation plus\n`tests/src/unit/locale_sync_progress.json`, which the next run reads to\nresume where it stopped: **re-running the workflow — or just waiting for the\nnext day's cron — is the entire recovery procedure.** Only a fully\nsuccessful run repins the baseline and deletes the progress file, so the\nsync runs stay red until every string is translated and nothing\nunvalidated ever ships. **The fallback when the engine is down is a human**:\nanyone can hand-translate the strings the dry-run\nlists, run `python scripts/generate_locales.py` and\n`python scripts/update_locale_baseline.py`, and open an ordinary PR — the\nnext sync run no-ops (it also cleans up any committed progress file).\nHand-edits always win; the machine only ever touches strings whose English\nchanged. The engine itself is one function (`_call_gemini`) with\n`GEMINI_API_URL` / `GEMINI_MODEL` / `GEMINI_API_KEY` overrides for any\nGemini-compatible endpoint, so replacing the provider stays a one-function\nchange.\n\nThe Webhook Proxy add-on and its bundled integration stay **English-only by\ndecision** — not worth the upkeep. The test records that, so any other new\ncatalog directory fails until it is either translated everywhere or listed as\nEnglish-only alongside them.\n\n## Home Assistant Add-on\n\n**Required files:**\n- `repository.yaml` (root) - For HA add-on store recognition\n- `homeassistant-addon/config.yaml` - Must match `pyproject.toml` version\n\n**Two add-on flavors:** `homeassistant-addon/` (stable, slug `ha_mcp`) and\n`homeassistant-addon-dev/` (dev channel, slug `ha_mcp_dev`) are *separate*\nadd-ons with *separate* `config.yaml` files.\n\n**Functional config is NOT auto-synced between them.** The release pipeline\nonly syncs the *version* (the `update-addon-config` job) and the *changelog*\n(the `Copy changelog to addon directory` step in `semver-release.yml`) into\n`homeassistant-addon/`. Functional keys — `ingress`, `ports`,\n`host_network`, `options`/`schema`, etc. — must be edited **by hand** in each\nflavor. When you add a non-beta capability to the dev add-on that should also\nship on stable (e.g. `ingress` for the web Settings UI / \"Open Web UI\" button),\nmirror it into `homeassistant-addon/config.yaml` **in the same PR**. Assuming\n\"the release pipeline handles it\" is what kept `ingress` off the stable add-on.\nBeta-only keys are the deliberate exception — see the NOTE in\n`homeassistant-addon/config.yaml` and `docs/beta.md`.\n\n### Webhook Proxy add-on: dev-first, promote-only\n\n**Any work on the Webhook Proxy add-on must start by reading\n[`homeassistant-addon-webhook-proxy/AGENTS.md`](homeassistant-addon-webhook-proxy/AGENTS.md)**\n— it owns the full flow (flavors, versioning guard, promotion, testing).\nThe short version: `homeassistant-addon-webhook-proxy/` (stable) is never\nedited directly by a PR in regular operation; every change (code *and* docs)\nlands on `homeassistant-addon-webhook-proxy-dev/` with a version bump, and\nstable is updated only via the manual promote workflow.\n\n**Docs**: https://developers.home-assistant.io/docs/add-ons\n\n## API Research\n\nSearch HA Core without cloning (500MB+ repo):\n```bash\n# Search for patterns\ngh search code \"use_blueprint\" --repo home-assistant/core path:tests --json path --limit 10\n\n# Fetch file contents (base64 encoded)\ngh api /repos/home-assistant/core/contents/homeassistant/components/automation/config.py \\\n  --jq '.content' | base64 -d > /tmp/ha_config.py\n```\n\n## Release Process\n\nUses [semantic-release](https://python-semantic-release.readthedocs.io/) with conventional commits.\n\n| Prefix | Bump | Changelog |\n|--------|------|-----------|\n| `fix:`, `perf:`, `refactor:` | Patch | User-facing |\n| `feat:` | Minor | User-facing |\n| `feat!:` or `BREAKING CHANGE:` | Major | User-facing |\n| `chore:`, `ci:`, `test:` | No release | Internal |\n| `docs:` | No release | User-facing |\n| `*:(internal)` | Same as type | Internal |\n\n**Use `(internal)` scope** for changes that aren't user-facing:\n```bash\nfeat(internal): Log package version on startup  # Internal, not in user changelog\nfeat: Add dark mode                             # User-facing\n```\n\n| Channel | When Updated |\n|---------|--------------|\n| Dev (`.devN`) | Every master commit |\n| Stable | Biweekly (Wednesday 10:00 UTC) |\n\nManual release: Actions > SemVer Release > Run workflow.\n","category":"root","tokens":14290}]}