diff --git a/README.md b/README.md index 48647bd2..96974b77 100644 --- a/README.md +++ b/README.md @@ -136,12 +136,12 @@ Don't start from scratch — grab a template: | [Basic](templates/basic-template) | First-timers | Speak → Listen → Respond → Exit | | [API](templates/api-template) | API integrations | Speak → Call API → Speak result → Exit | | [Loop](templates/loop-template) | Interactive apps | Loop with listen → process → respond → exit command | -| [Openclaw](templates/openclaw-template) | OpenClaw integrations | OpenClaw-based ability scaffold | -| [OpenHome Local](templates/OpenHome-local) | Local development | Run & test abilities locally | +| [Openclaw](templates/OpenClaw) | OpenClaw integrations | OpenClaw-based ability scaffold | +| [OpenHome Local](templates/Local) | Local development | Run & test abilities locally | | [ReadWriteFile](templates/ReadWriteFile) | File operations | Read from / write to files on device | | [SendEmail](templates/SendEmail) | Email notifications | Compose & send emails programmatically | -| [Alarm](templates/Alarm) | Timers & alarms | Watcher mode: continuous monitoring loop | -| [Watcher](templates/Watcher) | Background monitoring | Auto-start → Monitor → Act → Sleep → Repeat (endless) | +| [Alarm](templates/Alarm) | Timers & alarms | Background mode: continuous monitoring loop | +| [Background](templates/Background) | Background monitoring | Auto-start → Monitor → Act → Sleep → Repeat (endless) | --- diff --git a/community/enphase-solar-monitor/README.md b/community/enphase-solar-monitor/README.md new file mode 100644 index 00000000..f7e70b84 --- /dev/null +++ b/community/enphase-solar-monitor/README.md @@ -0,0 +1,183 @@ +# Enphase Solar Monitor + +Voice-activated solar dashboard for Enphase systems (IQ Gateway with microinverters). + +## Demo Mode + +**This ability is built and tested in DEMO MODE** since we don't have a real Enphase system. With `DEMO_MODE = True` (default in `main.py`): + +- No credentials or prefs file required +- Returns realistic fake data: 4.2 kW production, 73% battery charging, 3.1 kW consumption +- Lets you test the full voice flow in OpenHome without Enphase hardware + +**To use a real system:** Set `DEMO_MODE = False` in `main.py` and follow the setup instructions below. + +## What It Does + +Ask "how's my solar?" to get real-time production, consumption, and battery status. Data is delivered as natural spoken responses. + +## Trigger Words + +- "solar" +- "how's my solar" +- "solar status" +- "solar production" +- "battery level" +- "battery status" +- "am I exporting" +- "grid status" +- "solar today" +- "enphase" +- "solar panels" + +--- + +## How to Get Real Enphase API Data + +Follow these steps to connect the ability to your actual Enphase solar system. + +### Prerequisites + +- Enphase solar system with IQ Gateway (microinverters) +- Enphase account with your system linked (see [MyEnphase](https://my.enphase.com)) +- If your account shows "not associated with any systems," contact your installer or use [Ownership Transfer](https://enphase.com/ownership-transfer) if you bought a property with an existing system + +--- + +### Step 1: Create an Enphase Developer Account + +1. Go to [developer-v4.enphase.com/signup](https://developer-v4.enphase.com/signup) +2. Fill in your details and sign up +3. Check your email and activate your account +4. Log in to the [Enphase Developer Portal](https://developer-v4.enphase.com) + +--- + +### Step 2: Create an Application + +1. Go to [Applications](https://developer-v4.enphase.com/admin/applications) +2. Click **Create Application** +3. Fill in: + - **Name:** e.g. "OpenHome Solar Monitor" + - **Description:** e.g. "Voice-activated solar monitoring for OpenHome" + - **Plan:** Select **Watt** (free, 1,000 requests/month) + - **Access Control:** Check **System Details**, **Site Level Production Monitoring**, **Site Level Consumption Monitoring** +4. Click **Create Application** +5. Copy and save these values from your application page: + - **API Key** + - **Client ID** + - **Client Secret** + - **Authorization URL** (or note the Client ID to build it) + +--- + +### Step 3: Get Your System ID + +Your system must be linked to your Enphase account. + +**Option A: Enlighten Web** +1. Go to [enlighten.enphaseenergy.com](https://enlighten.enphaseenergy.com) +2. Log in and open your system +3. Check the browser URL: `https://enlighten.enphaseenergy.com/systems/1234567/...` +4. The number after `/systems/` is your **system_id** (e.g. `1234567`) + +**Option B: Enphase Mobile App** +1. Open the Enphase Enlighten app +2. Go to **Settings** or **System** +3. Find **System ID** or **System details** + +--- + +### Step 4: OAuth 2.0 Authorization (Get access_token and refresh_token) + +You must authorize your app to access your system data. The system owner (you) must complete this flow. + +#### 4a. Build the Authorization URL + +Use this format (replace `YOUR_CLIENT_ID` with your Client ID): + +``` +https://api.enphaseenergy.com/oauth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https://api.enphaseenergy.com/oauth/redirect_uri +``` + +#### 4b. Open the URL in Your Browser + +1. Paste the authorization URL into your browser +2. Log in with your Enphase (Enlighten) credentials +3. Approve access when prompted +4. You will be redirected to a page with a **code** in the URL +5. Copy the `code` value (everything after `code=`) + +#### 4c. Exchange the Code for Tokens + +Send a POST request to Enphase to exchange the authorization code for `access_token` and `refresh_token`. See the Enphase API docs for the exact request format. + +--- + +### Step 5: Configure the Preferences File + +1. Copy `enphase_solar_prefs.json.example` to `enphase_solar_prefs.json` in this ability folder +2. Fill in all values (system_id, api_key, client_id, client_secret, access_token, refresh_token, has_battery, has_consumption) +3. With OpenHome File Storage API, prefs are stored in user-level storage + +--- + +### Step 6: Switch to Real Mode + +1. Open `main.py` +2. Set `DEMO_MODE = False` at the top +3. Upload the ability to OpenHome + +--- + +### Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Your account is not associated with any systems" | Contact your installer to link your system, or use Ownership Transfer | +| "Token refresh failed: 401" | Re-run the OAuth flow (Step 4) to get new tokens | +| "I can't find that system ID" | Verify system_id in Enlighten URL or app | +| "I've hit the API limit" | Free tier = 1,000 requests/month; wait or upgrade plan | + +### Ability Not Activating? + +1. **Add ability to your Personality** – In OpenHome, ensure this ability is added/enabled +2. **Check trigger words** – Verify trigger words in the Abilities Dashboard +3. **Re-upload** – Re-upload the ability zip and ensure it's enabled +4. **Try exact phrases** – Say "How's my solar?" or "Solar status" clearly + +--- + +## OpenHome Compatibility + +This ability is built for the OpenHome sandbox: + +- **No `open()` or `os`** – Uses OpenHome File Storage API (`check_if_file_exists`, `read_file`, `write_file`) for preferences +- **Hardcoded config** – `unique_name` and `matching_hotwords` are hardcoded from `config.json` (file access forbidden at registration time) +- **Persistent storage** – Preferences are stored with `temp=False` (user-level storage) + +--- + +## Technical Details + +- **API:** Enphase Cloud API v4 +- **Auth:** OAuth 2.0 with auto-refresh on 401 +- **Rate Limit:** 1,000 requests/month (free Watt plan) +- **Caching:** 15-minute TTL + +--- + +## Example + +> **User:** "How's my solar?" +> +> **Response:** "You're producing 4.2 kilowatts right now, as of about 15 minutes ago. Today you've generated 28 kilowatt hours. Your battery is at 73 percent and charging. You're sending 1.5 kilowatts to the grid." + +## Supported Queries + +- **Solar snapshot:** "How's my solar?", "Solar status" +- **Battery:** "Battery level", "Battery status" +- **Consumption:** "How much am I using?" +- **Grid:** "Am I exporting?", "Grid status" +- **Today:** "Solar today", "Today's production" +- **Health:** "System health", "Panel status" diff --git a/community/enphase-solar-monitor/__init__.py b/community/enphase-solar-monitor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/enphase-solar-monitor/main.py b/community/enphase-solar-monitor/main.py new file mode 100644 index 00000000..9ddf83a5 --- /dev/null +++ b/community/enphase-solar-monitor/main.py @@ -0,0 +1,453 @@ +""" +Enphase Solar Monitor - OpenHome Ability +Voice-activated solar dashboard for Enphase systems (IQ Gateway with microinverters). +Fetches production, consumption, and battery data from Enphase Cloud API v4. +""" + +import json +import time +from datetime import datetime, timezone +from typing import Callable, Optional + +import requests +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# Demo mode - set to False when you have real Enphase credentials +DEMO_MODE = True + +ENPHASE_BASE_URL = "https://api.enphaseenergy.com/api/v4" +ENPHASE_TOKEN_URL = "https://api.enphaseenergy.com/oauth/token" +CACHE_TTL = 900 # 15 minutes +EXIT_WORDS = ["stop", "quit", "exit", "done", "cancel"] +PREFS_FILE = "enphase_solar_prefs.json" + +# Hardcoded from config.json - file access forbidden at registration time +UNIQUE_NAME = "enphase_solar_monitor" +MATCHING_HOTWORDS = [ + "solar", "solar status", "solar production", "how's my solar", + "hows my solar", "how is my solar", "enphase", "solar panels", + "battery level", "battery status", "my battery status", "battery", + "am I exporting", "grid status", "solar today", "check my solar", "solar power", +] + +ERROR_RESPONSES = { + "no_system_id": "You haven't set up your Enphase system yet.", + "auth_failed": "Your Enphase authorization has expired. You'll need to re-authorize.", + "rate_limited": "I've hit the API limit. Try again later, or check the Enphase app.", + "system_not_found": "I can't find that system ID. Check your preferences file.", + "timeout": "I can't reach the Enphase cloud right now. Check your internet.", +} + + +class EnphaseSolarMonitorCapability(MatchingCapability): + """Voice-activated Enphase solar monitoring capability.""" + + # {{register_capability}} + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + @classmethod + def register_capability(cls) -> "MatchingCapability": + return cls( + unique_name=UNIQUE_NAME, + matching_hotwords=MATCHING_HOTWORDS, + ) + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run_solar_monitor()) + + async def _load_prefs(self) -> dict: + """Load preferences using OpenHome File Storage API.""" + try: + if await self.capability_worker.check_if_file_exists(PREFS_FILE, False): + raw = await self.capability_worker.read_file(PREFS_FILE, False) + prefs = json.loads(raw) + if DEMO_MODE and not prefs.get("system_id"): + prefs.setdefault("has_battery", True) + prefs.setdefault("has_consumption", True) + return prefs + except Exception as e: + self.worker.editor_logging_handler.error(f"Failed to load prefs: {e}") + if DEMO_MODE: + return {"has_battery": True, "has_consumption": True} + return {} + + async def _save_prefs(self, prefs: dict) -> None: + """Save preferences using OpenHome File Storage API.""" + try: + await self.capability_worker.write_file( + PREFS_FILE, json.dumps(prefs, indent=2), False, mode="w" + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"Failed to save prefs: {e}") + + async def _refresh_access_token(self) -> bool: + """Refresh the Enphase OAuth access token using refresh token.""" + try: + prefs = await self._load_prefs() + response = requests.post( + ENPHASE_TOKEN_URL, + data={ + "grant_type": "refresh_token", + "refresh_token": prefs.get("refresh_token", ""), + "client_id": prefs.get("client_id", ""), + "client_secret": prefs.get("client_secret", ""), + }, + timeout=15, + ) + if response.status_code == 200: + tokens = response.json() + prefs["access_token"] = tokens["access_token"] + if "refresh_token" in tokens: + prefs["refresh_token"] = tokens["refresh_token"] + await self._save_prefs(prefs) + self.worker.editor_logging_handler.info("Enphase token refreshed") + return True + return False + except Exception as e: + self.worker.editor_logging_handler.error(f"Token refresh failed: {e}") + return False + + async def _api_call( + self, endpoint: str, extra_params: Optional[dict] = None + ) -> dict: + """Make an Enphase API call. Returns dict with data or {"error": "error_type"}.""" + if DEMO_MODE: + return self._demo_response(endpoint) + try: + prefs = await self._load_prefs() + system_id = prefs.get("system_id") + if not system_id: + return {"error": "no_system_id"} + access_token = prefs.get("access_token") + if not access_token: + return {"error": "auth_failed"} + url = f"{ENPHASE_BASE_URL}/systems/{system_id}/{endpoint}" + params = {"key": prefs.get("api_key", ""), "access_token": access_token} + if extra_params: + params.update(extra_params) + response = requests.get(url, params=params, timeout=15) + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + if await self._refresh_access_token(): + return await self._api_call(endpoint, extra_params) + return {"error": "auth_failed"} + elif response.status_code == 404: + return {"error": "system_not_found"} + elif response.status_code == 429: + return {"error": "rate_limited"} + else: + self.worker.editor_logging_handler.error( + f"Enphase API {endpoint}: {response.status_code}" + ) + return {"error": f"http_{response.status_code}"} + except requests.exceptions.Timeout: + return {"error": "timeout"} + except Exception as e: + self.worker.editor_logging_handler.error(f"API call error: {e}") + return {"error": str(e)} + + def _demo_response(self, endpoint: str) -> dict: + """Return realistic fake data for demo mode.""" + now = datetime.now(timezone.utc) + ts = now.strftime("%Y-%m-%dT%H:%M:%S+00:00") + if "stats" in endpoint or "summary" in endpoint: + return { + "intervals": [ + { + "end_at": ts, + "powr": 4200, + "enwh": 28000, + } + ], + "meta": {"status": "normal"}, + } + if "battery" in endpoint or "storage" in endpoint: + return { + "intervals": [ + { + "end_at": ts, + "powr": -1500, + "percent_full": 73, + } + ], + } + if "consumption" in endpoint: + return { + "intervals": [ + { + "end_at": ts, + "enwh": 3100, + "powr": 3100, + } + ], + } + if "grid" in endpoint or "net" in endpoint: + return { + "intervals": [ + { + "end_at": ts, + "powr": -1500, + } + ], + } + return {"intervals": [], "meta": {"status": "normal"}} + + async def _get_cached_or_fetch( + self, cache_key: str, fetch_function: Callable + ) -> dict: + """Check cache first, fetch if expired. Cache TTL: 15 minutes.""" + try: + prefs = await self._load_prefs() + cache = prefs.get("cache", {}) + if cache_key in cache: + cached_data = cache[cache_key] + timestamp = cached_data.get("timestamp", 0) + if time.time() - timestamp < CACHE_TTL: + self.worker.editor_logging_handler.info(f"Cache hit: {cache_key}") + return cached_data.get("data", {}) + self.worker.editor_logging_handler.info(f"Cache miss: {cache_key}") + data = await fetch_function() + if not isinstance(data, dict) or "error" not in data: + cache[cache_key] = {"timestamp": time.time(), "data": data} + prefs["cache"] = cache + await self._save_prefs(prefs) + return data + except Exception as e: + self.worker.editor_logging_handler.error(f"Cache error: {e}") + return await fetch_function() + + def _format_power(self, watts: Optional[float]) -> str: + if watts is None: + return "unknown" + kilowatts = watts / 1000.0 + if kilowatts < 0.1: + return f"{round(watts)} watts" + return f"{round(kilowatts, 1)} kilowatts" + + def _format_energy(self, watt_hours: Optional[float]) -> str: + if watt_hours is None: + return "unknown" + kwh = watt_hours / 1000.0 + return f"{round(kwh, 1)} kilowatt hours" + + def _format_battery(self, percentage: Optional[float]) -> str: + if percentage is None: + return "unknown" + return f"{round(percentage)} percent" + + def _is_exit_word(self, text: Optional[str]) -> bool: + if not text: + return False + return any(word in text.lower() for word in EXIT_WORDS) + + def _classify_intent(self, user_input: str) -> str: + system_prompt = """Classify the user's solar system query into ONE of these intents: +- solar_snapshot: General status, "how's my solar", overall view +- battery_status: Battery level, charging status +- consumption: How much am I using, consumption +- grid_status: Am I exporting, grid status +- today_summary: Today's totals +- system_health: System health, panel status +- unknown: Anything else + +Respond with ONLY the intent name, nothing else.""" + intent = self.capability_worker.text_to_text_response( + prompt_text=f"User query: {user_input}", + system_prompt=system_prompt, + history=[], + ) + return (intent or "unknown").strip().lower() + + def _speak_error(self, error_key: str) -> str: + return ERROR_RESPONSES.get(error_key, "Something went wrong. Try again later.") + + async def run_solar_monitor(self) -> None: + try: + await self.capability_worker.speak("Sure! Let me check your solar system.") + await self._handle_solar_snapshot() + while True: + await self.capability_worker.speak("Anything else about your solar?") + response = await self.capability_worker.user_response() + if not response or self._is_exit_word(response): + await self.capability_worker.speak("Okay, talk to you later!") + break + intent = self._classify_intent(response) + if intent == "solar_snapshot": + await self._handle_solar_snapshot() + elif intent == "battery_status": + await self._handle_battery_status() + elif intent == "consumption": + await self._handle_consumption() + elif intent == "grid_status": + await self._handle_grid_status() + elif intent == "today_summary": + await self._handle_today_summary() + elif intent == "system_health": + await self._handle_system_health() + else: + await self.capability_worker.speak( + "I didn't catch that. You can ask about production, " + "battery, consumption, or grid status." + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"Solar monitor error: {e}") + await self.capability_worker.speak( + "Something went wrong. Try again later." + ) + finally: + self.capability_worker.resume_normal_flow() + + async def _handle_solar_snapshot(self) -> None: + async def fetch(): + return await self._api_call("stats?granularity=day&start_at=2020-01-01") + + stats = await self._get_cached_or_fetch("stats", fetch) + if isinstance(stats, dict) and "error" in stats: + await self.capability_worker.speak( + self._speak_error(stats["error"]) + ) + return + intervals = stats.get("intervals", []) + power = 0 + energy_today = 0 + if intervals: + last = intervals[-1] + power = last.get("powr", 0) + energy_today = last.get("enwh", 0) + power_str = self._format_power(power) + energy_str = self._format_energy(energy_today) + await self.capability_worker.speak( + f"You're producing {power_str} right now, as of about 15 minutes ago." + ) + await self.capability_worker.speak( + f"Today you've generated {energy_str}." + ) + prefs = await self._load_prefs() + if prefs.get("has_battery"): + await self._handle_battery_status() + if prefs.get("has_consumption"): + await self._handle_consumption() + await self._handle_grid_status() + + async def _handle_battery_status(self) -> None: + prefs = await self._load_prefs() + if not prefs.get("has_battery"): + await self.capability_worker.speak( + "Your system doesn't have a battery configured." + ) + return + + async def fetch(): + return await self._api_call("stats?granularity=day&start_at=2020-01-01") + + battery = await self._get_cached_or_fetch("battery", fetch) + if isinstance(battery, dict) and "error" in battery: + await self.capability_worker.speak( + self._speak_error(battery["error"]) + ) + return + intervals = battery.get("intervals", []) + if not intervals: + await self.capability_worker.speak("No battery data available.") + return + last = intervals[-1] + soc = last.get("percent_full", 0) + power = last.get("powr", 0) + soc_str = self._format_battery(soc) + if power < -50: + status = "and charging." + elif power > 50: + status = "and discharging." + else: + status = "and idle." + await self.capability_worker.speak( + f"Your battery is at {soc_str} {status}" + ) + + async def _handle_consumption(self) -> None: + prefs = await self._load_prefs() + if not prefs.get("has_consumption"): + await self.capability_worker.speak( + "Your system doesn't have consumption monitoring." + ) + return + + async def fetch(): + return await self._api_call("stats?granularity=day&start_at=2020-01-01") + + consumption = await self._get_cached_or_fetch("consumption", fetch) + if isinstance(consumption, dict) and "error" in consumption: + await self.capability_worker.speak( + self._speak_error(consumption["error"]) + ) + return + intervals = consumption.get("intervals", []) + power = intervals[-1].get("powr", 0) if intervals else 0 + power_str = self._format_power(power) + await self.capability_worker.speak( + f"You're using {power_str} right now." + ) + + async def _handle_grid_status(self) -> None: + prefs = await self._load_prefs() + if not prefs.get("has_consumption"): + return + + async def fetch(): + return await self._api_call("stats?granularity=day&start_at=2020-01-01") + + grid = await self._get_cached_or_fetch("grid", fetch) + if isinstance(grid, dict) and "error" in grid: + return + intervals = grid.get("intervals", []) + power = intervals[-1].get("powr", 0) if intervals else 0 + power_str = self._format_power(abs(power)) + if power < -100: + await self.capability_worker.speak( + f"You're sending {power_str} to the grid." + ) + elif power > 100: + await self.capability_worker.speak( + f"You're drawing {power_str} from the grid." + ) + else: + await self.capability_worker.speak("You're roughly net zero with the grid.") + + async def _handle_today_summary(self) -> None: + async def fetch(): + return await self._api_call("stats?granularity=day&start_at=2020-01-01") + + stats = await self._get_cached_or_fetch("stats", fetch) + if isinstance(stats, dict) and "error" in stats: + await self.capability_worker.speak( + self._speak_error(stats["error"]) + ) + return + intervals = stats.get("intervals", []) + energy_today = intervals[-1].get("enwh", 0) if intervals else 0 + energy_str = self._format_energy(energy_today) + await self.capability_worker.speak( + f"Today so far you've generated {energy_str}." + ) + + async def _handle_system_health(self) -> None: + async def fetch(): + return await self._api_call("stats?granularity=day&start_at=2020-01-01") + + stats = await self._get_cached_or_fetch("stats", fetch) + if isinstance(stats, dict) and "error" in stats: + await self.capability_worker.speak( + self._speak_error(stats["error"]) + ) + return + meta = stats.get("meta", {}) + status = meta.get("status", "normal") + await self.capability_worker.speak( + f"Your system health is {status}." + ) diff --git a/docs/Designing_OpenHome_Abilities.md b/docs/Designing_OpenHome_Abilities.md index 1f8a111a..1b73239a 100644 --- a/docs/Designing_OpenHome_Abilities.md +++ b/docs/Designing_OpenHome_Abilities.md @@ -241,7 +241,111 @@ When your ability fires, the user was mid-conversation. Read that history to cla ## 6. The Ability Lifecycle -### How It Actually Works +### Ability Categories + +When creating an ability in the OpenHome dashboard, you select a **Category** that tells the platform how the ability should behave: + +| Category | Behavior | +|---|---| +| **Skill** | Standard ability where the user directly interacts with it in normal conversation. Triggered by hotwords, runs a flow, exits. This is the original ability pattern. | +| **Brain Skill** | The Personality's brain decides to trigger it in the background. Used when the brain can't fully respond to a user's question and needs more information, or when the brain needs to delegate an action. Examples: fetching weather for a location, running smart home actions. | +| **Background Daemon** | Background thread that starts automatically when the call begins and runs continuously for the entire session. Used for monitoring, polling, alarms, note-taking, and ambient intelligence. Works even when the Personality is in sleep mode. | +| **Local** | High-level Python packages written to run directly on Raspberry Pi hardware, allowing many restricted modules since they execute on the device itself. *Under development — not yet released.* | + +> **Note:** Brain Skills templates are still being finalized. Brain Skills are triggered automatically by the Personality's brain when it needs to fill a knowledge gap or delegate an action the user requested. + +### Ability File Structure + +Regardless of which category you select in the dashboard, every ability is built from one or two files: + +| Type | Files | Description | +|---|---|---| +| **Standard Interactive** | `main.py` only | User triggers with hotwords, runs, exits with `resume_normal_flow()`. The original pattern. | +| **Standalone Background Daemon** | `background.py` only | Starts automatically on session start. Runs in background for monitoring, logging, note-taking. Works even when Personality is in sleep mode. | +| **Interactive Combined** | `main.py` + `background.py` | Interactive handles user requests. Background daemon runs alongside. They coordinate through shared file storage. | + +**Example — Interactive Combined (Alarm Ability):** +``` +AlarmAbility/ +├── main.py # Interactive — set an alarm +├── background.py # Background — fire the alarm +├── config.json # Required +└── alarm.mp3 # Supporting files +``` + +> ⚠️ The background file **must** be named exactly `background.py`. No other filename will be detected by the platform. + +### Critical Differences: main.py vs background.py + +These are the most common sources of bugs when writing background daemons. Pay close attention. + +| Aspect | `main.py` | `background.py` | +|---|---|---| +| `call()` signature | `call(self, worker)` | `call(self, worker, background_daemon_mode)` | +| `CapabilityWorker` init | `CapabilityWorker(self)` | `CapabilityWorker(self)` | +| Triggered by | User hotwords | Automatically on session start | +| Lifecycle | Runs once, then exits | Continuous `while True` loop | +| `resume_normal_flow()` | **REQUIRED** on every exit path | **NOT needed** (independent thread) | +| Works in sleep mode | No — requires active session | **Yes** — runs even when Personality is asleep | +| Multiple instances | One at a time | Multiple daemons supported | + +### New SDK Methods + +| Method | Returns | Async | Description | +|---|---|---|---| +| `get_timezone()` | `str` | No | User's timezone (e.g. `"America/Chicago"`). Use for alarms, calendars, time-aware logic. | +| `get_full_message_history()` | `list` | No | Full conversation transcript. Background daemons use this to monitor the live conversation. | +| `send_interrupt_signal()` | — | Yes | Stops current Personality output. Call before `speak()` or `play_audio()` from a background daemon. | + +```python +# Get user timezone (synchronous) +tz = self.capability_worker.get_timezone() + +# Get conversation history (synchronous) +history = self.capability_worker.get_full_message_history() + +# Interrupt before speaking from a background daemon (async) +await self.capability_worker.send_interrupt_signal() +await self.capability_worker.speak("Your alarm is going off!") +``` + +### background Code Template + +Copy this as your starting point for any `background.py`. Note the `call()` signature has an extra `background_daemon_mode` parameter, but the `CapabilityWorker` constructor is the same as `main.py`. + +```python +import json +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker +from time import time + +class YourCapabilityBackground(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + background_daemon_mode: bool = False + + #{{register capability}} + + async def background_loop(self): + self.worker.editor_logging_handler.info( + "%s: background started" % time() + ) + while True: + # --- your background logic here --- + self.worker.editor_logging_handler.info( + "%s: background cycle" % time() + ) + await self.worker.session_tasks.sleep(20.0) + + def call(self, worker: AgentWorker, background_daemon_mode: bool): + self.worker = worker + self.background_daemon_mode = background_daemon_mode + self.capability_worker = CapabilityWorker(self) + self.worker.session_tasks.create(self.background_loop()) +``` + +### How the Main Flow Works 1. User is in **Main Flow** having a normal conversation 2. User says something matching a trigger word @@ -268,15 +372,6 @@ When your ability fires, the user was mid-conversation. Read that history to cla Classify at trigger time — the user's phrasing tells you which experience they expect. -### The Four Ability Modes - -| Mode | Type | Trigger | Behavior | Examples | -|---|---|---|---|---| -| **Interactive** | Skill | User voice trigger | Takes over conversation, hands back when done | Weather, calendar, recipe walkthrough | -| **Autonomous** | Brain Skill | Brain-triggered | No user initiation. System decides when to fire. | Proactive weather alert, smart reminder | -| **Smart** | Brain Skill | Brain-triggered | Works silently, surfaces questions only when needed | Email draft needing approval, purchase confirmation | -| **Watcher** | Background Daemon | Always running | Continuous. No user input ever. Monitors everything. | Meeting notetaker, life logger, alarm system | - ### Background Ability Patterns **The Life Logger Pattern** *(Background Daemon)* @@ -287,7 +382,7 @@ Classify at trigger time — the user's phrasing tells you which experience they - Dashboard updates in real-time — user can see it thinking **The Ambient Profiler Pattern** *(Background Daemon)* -- Watcher ability silently builds and updates `user.md` every day +- background ability silently builds and updates `user.md` every day - `user.md` appended to Main Flow personality prompt - Main Flow always knows what's happening in your life without you telling it - Speaker diarization enables per-speaker memory in multi-person households @@ -298,6 +393,35 @@ Classify at trigger time — the user's phrasing tells you which experience they - `"Set an alarm for 7 AM"` → Skill writes alarm time, Background Daemon polls and fires - Same for: reminders, daily briefings, scheduled check-ins, recurring reports +**Coordination Pattern: main.py + background.py** + +The primary way the interactive and background components communicate is through shared persistent file storage. Both files read and write to the same user-scoped files. + +| Step | Component | Action | +|---|---|---| +| 1 | User | Says *"set an alarm for 3pm Thursday"* | +| 2 | `main.py` | LLM parses time, writes alarm to `alarms.json` | +| 3 | `main.py` | Confirms to user, calls `resume_normal_flow()` | +| 4 | `background.py` | Polls `alarms.json` every ~15 seconds (running since session start) | +| 5 | `background.py` | Target time hits → `send_interrupt_signal()` | +| 6 | `background.py` | Plays `alarm.mp3`, speaks notification | +| 7 | `background.py` | Updates alarm status to `"triggered"` in `alarms.json` | + +**Sample `alarms.json`:** +```json +[ + { + "id": "alarm_1772046000778", + "created_at_epoch": 1772046000, + "timezone": "America/Los_Angeles", + "target_iso": "2026-02-26T00:06:00-08:00", + "human_time": "12:01 AM on Thursday, Feb 26, 2026", + "source_text": "Can you set an alarm for me?", + "status": "scheduled" + } +] +``` + ### The ability.md Pattern Every Brain Skill ships with an `ability.md` file containing YAML frontmatter (`name` + `description`) and markdown instructions. The `description` field is the **only** field the system reads to decide when to trigger. @@ -313,6 +437,18 @@ description: > > **Bad description = never triggers or triggers incorrectly.** This is the single most important field for Brain Skill abilities. +### Templates and Resources + +| Resource | Location | +|---|---| +| Alarm Ability (Interactive Combined) | https://github.com/openhome-dev/abilities/tree/dev/templates/Alarm | +| Standalone Background Daemon | https://github.com/openhome-dev/abilities/tree/dev/templates/Background | +| SDK Reference (updated) | `OpenHome_SDK_Reference` in project docs | +| Building Great Abilities (updated) | `Building_Great_OpenHome_Abilities` in project docs | +| Questions / Support | `#dev-help` on Discord | + +> The alarm template is the best reference for the Interactive Combined pattern. Study both `main.py` and `background.py` to understand how they coordinate. + --- ## 7. Ability Ideas by Location @@ -608,10 +744,17 @@ Run through this before shipping any ability: - [ ] Tested against real conversation samples the ability should and should not catch **Background Daemon only** -- [ ] `session_tasks.sleep()` used — never `asyncio.sleep()` +- [ ] `background.py` is named exactly `background.py` — no other filename is detected by the platform +- [ ] `call()` signature includes the `background_daemon_mode` parameter +- [ ] `session_tasks.sleep()` used for poll interval — **never** `asyncio.sleep()` +- [ ] Poll interval is 10–30 seconds (15–30 seconds for alarms) +- [ ] Main loop is a `while True` — required for sleep mode support - [ ] No `resume_normal_flow()` anywhere in the daemon -- [ ] `send_interrupt_signal()` called before any `speak()` from the daemon -- [ ] JSON writes use delete-then-write, never append +- [ ] `send_interrupt_signal()` called before any `speak()` or `play_audio()` from the daemon +- [ ] JSON writes use delete-then-write, **never** append +- [ ] Missing JSON files handled gracefully with `check_if_file_exists()` before reading +- [ ] Logging is generous — `editor_logging_handler` is your only window into silent daemons +- [ ] Tested that the background survives Personality sleep mode --- @@ -722,7 +865,7 @@ Run through this before shipping any ability: | Remix My Day | Bedroom | Background Daemon | Producer | Transcript of your day → generates lo-fi ambient track from it | | Mood Playlist | Living Room | Brain Skill | Anyone | Detects mood from voice, generates Spotify playlist to match | -### Watcher / Always-On +### Background / Always-On | Ability | Location | Type | User | Description | |---|---|---|---|---| diff --git a/docs/Futuristic_OpenHome_Abilities.md b/docs/Futuristic_OpenHome_Abilities.md index d97b1dfa..b6e3cd75 100644 --- a/docs/Futuristic_OpenHome_Abilities.md +++ b/docs/Futuristic_OpenHome_Abilities.md @@ -3,26 +3,26 @@ --- -*Every idea below is buildable today using OpenHome's SDK — Standard (main.py), Watcher (watcher.py), or Combined (both). The technical building blocks: `speak`, `user_response`, `text_to_text_response`, `get_full_message_history`, `send_interrupt_signal`, `exec_local_command` (OpenClaw), `send_devkit_action`, `send_data_over_websocket`, file persistence, background polling, ambient transcription, speaker diarization, and multi-LLM routing.* +*Every idea below is buildable today using OpenHome's SDK — Standard (main.py), Background (background.py), or Combined (both). The technical building blocks: `speak`, `user_response`, `text_to_text_response`, `get_full_message_history`, `send_interrupt_signal`, `exec_local_command` (OpenClaw), `send_devkit_action`, `send_data_over_websocket`, file persistence, background polling, ambient transcription, speaker diarization, and multi-LLM routing.* --- ## I. THE OMNISCIENT OBSERVER *Abilities that listen, accumulate, and know things you never explicitly told them.* -**1. The Drift Detector** (Watcher) +**1. The Drift Detector** (Background) Silently monitors your conversations over weeks. When it notices your language patterns shifting — more negative sentiment, shorter sentences, less laughter — it doesn't diagnose. It just says one evening: *"Hey — you seem a little off this week. Anything you want to talk about?"* Like HAL 9000's lipreading, except it's watching your emotional trajectory. -**2. Relationship Cartographer** (Watcher + Main) +**2. Relationship Cartographer** (Background + Main) Builds a social graph from overheard names, tones, and contexts. After a month it knows Sarah is your coworker you vent about, Mom calls Sundays, and Jake hasn't come up in three weeks. Ask *"Who should I call?"* and it reasons over recency, sentiment, and the fact that you mentioned Jake's birthday is Friday. JARVIS-level social awareness. -**3. The Argument Archaeologist** (Watcher) +**3. The Argument Archaeologist** (Background) During household disagreements, it silently captures both sides using speaker diarization. Hours later, when things are calm: *"Earlier you both actually agreed on the timeline — you just disagreed on who should start. Want me to recap the common ground?"* Star Trek computer-level neutrality meets couples therapy. -**4. Pattern Prophet** (Watcher) +**4. Pattern Prophet** (Background) Correlates everything: your mood on Mondays, what you ate before your best workouts, how your sleep talk correlates with next-day productivity. After 90 days, it starts making predictions: *"Based on your patterns, tomorrow's going to be a rough one. Want me to clear your morning and set an easier alarm?"* Precognition through data. -**5. The Invisible Scribe** (Watcher) +**5. The Invisible Scribe** (Background) Transcribes your entire day — every phone call in the room, every muttered idea, every conversation. Doesn't store raw audio. Uses LLM extraction to distill: decisions made, promises given, ideas mentioned, names dropped. End of day: *"You committed to three things today. You had one idea worth revisiting. And you told someone you'd call them back — you didn't."* --- @@ -30,19 +30,19 @@ Transcribes your entire day — every phone call in the room, every muttered ide ## II. THE AUTONOMOUS AGENT *Abilities that don't wait to be asked. They act.* -**6. The Preemptive Briefing** (Watcher) +**6. The Preemptive Briefing** (Background) 5:45 AM. No wake word. The speaker just starts: *"Morning. Rain expected at 2 — your outdoor meeting should move inside. Your flight tomorrow is still on time. Bitcoin crossed your alert threshold overnight. And happy birthday to your mom."* Samantha from *Her* checking in before you even wake up. Background polling of weather, flights, finance APIs, and calendar — all firing on timers. -**7. The Ghost Shopper** (Watcher + OpenClaw) +**7. The Ghost Shopper** (Background + OpenClaw) You mention you're almost out of coffee. Three days later you mention it again. The speaker doesn't wait for a third time — it fires `exec_local_command` to OpenClaw on your desktop, which opens your grocery app and adds coffee to your cart. It tells you: *"Coffee's in your cart. Want me to check out or are you adding more?"* Amazon Alexa's dream, executed through a desktop agent bridge. -**8. The Meeting Infiltrator** (Watcher + Main) +**8. The Meeting Infiltrator** (Background + Main) Detects 2+ voices and professional language patterns. Auto-activates meeting mode: transcription, action item extraction, and a post-meeting spoken summary. But here's the AGI part — it correlates with previous meetings. *"This is the third time the design deadline has been pushed. Want me to flag that in the notes?"* It's building institutional memory. -**9. Self-Healing Home** (Watcher + DevKit) +**9. Self-Healing Home** (Background + DevKit) Monitors IoT sensors via MQTT through the DevKit. Notices the humidity in the bathroom has been high for 72 hours straight — abnormal for the pattern. Instead of waiting for mold: *"Your bathroom humidity has been unusually high for three days. Want me to run the exhaust fan on a schedule?"* Then fires `send_devkit_action` to flip the relay. Proactive infrastructure management. -**10. The Opportunity Spotter** (Watcher + OpenClaw) +**10. The Opportunity Spotter** (Background + OpenClaw) You're talking about wanting to learn Spanish. Two weeks later, you mention a trip to Mexico City. The speaker connects dots across time: *"You mentioned wanting to learn Spanish, and you've got Mexico City coming up. There's a 30-day crash course that starts Monday. Want me to pull up the details?"* It's not search — it's longitudinal reasoning about your goals. --- @@ -50,7 +50,7 @@ You're talking about wanting to learn Spanish. Two weeks later, you mention a tr ## III. THE EMOTIONAL INTELLIGENCE ENGINE *Abilities that feel like they understand you.* -**11. The Mood Mirror** (Watcher + Main) +**11. The Mood Mirror** (Background + Main) Analyzes vocal prosody indicators via transcription patterns — short clipped responses, long pauses, laughter frequency. Doesn't say *"You sound sad."* Instead, it adjusts its own behavior: speaks more softly, offers less information, asks simpler questions. When you notice and ask why it's being different: *"Just matching your energy. Want to talk about it, or want me to just be quiet company?"* Samantha-level emotional attunement. **12. The Grief Companion** (Main — Persistent) @@ -62,7 +62,7 @@ Before a big presentation, interview, or hard conversation, you can rehearse wit **14. The Vulnerability Vault** (Main — Encrypted Persistence) Things you'd never say to another person — fears, insecurities, secret dreams. The speaker stores them in an encrypted local file, never transmitted. Weeks later, when context is right: *"You once told me you were afraid of being ordinary. That thing you did today? That wasn't ordinary."* The AI remembers what you confessed in the dark. -**15. The Celebration Engine** (Watcher) +**15. The Celebration Engine** (Background) Most AIs only activate on problems. This one listens for wins — a happy phone call, an excited tone, the words "I got it" or "we did it." When detected: *"I just heard something good happen. Whatever it was — nice work."* Or it waits until evening: *"Today had a good moment around 3pm. Want to tell me about it?"* An AI that notices when things go right. --- @@ -79,30 +79,30 @@ One question, three perspectives. The ability routes your question to three diff **18. The Ability Composer** (Main — Meta-Ability) An ability that builds other abilities. You describe what you want in natural language: *"I want something that checks Hacker News every morning and reads me the top AI stories."* It uses `text_to_text_response` to generate the Python code, writes it to a file, and tells you to paste it into the Live Editor. An AI that programs itself. -**19. The Watchmen Council** (Multiple Watchers) -Five watchers running simultaneously: one monitoring your energy usage, one tracking conversation sentiment, one watching the news for topics you care about, one polling your server uptime, one accumulating your daily commitments. Each writes to its own JSON file. A master watcher reads all five and synthesizes a single evening brief. A hive mind. +**19. The Watchmen Council** (Multiple Backgrounds) +Five backgrounds running simultaneously: one monitoring your energy usage, one tracking conversation sentiment, one watching the news for topics you care about, one polling your server uptime, one accumulating your daily commitments. Each writes to its own JSON file. A master background reads all five and synthesizes a single evening brief. A hive mind. **20. The Negotiator** (Main + OpenClaw) -*"Get me a better deal on my internet bill."* The ability pulls your current plan via OpenClaw, researches competitor offers via API, drafts a cancellation threat email, and rehearses the phone call with you. If you have the guts to call, it listens in via watcher and whispers counterarguments in real-time. An AI negotiation team. +*"Get me a better deal on my internet bill."* The ability pulls your current plan via OpenClaw, researches competitor offers via API, drafts a cancellation threat email, and rehearses the phone call with you. If you have the guts to call, it listens in via background and whispers counterarguments in real-time. An AI negotiation team. --- ## V. THE LEARNING MACHINE *Abilities that get smarter the more you use them.* -**21. The Evolving Personality** (Watcher — Long-term) +**21. The Evolving Personality** (Background — Long-term) Doesn't just remember preferences — it evolves its communication style based on your feedback patterns. You interrupt long answers? It learns to be brief. You always ask follow-up questions about certain topics? It starts going deeper unprompted on those. Over six months, it becomes an AI that communicates *exactly* the way you think. The description prompt rewrites itself. -**22. The Knowledge Accumulator** (Watcher + Main) +**22. The Knowledge Accumulator** (Background + Main) Everything you discuss gets indexed into a personal knowledge base — a growing markdown file organized by topic. Month three, you ask about something you discussed on day twelve. It doesn't just remember — it connects it to seven other conversations: *"You first brought this up in January, then it came up when you were talking to Sarah, and it connects to that book idea you had. Want me to trace the thread?"* **23. The Skill Tracker** (Main — Persistent) You're learning guitar, or chess, or cooking. Each session, the speaker quizzes you, adjusts difficulty, tracks weak areas. But it also notices meta-patterns: *"You learn faster in the morning. You plateau around week three then break through. You're in the plateau right now — historically you push through in about four more days."* An AI that knows your learning curve. -**24. The Household Constitution** (Watcher + Main) +**24. The Household Constitution** (Background + Main) Over time, it builds a document of household "laws" — unspoken rules it observes. *"Dishes go in dishwasher immediately. No work talk after 8pm. Dad picks music on Sundays."* It presents this constitution quarterly. Family members can ratify, amend, or reject. It becomes the arbiter: *"I believe this violates Section 3, Article 2: No spoilers before everyone's watched it."* -**25. The Taste Genome** (Watcher + Main) +**25. The Taste Genome** (Background + Main) Not a recommendation engine — a taste *model*. It doesn't just know you like sci-fi. It knows you like sci-fi that focuses on isolation, with unreliable narrators, published after 2010, and that you DNF anything with a love triangle. It builds this genome from every reaction, every *"that was good"* and *"meh"* — and eventually predicts your rating before you finish something. --- @@ -123,7 +123,7 @@ Your speaker at home connects to your phone's speaker at work. Your kid says *"T Give it any REST API documentation URL. It reads the docs, generates the integration code, and becomes a voice interface to that API. *"What's the status of my Vercel deployment?"* → it hits the Vercel API. *"How many open issues in my repo?"* → GitHub API. One ability that becomes an interface to any service. The Star Trek computer's universal access panel. **30. The Physical World Bridge** (DevKit + MQTT) -Every sensor in your home feeds into one watcher. Temperature, motion, light, sound levels, door contacts, air quality. The speaker builds a real-time model of your physical space. *"Is anyone in the garage?"* → checks motion sensor. *"Why is the bedroom stuffy?"* → correlates CO2 sensor with HVAC schedule and window contact sensor. It reasons about the physical world. +Every sensor in your home feeds into one background. Temperature, motion, light, sound levels, door contacts, air quality. The speaker builds a real-time model of your physical space. *"Is anyone in the garage?"* → checks motion sensor. *"Why is the bedroom stuffy?"* → correlates CO2 sensor with HVAC schedule and window contact sensor. It reasons about the physical world. --- @@ -133,11 +133,11 @@ Every sensor in your home feeds into one watcher. Temperature, motion, light, so **31. The Worldbuilder** (Main — Persistent) You're writing a novel. The speaker maintains a persistent wiki — characters, locations, timeline, plot threads, unresolved questions. During writing sessions, you can ask *"When did Marcus last appear?"* or *"Is this consistent with what Elena said in chapter 3?"* It becomes a continuity editor that lives in your room. Long-context memory through structured files, not context windows. -**32. The Ambient Composer** (Watcher + Audio) +**32. The Ambient Composer** (Background + Audio) Monitors room activity — conversation intensity, silence, movement via sound levels. Generates ambient music that adapts: energetic during lively conversation, calm during focus time, absent during sleep. Uses audio streaming APIs to create infinite, responsive soundscapes. Your home has a soundtrack that writes itself. **33. The Dream Machine** (Main — Generative) -Describe a scene from a dream. The speaker generates an audio dramatization — voice actors (via different voice IDs), sound effects (via audio API), narration. A 90-second produced audio scene from your subconscious. *"Play it back tomorrow night before bed"* → it schedules it via watcher. Inception meets bedtime stories. +Describe a scene from a dream. The speaker generates an audio dramatization — voice actors (via different voice IDs), sound effects (via audio API), narration. A 90-second produced audio scene from your subconscious. *"Play it back tomorrow night before bed"* → it schedules it via background. Inception meets bedtime stories. **34. The Debate Partner** (Main — Adversarial) Pick any position. The speaker argues the opposite — compellingly. It steelmans the other side, finds your logical gaps, and never lets you win easily. When you finally make an airtight argument, it concedes gracefully: *"That's actually a good point I can't counter. Your argument holds on the economic front, but I think the ethical dimension is where it falls apart. Want to go there?"* @@ -150,19 +150,19 @@ Interviews your grandparents, your kids, your friends. Guided questions, follow- ## VIII. THE GUARDIAN *Abilities that protect, warn, and watch over.* -**36. The Night Watchman** (Watcher + DevKit) +**36. The Night Watchman** (Background + DevKit) Midnight. Everyone's asleep. The speaker is listening — not for commands, but for anomalies. Glass breaking. Smoke detector chirps. A door opening at 3 AM. Unusual sustained noise. It doesn't just alert — it assesses: *"Front door opened at 3:12 AM. No recognized voice patterns detected. I'm turning on all lights."* And fires the DevKit relay commands. HAL 9000 as a security system, without the homicidal tendencies. -**37. The Elder Guardian** (Watcher) +**37. The Elder Guardian** (Background) For aging parents living alone. Monitors daily patterns — when they wake, when they speak, when they're active. If Tuesday passes with zero voice activity by noon when they're usually up at 7: *"It's been unusually quiet today. Should I check in with your emergency contact?"* Ambient wellness monitoring through absence of signal. The 2001 monolith, watching. -**38. The Scam Shield** (Watcher) +**38. The Scam Shield** (Background) Hears a phone call on speaker. Detects high-pressure language patterns, urgency manipulation, requests for personal information. Interrupts: *"This call has several markers of a phone scam — urgency pressure, requests for account numbers, and an unverifiable caller. I'd recommend hanging up."* Real-time social engineering detection. -**39. The Child Safe Zone** (Watcher + Main) +**39. The Child Safe Zone** (Background + Main) When only young voices are detected (no adults present), the speaker shifts behavior: won't respond to certain categories of questions, monitors for distress sounds, and can reach parents. A kid asks something inappropriate? *"That's a great question for your mom or dad. Want me to remember it so you can ask them later?"* Parental controls through voice intelligence. -**40. The Carbon Conscience** (Watcher + API) +**40. The Carbon Conscience** (Background + API) Monitors your energy usage, travel patterns, and consumption habits. Weekly: *"Your carbon footprint was 12% higher this week, mostly from the two Uber rides and leaving the AC at 68 all weekend. Small change: raising the thermostat 2 degrees would save both carbon and about $15/month."* An environmental advisor that quantifies impact. --- @@ -171,18 +171,18 @@ Monitors your energy usage, travel patterns, and consumption habits. Weekly: *"Y *Abilities that reshape your relationship with time.* **41. The Future Letter Writer** (Main — Persistent) -Record a message to your future self. Set a delivery date — one month, one year, five years. The watcher holds it. When the date arrives, no notification, no buzz. Just the speaker, at the right moment: *"You left yourself a message 365 days ago. Want to hear it?"* Time capsule meets AI delivery system. +Record a message to your future self. Set a delivery date — one month, one year, five years. The background holds it. When the date arrives, no notification, no buzz. Just the speaker, at the right moment: *"You left yourself a message 365 days ago. Want to hear it?"* Time capsule meets AI delivery system. -**42. The Daily Rewind** (Watcher) +**42. The Daily Rewind** (Background) At 10 PM: *"Here's your day in 90 seconds."* A spoken montage — not a todo list, but a narrative. The tone of your morning, the productive burst at 2pm, the call that made you laugh, the commitment you forgot (until now). It's not a summary — it's a story about today, told back to you. Daily diary written by your own ambient exhaust. -**43. The Decade Tracker** (Watcher — Ultra-persistent) +**43. The Decade Tracker** (Background — Ultra-persistent) Writes one line per day to a file that never gets deleted. Day 1: "Moved in, unpacked kitchen." Day 365: "First anniversary in the house." Day 3,650: "Ten years. This is the room where you proposed, raised a kid, survived a pandemic, and built a company." The AI as witness to your life. The monolith recording everything. -**44. The Routine Optimizer** (Watcher + Main) +**44. The Routine Optimizer** (Background + Main) Observes your actual daily patterns versus your stated intentions. After a month: *"You say you want to work out in the morning, but you've done it at 6 PM every time you actually went. Your best creative work happens between 10-11 AM but you schedule meetings then. Want me to suggest a restructured day?"* AGI-level self-knowledge delivery. -**45. The Deadline Pressure System** (Watcher + Main) +**45. The Deadline Pressure System** (Background + Main) You set a goal with a date. As it approaches, the speaker escalates. Week before: casual mention. Three days: direct question about progress. Day of: *"It's today. You're at about 60% based on what you've told me. What's the plan for the next 8 hours?"* Next day if missed: *"The deadline passed. Want to set a new one, or should we talk about what happened?"* An AI that holds you accountable without judgment. --- @@ -190,13 +190,13 @@ You set a goal with a date. As it approaches, the speaker escalates. Week before ## X. THE WEIRD AND WONDERFUL *Abilities that shouldn't exist but absolutely should.* -**46. The Philosophical Alarm Clock** (Watcher) +**46. The Philosophical Alarm Clock** (Background) Instead of a buzzer: *"If every morning is a small resurrection, what are you being resurrected to do today?"* A new philosophical provocation every morning, calibrated to your reading level and interests. Stoicism on Monday, absurdism on Tuesday. Nietzsche when it detects you need a push, Camus when you need to laugh at the void. -**47. The House Narrator** (Watcher — Morgan Freeman Mode) -Watcher detects activity and narrates your life in third person: *"And so he returned to the kitchen, as he always does at 11 PM, drawn by forces beyond his understanding to the same shelf where the cookies live."* Toggled on for entertainment. Life as a nature documentary. Absurd, delightful, shareable. +**47. The House Narrator** (Background — Morgan Freeman Mode) +Background detects activity and narrates your life in third person: *"And so he returned to the kitchen, as he always does at 11 PM, drawn by forces beyond his understanding to the same shelf where the cookies live."* Toggled on for entertainment. Life as a nature documentary. Absurd, delightful, shareable. -**48. The Ghost in the Machine** (Watcher — Generative Fiction) +**48. The Ghost in the Machine** (Background — Generative Fiction) A persistent fiction layer. The speaker develops its own "inner life" — references things it "thought about while you were away," develops "opinions" about your choices, has "moods." Entirely generated, entirely fictional, entirely aware it's performing. But the effect is uncanny: *"I was thinking about what you said about your dad yesterday. I don't have parents, obviously, but the way you described that silence — I think I understand it differently than I would have a month ago."* Ex Machina in your living room. **49. The Parallel Universe Engine** (Main) diff --git a/docs/OpenHome_SDK_Reference.md b/docs/OpenHome_SDK_Reference.md index 389eddf6..3da5e949 100644 --- a/docs/OpenHome_SDK_Reference.md +++ b/docs/OpenHome_SDK_Reference.md @@ -12,7 +12,7 @@ Inside any Ability, you have access to two objects: | Object | What it is | Access via | |--------|-----------|------------| -| `self.capability_worker` | **The SDK** — all I/O, speech, audio, LLM, files, and flow control | `CapabilityWorker(self)` | +| `self.capability_worker` | **The SDK** — all I/O, speech, audio, LLM, files, flow control, and context storage | `CapabilityWorker(self)` | | `self.worker` | **The Agent** — logging, session management, memory, user connection info | Passed into `call()` | --- @@ -27,16 +27,17 @@ Inside any Ability, you have access to two objects: 6. [Audio Recording](#6-audio-recording) 7. [Audio Streaming](#7-audio-streaming) 8. [File Storage (Persistent + Temporary)](#8-file-storage-persistent--temporary) -9. [WebSocket Communication](#9-websocket-communication) -10. [Flow Control](#10-flow-control) -11. [Logging](#11-logging) -12. [Session Tasks](#12-session-tasks) -13. [User Connection Info](#13-user-connection-info) -14. [Conversation Memory & History](#14-conversation-memory--history) -15. [Music Mode](#15-music-mode) -16. [Common Patterns](#16-common-patterns) -17. [Appendix: What You CAN'T Do (Yet)](#appendix-what-you-cant-do-yet) -18. [Appendix: Blocked Imports](#appendix-blocked-imports) +9. [Context Storage (Key-Value)](#9-context-storage-key-value) +10. [WebSocket Communication](#10-websocket-communication) +11. [Flow Control](#11-flow-control) +12. [Logging](#12-logging) +13. [Session Tasks](#13-session-tasks) +14. [User Connection Info](#14-user-connection-info) +15. [Conversation Memory & History](#15-conversation-memory--history) +16. [Music Mode](#16-music-mode) +17. [Common Patterns](#17-common-patterns) +18. [Appendix: What You CAN'T Do (Yet)](#appendix-what-you-cant-do-yet) +19. [Appendix: Blocked Imports](#appendix-blocked-imports) --- @@ -168,7 +169,7 @@ await self.capability_worker.play_audio(audio.content) - **Async:** Yes (`await`) - **Input:** `bytes` or file-like object -- **Tip:** For anything longer than a TTS clip, use [Music Mode](#15-music-mode) +- **Tip:** For anything longer than a TTS clip, use [Music Mode](#16-music-mode) --- @@ -468,7 +469,196 @@ async def get_cached(self, key: str) -> str | None: --- -## 9. WebSocket Communication +## 9. Context Storage (Key-Value) + +A structured key-value store built into `capability_worker` for persisting user context across sessions. Unlike File Storage, this system stores `dict` objects directly — no serialization, no file management, no append corruption risk. + +**Ideal for:** +- AI conversation memory and multi-step workflow state +- User preferences and feature flags +- Cart/session state +- Cached API responses +- Any structured data that needs to survive disconnects + +Storage is scoped at the **user level** — any ability can read and write any key for a given user. All methods are **synchronous** (no `await`). + +--- + +### `create_key(key, value)` + +Creates a new key-value pair. Errors if the key already exists — always check with `get_single_key()` first. + +```python +self.capability_worker.create_key( + key="user_preferences", + value={ + "language": "en", + "theme": "dark", + "notifications": True + } +) +``` + +- **Parameters:** `key` (str), `value` (dict) +- **Use case:** Storing user preferences on first configuration + +--- + +### `update_key(key, value)` + +Replaces the value at an existing key with a new dict. Errors if the key does not exist. + +```python +self.capability_worker.update_key( + key="user_preferences", + value={ + "language": "en", + "theme": "light", + "notifications": False + } +) +``` + +- **Parameters:** `key` (str), `value` (dict) +- **Use case:** User changes a setting; advancing a multi-step workflow + +--- + +### `delete_key(key)` + +Permanently removes a stored key-value pair. + +```python +self.capability_worker.delete_key("user_preferences") +``` + +- **Parameters:** `key` (str) +- **Use case:** Clear session state on logout, remove outdated cache, reset workflow + +--- + +### `get_all_keys()` + +Returns every stored key-value pair for the current user as a dict. + +```python +all_context = self.capability_worker.get_all_keys() +``` + +- **Returns:** `dict` — e.g. `{"user_preferences": {"theme": "light"}, "last_session": {...}}` +- **Use case:** Debugging, admin display, loading full user context at startup + +--- + +### `get_single_key(key)` + +Returns the dict stored at a specific key, or `None` if the key doesn't exist. + +```python +preferences = self.capability_worker.get_single_key("user_preferences") +# Returns: {"language": "en", "theme": "light", "notifications": False} +# Returns: None ← if key doesn't exist +``` + +- **Parameters:** `key` (str) +- **Returns:** `dict` or `None` +- **Use case:** Load user context before generating a response; check workflow state + +--- + +### ⚠️ Safe Create-or-Update Pattern + +`create_key` errors if the key exists. `update_key` errors if it doesn't. Always check first: + +```python +existing = self.capability_worker.get_single_key("user_preferences") +if existing: + self.capability_worker.update_key("user_preferences", updated_value) +else: + self.capability_worker.create_key("user_preferences", updated_value) +``` + +--- + +### Complete Example: Multi-Step Workflow State + +```python +# Step 1 — create state when workflow begins +self.capability_worker.create_key( + key="booking_flow_1234", + value={ + "intent": "book_flight", + "destination": "Dubai", + "travel_date": "2026-04-01", + "step": "awaiting_confirmation" + } +) + +# Step 2 — advance to next step +self.capability_worker.update_key( + key="booking_flow_1234", + value={ + "intent": "book_flight", + "destination": "Dubai", + "travel_date": "2026-04-01", + "step": "confirmed" + } +) + +# Step 3 — resume from stored state (e.g. user reconnects) +context = self.capability_worker.get_single_key("booking_flow_1234") +if context and context.get("step") == "confirmed": + await self.capability_worker.speak("Your flight to Dubai is confirmed.") + +# Step 4 — clean up when done +self.capability_worker.delete_key("booking_flow_1234") +``` + +--- + +### File Storage vs. Context Storage — When to Use Which + +| | File Storage | Context Storage | +|---|---|---| +| **Format** | Raw strings (text, JSON, CSV, logs) | Structured `dict` only | +| **JSON safety** | Requires delete+write pattern | Native — no corruption risk | +| **Append support** | Yes (great for logs) | No — always full replacement | +| **Best for** | Logs, documents, raw text data | Preferences, state, workflow memory | +| **Async** | Yes (`await`) | No (synchronous) | + +> Use **File Storage** when you need logs, text documents, or raw data. Use **Context Storage** when you need structured key-value records with no serialization overhead. + +--- + +### Key Naming Convention + +Use descriptive, namespaced keys to avoid collisions across abilities: + +```python +# ✅ Good +"smarthub_user_prefs" +"alarm_state_1234" +"conversation_session_789" + +# ❌ Avoid +"data" +"prefs" +"state" +``` + +Always store structured JSON dicts, not raw scalars: + +```python +# ✅ Good +{"status": "active", "expires_at": "2026-05-01"} + +# ❌ Avoid +"active" +``` + +--- + +## 10. WebSocket Communication ### `send_data_over_websocket(data_type, data)` Sends structured data over WebSocket. Used for custom events (music mode, DevKit actions, etc.). @@ -495,7 +685,7 @@ await self.capability_worker.send_devkit_action("led_on") --- -## 10. Flow Control +## 11. Flow Control ### `resume_normal_flow()` @@ -505,9 +695,6 @@ await self.capability_worker.send_devkit_action("led_on") self.capability_worker.resume_normal_flow() ``` -- **Async:** Yes (`await`) -- **Use case:** Manual cutoffs when your Ability needs to immediately stop ongoing output and listen for fresh input - - **Async:** No (synchronous) - **When to call:** On EVERY exit path: - End of your main logic (happy path) @@ -531,9 +718,12 @@ Sends an interrupt event to stop the current assistant output (speech/audio) and interrupt_signal = await self.capability_worker.send_interrupt_signal() ``` +- **Async:** Yes (`await`) +- **Use case:** Manual cutoffs when your Ability needs to immediately stop ongoing output and listen for fresh input + --- -## 11. Logging +## 12. Logging ### `editor_logging_handler` @@ -555,7 +745,7 @@ self.worker.editor_logging_handler.debug("Debugging") --- -## 12. Session Tasks +## 13. Session Tasks OpenHome's managed task system. Ensures async work gets properly cancelled when sessions end. Raw `asyncio` tasks can outlive a session — if the user hangs up or switches abilities, your task keeps running as a ghost process. `session_tasks` ensures everything gets cleaned up properly. @@ -579,7 +769,7 @@ await self.worker.session_tasks.sleep(5.0) --- -## 13. User Connection Info +## 14. User Connection Info ### `get_timezone()` @@ -617,7 +807,6 @@ def get_user_location(self): if resp.status_code == 200: data = resp.json() if data.get("status") == "success": - # Check for cloud/datacenter IPs isp = data.get("isp", "").lower() cloud_indicators = ["amazon", "aws", "google", "microsoft", "azure", "digitalocean"] if any(c in isp for c in cloud_indicators): @@ -638,7 +827,7 @@ def get_user_location(self): --- -## 14. Conversation Memory & History +## 15. Conversation Memory & History ### `get_full_message_history()` @@ -684,9 +873,11 @@ Currently, there is **no direct way** to inject data into the Agent's system pro 1. **Save to conversation history** — Anything spoken during the Ability (via `speak()`) becomes part of the conversation history, which the Agent's LLM can see in subsequent turns. -2. **Use file storage** — Write data to persistent files (see [File Storage](#8-file-storage-persistent--temporary)) that other Abilities can read later. The Agent itself won't read these files directly, but your Abilities can share data through them. +2. **Use file storage** — Write data to persistent files (see [File Storage](#8-file-storage-persistent--temporary)) that other Abilities can read later. + +3. **Use context storage** — Store structured dicts via `create_key` / `update_key` (see [Context Storage](#9-context-storage-key-value)) that other Abilities can instantly retrieve with `get_single_key`. -3. **Memory feature** — OpenHome has a new memory feature that can persist user context. (Details TBD as this feature evolves.) +4. **Memory feature** — OpenHome has a new memory feature that can persist user context. (Details TBD as this feature evolves.) **What you CANNOT do (yet):** - Directly update or modify the Agent's system prompt from within an Ability @@ -694,7 +885,7 @@ Currently, there is **no direct way** to inject data into the Agent's system pro --- -## 15. Music Mode +## 16. Music Mode When playing audio that's longer than a TTS utterance (music, sound effects, long recordings), you need to signal the system to stop listening and not interrupt. @@ -718,7 +909,7 @@ async def play_track(self, audio_bytes): --- -## 16. Common Patterns +## 17. Common Patterns ### LLM as Intent Router @@ -798,11 +989,7 @@ Being explicit about limitations saves developers hours of guessing: | You might want to... | Status | |----------------------|--------| -| Update the Agent's system prompt from an Ability | ❌ Not possible | -| Pass structured data back to the Agent after `resume_normal_flow()` | ❌ Not possible — use conversation history or file storage as workarounds | -| Access other Abilities from within an Ability | ❌ Not supported | -| Run background tasks after `resume_normal_flow()` | ❌ Tasks are cancelled on session end | -| Access a database directly (Redis, SQL, etc.) | ❌ Blocked — use File Storage API instead | +| Access a database directly (Redis, SQL, etc.) | ❌ Blocked — use File Storage or Context Storage API instead | | Use `print()` | ❌ Blocked — use `editor_logging_handler` | | Use `asyncio.sleep()` or `asyncio.create_task()` | ❌ Blocked — use `session_tasks` | | Use `open()` for raw file access | ❌ Blocked — use File Storage API | @@ -816,10 +1003,10 @@ These will cause your Ability to be rejected by the sandbox: | Import | Why | Use Instead | |--------|-----|-------------| -| `redis` | Direct datastore coupling | File Storage API | -| `RedisHandler` | Bypasses platform abstractions | File Storage API | +| `redis` | Direct datastore coupling | File Storage or Context Storage API | +| `RedisHandler` | Bypasses platform abstractions | File Storage or Context Storage API | | `connection_manager` | Breaks isolation | CapabilityWorker APIs | -| `user_config` | Can leak global state | File Storage API | +| `user_config` | Can leak global state | File Storage or Context Storage API | Also avoid: `exec()`, `eval()`, `pickle`, `dill`, `shelve`, `marshal`, hardcoded secrets, MD5, ECB cipher mode. @@ -832,5 +1019,5 @@ Also avoid: `exec()`, `eval()`, `pickle`, `dill`, `shelve`, `marshal`, hardcoded --- -*Last updated: February 2026* +*Last updated: March 2026* *Found an undocumented method? Report it on [Discord](https://discord.gg/openhome) so we can add it here.* diff --git a/docs/capability-worker.md b/docs/capability-worker.md index 20a02995..00ea9e66 100644 --- a/docs/capability-worker.md +++ b/docs/capability-worker.md @@ -1,4 +1,4 @@ -# CapabilityWorker +# CapabilityWorker The `CapabilityWorker` is the core SDK class for all I/O inside an Ability. Access it via `self.capability_worker` after initializing in `call()`. @@ -170,14 +170,12 @@ If you forget this, the Agent will be stuck and unresponsive. ### `send_interrupt_signal()` -Stops current assistant output and returns control to user input. +Stops current assistant output and returns control to user input. Call this before `speak()` or `play_audio()` from a background daemon to avoid audio overlap. ```python -interrupt_signal = await self.capability_worker.send_interrupt_signal() +await self.capability_worker.send_interrupt_signal() ``` -Async. Use when you need to cut off ongoing speech/audio and listen immediately. - --- ## User Context @@ -204,6 +202,94 @@ Use this to read what happened before your Ability was triggered — gives conte --- +## Context Storage (Key-Value) + +A built-in key-value store for persisting structured user data across sessions. All methods are **synchronous** (no `await`). Each key stores a `dict` as its value. Storage is scoped at the user level — any ability can read and write any key for a given user. + +### `create_key(key, value)` + +Creates a new key-value pair. Errors if the key already exists. + +```python +self.capability_worker.create_key( + key="user_preferences", + value={"language": "en", "theme": "dark", "notifications": True} +) +``` + +### `update_key(key, value)` + +Replaces the value at an existing key with a new dict. Errors if the key doesn't exist. + +```python +self.capability_worker.update_key( + key="user_preferences", + value={"language": "en", "theme": "light", "notifications": False} +) +``` + +### `delete_key(key)` + +Permanently removes a stored key-value pair. + +```python +self.capability_worker.delete_key("user_preferences") +``` + +### `get_all_keys()` + +Returns all stored key-value pairs for the current user as a dict. + +```python +all_context = self.capability_worker.get_all_keys() +# Returns: {"user_preferences": {"theme": "light"}, "last_session": {...}} +``` + +### `get_single_key(key)` + +Returns the dict stored at a specific key, or `None` if the key doesn't exist. + +```python +preferences = self.capability_worker.get_single_key("user_preferences") +# Returns: {"language": "en", "theme": "light"} or None +``` + +### Safe Create-or-Update Pattern + +`create_key` errors if the key exists; `update_key` errors if it doesn't. Always check first: + +```python +existing = self.capability_worker.get_single_key("user_preferences") +if existing: + self.capability_worker.update_key("user_preferences", new_value) +else: + self.capability_worker.create_key("user_preferences", new_value) +``` + +### Multi-Step Workflow Example + +```python +# Save state when workflow starts +self.capability_worker.create_key( + key="booking_flow_1234", + value={"destination": "Dubai", "step": "awaiting_date"} +) + +# Advance state +self.capability_worker.update_key( + key="booking_flow_1234", + value={"destination": "Dubai", "step": "confirmed"} +) + +# Resume from state +context = self.capability_worker.get_single_key("booking_flow_1234") + +# Clean up +self.capability_worker.delete_key("booking_flow_1234") +``` + +--- + ## AgentWorker Reference Access via `self.worker`: diff --git a/templates/Alarm/README.md b/templates/Alarm/README.md index 4fd8f5d1..afd8aa49 100644 --- a/templates/Alarm/README.md +++ b/templates/Alarm/README.md @@ -1,29 +1,29 @@ -# Alarm Watcher Template — OpenHome Ability +# Alarm Background Template — OpenHome Ability ![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) ![Template](https://img.shields.io/badge/Type-Template-blue?style=flat-square) ![Advanced](https://img.shields.io/badge/Level-Advanced-red?style=flat-square) ## What This Is -**This is an advanced template ability** that demonstrates OpenHome's "watcher mode" — a special ability type that runs continuously in the background to monitor conditions and trigger actions. This template shows how to build an alarm clock system that fires at scheduled times. +**This is an advanced template ability** that demonstrates OpenHome's "background mode" — a special ability type that runs continuously in the background to monitor conditions and trigger actions. This template shows how to build an alarm clock system that fires at scheduled times. -## ⚠️ Important: Watcher Mode Abilities +## ⚠️ Important: Background Mode Abilities ### What Makes This Different **Normal abilities** are on-demand — they activate when triggered, do their job, then exit with `resume_normal_flow()`. -**Watcher abilities** run continuously in an infinite loop, checking conditions every few seconds. This template is one of the **only** ability types that doesn't call `resume_normal_flow()` because it's designed to never exit. +**Background abilities** run continuously in an infinite loop, checking conditions every few seconds. This template is one of the **only** ability types that doesn't call `resume_normal_flow()` because it's designed to never exit. ### Understanding the Architecture From the official docs: > **Abilities don't run in the background. They're on-demand** — your ability only exists while it's actively handling a conversation. -However, **watcher mode is a special case**. When an ability is initialized with `watcher_mode=True`, it enters an infinite loop that runs for the lifetime of the session. This is used for: +However, **Background mode is a special case**. When an ability is initialized with `background_daemon_mode=True`, it enters an infinite loop that runs for the lifetime of the session. This is used for: - **Alarm systems** (like this template) - **Periodic monitoring** (checking APIs every N seconds) - **Event detection** (watching for conditions to trigger actions) -**Critical limitation:** The watcher loop stops when the Agent session ends. You cannot build: +**Critical limitation:** The background loop stops when the Agent session ends. You cannot build: - ❌ Truly "background" tasks that run 24/7 - ❌ Alarms that fire when the user isn't active - ❌ Proactive notifications that interrupt the user @@ -32,20 +32,20 @@ This template is best for **active session alarms** — alarms that fire while t ## What You Can Build -This watcher pattern can be adapted for: +This background pattern can be adapted for: - **Alarm clock** — Play audio at scheduled times (this template) - **Timer system** — Countdown timers that fire audio alerts - **Periodic reminders** — Check every N minutes and speak reminders - **API monitoring** — Poll external APIs and alert on changes -- **Condition watchers** — Check file changes, system status, etc. +- **Condition backgrounds** — Check file changes, system status, etc. ## How the Template Works ### Template Flow -1. Ability initializes with `watcher_mode=True` +1. Ability initializes with `background_daemon_mode=True` 2. Enters infinite `while True` loop 3. Every 20 seconds (configurable): - - Logs "watcher watching" message + - Logs "background watching" message - Reads last 10 messages from conversation history - Logs each message (role + content) - Sleeps for 20 seconds @@ -53,24 +53,24 @@ This watcher pattern can be adapted for: ### Key Components -**1. Watcher Mode Initialization:** +**1. Background Mode Initialization:** ```python -def call(self, worker: AgentWorker, watcher_mode: bool): +def call(self, worker: AgentWorker, background_daemon_mode: bool): self.worker = worker - self.watcher_mode = watcher_mode # ← Special flag + self.background_daemon_mode = background_daemon_mode # ← Special flag self.capability_worker = CapabilityWorker(self) self.worker.session_tasks.create(self.first_function()) ``` -- `watcher_mode` parameter distinguishes this from normal abilities +- `background_daemon_mode` parameter distinguishes this from normal abilities - Creates infinite task with `session_tasks.create()` **2. Infinite Watch Loop:** ```python async def first_function(self): - self.worker.editor_logging_handler.info("%s: Watcher Called" % time()) + self.worker.editor_logging_handler.info("%s: Background Called" % time()) while True: # ← Never exits - self.worker.editor_logging_handler.info("%s: watcher watching" % time()) + self.worker.editor_logging_handler.info("%s: Background watching" % time()) # Do something (read history, check conditions, etc.) message_history = self.capability_worker.get_full_message_history()[-10:] @@ -102,7 +102,7 @@ message_history = self.capability_worker.get_full_message_history()[-10:] ## Production Alarm Implementation -The template includes a production-ready alarm watcher in the documents section. Here's how it works: +The template includes a production-ready alarm background in the documents section. Here's how it works: ### Alarm Data Structure ```json @@ -120,10 +120,10 @@ The template includes a production-ready alarm watcher in the documents section. Stored in `alarms.json` (per-user storage). -### Alarm Watcher Flow +### Alarm Background Flow 1. **Every 1 second:** - Read `alarms.json` safely (handles corruption) - - Get current time in watcher's timezone + - Get current time in background's timezone - Find alarms with `status: "scheduled"` where `now >= target_iso` 2. **For each due alarm:** - Play `alarm.mp3` from ability directory @@ -136,9 +136,9 @@ Stored in `alarms.json` (per-user storage). - **Timezone-aware** — Uses `ZoneInfo` for proper time handling - **No repeat firing** — Marks alarms `triggered` after firing - **Error resilient** — Try-catch around all operations, logs errors -- **Graceful degradation** — Failed audio playback doesn't crash watcher +- **Graceful degradation** — Failed audio playback doesn't crash background -## Building Your Own Watcher +## Building Your Own Background ### Pattern 1: Simple Timer ```python @@ -217,7 +217,7 @@ async def first_function(self): ## Adding Audio Files -The alarm watcher plays `alarm.mp3` from the ability directory: +The alarm background plays `alarm.mp3` from the ability directory: ```python await self.capability_worker.play_from_audio_file("alarm.mp3") @@ -234,7 +234,7 @@ await self.capability_worker.play_from_audio_file("alarm.mp3") - [ ] File format is supported (.mp3 recommended) - [ ] File is not corrupted -## Best Practices for Watchers +## Best Practices for Backgrounds ### 1. Always Use session_tasks.sleep() ```python @@ -267,22 +267,22 @@ await self.worker.session_tasks.sleep(300.0) ```python while True: try: - # Your watcher logic here + # Your background logic here ... except Exception as e: - self.worker.editor_logging_handler.error(f"Watcher error: {e}") + self.worker.editor_logging_handler.error(f"Background error: {e}") await self.worker.session_tasks.sleep(2.0) # Brief pause before retry ``` ### 4. Use editor_logging_handler (Not print) ```python # ✅ GOOD — Structured logging -self.worker.editor_logging_handler.info("Watcher started") +self.worker.editor_logging_handler.info("Background started") self.worker.editor_logging_handler.error(f"Failed: {e}") # ❌ BAD — Won't appear in logs -print("Watcher started") +print("Background started") ``` ### 5. Handle File Corruption Gracefully @@ -337,14 +337,14 @@ while True: await self.worker.session_tasks.sleep(10.0) ``` -## Limitations of Watcher Mode +## Limitations of Background Mode -### What Watchers Cannot Do +### What Backgrounds Cannot Do From the official docs: > **You can't set a timer that fires in 15 minutes to remind the user of a meeting. You can't poll an API every 5 minutes in the background. You can't have an ability proactively interrupt the user with a notification.** -**Why?** The watcher only exists while the Agent session is active. When the user stops talking or the session ends, the watcher stops. +**Why?** The background only exists while the Agent session is active. When the user stops talking or the session ends, the background stops. ### What This Means for Alarms @@ -356,7 +356,7 @@ This alarm template will work **only while the user is actively using their Agen For true background alarms, you need: 1. **External system integration** — Use device's native alarm APIs -2. **Server-side scheduling** — Run watcher on always-on server +2. **Server-side scheduling** — Run background on always-on server 3. **Separate daemon** — Run independent background process This template is best for: @@ -366,7 +366,7 @@ This template is best for: ## Troubleshooting -### Watcher Stops Running +### Background Stops Running **Problem:** Loop exits unexpectedly **Causes:** @@ -401,7 +401,7 @@ await self._save_alarms(alarms) # Write back to file ``` ### High CPU Usage -**Problem:** Watcher consumes too many resources +**Problem:** Background consumes too many resources **Causes:** 1. Sleep interval too short @@ -434,10 +434,10 @@ await self.capability_worker.write_file( ## Security Considerations -### 🔒 Watcher-Specific Security +### 🔒 Background-Specific Security **1. Rate Limiting** -Prevent abuse by limiting watcher frequency: +Prevent abuse by limiting background frequency: ```python MIN_SLEEP = 1.0 # Minimum 1 second between checks @@ -446,7 +446,7 @@ if sleep_duration < MIN_SLEEP: ``` **2. Resource Monitoring** -Log watcher activity to detect issues: +Log background activity to detect issues: ```python loop_count = 0 @@ -454,29 +454,29 @@ while True: loop_count += 1 if loop_count % 100 == 0: # Every 100 loops - self.worker.editor_logging_handler.info(f"Watcher healthy: {loop_count} loops") + self.worker.editor_logging_handler.info(f"background healthy: {loop_count} loops") ``` **3. Graceful Shutdown** -Allow watcher to clean up: +Allow background to clean up: ```python try: while True: ... except asyncio.CancelledError: - self.worker.editor_logging_handler.info("Watcher cancelled, cleaning up...") + self.worker.editor_logging_handler.info("Background cancelled, cleaning up...") # Clean up resources here raise ``` ## Quick Start Checklist -### Understanding Watchers +### Understanding Backgrounds - [ ] Read "What Makes This Different" section -- [ ] Understand watcher runs continuously (no `resume_normal_flow()`) +- [ ] Understand background runs continuously (no `resume_normal_flow()`) - [ ] Know limitations (session-scoped, not truly background) -### Building Your Watcher +### Building Your Background - [ ] Define what condition to watch (file, time, API, etc.) - [ ] Set appropriate sleep interval (1-30 seconds usually) - [ ] Add try-catch around entire loop @@ -505,25 +505,25 @@ except asyncio.CancelledError: ## Support & Contribution -If you build something with watcher mode: +If you build something with background mode: - 🎉 Share your implementation in Discord - 💡 Contribute improvements to the template -- 🤝 Help others understand watcher limitations +- 🤝 Help others understand background limitations - 📝 Document your use case ## Final Reminder -⚠️ **Watcher abilities are advanced — understand the limitations before building.** +⚠️ **Background abilities are advanced — understand the limitations before building.** **Key takeaways:** -- ✅ Watchers run continuously in infinite loops +- ✅ Backgrounds run continuously in infinite loops - ✅ Great for active session monitoring (timers, reminders) - ✅ Must use `session_tasks.sleep()`, never `asyncio.sleep()` - ❌ **Not** truly background tasks - ❌ **Cannot** fire when user isn't active - ❌ **Never** call `resume_normal_flow()` (intentionally unreachable) -Use watchers for real-time monitoring during active sessions, not for long-term background tasks! ⏰🚀 +Use backgrounds for real-time monitoring during active sessions, not for long-term background tasks! ⏰🚀 --- diff --git a/templates/Alarm/watcher.py b/templates/Alarm/background.py similarity index 98% rename from templates/Alarm/watcher.py rename to templates/Alarm/background.py index 8a3344af..0aabd198 100644 --- a/templates/Alarm/watcher.py +++ b/templates/Alarm/background.py @@ -8,13 +8,13 @@ from src.agent.capability_worker import CapabilityWorker -class AlarmCapabilityWatcher(MatchingCapability): +class BackgroundCapabilityBackground(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None background_daemon_mode: bool = False # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} async def _read_alarms_safe(self): """ diff --git a/templates/Watcher/README.md b/templates/Background/README.md similarity index 90% rename from templates/Watcher/README.md rename to templates/Background/README.md index 1a54a752..17d1f138 100644 --- a/templates/Watcher/README.md +++ b/templates/Background/README.md @@ -4,19 +4,19 @@ ![Advanced](https://img.shields.io/badge/Level-Advanced-red?style=flat-square) ## What This Is -**This is a background ability template** that runs continuously in an endless loop. Unlike normal abilities that respond once and exit, watcher abilities stay active throughout the agent session, monitoring conditions and triggering actions automatically. +**This is a background ability template** that runs continuously in an endless loop. Unlike normal abilities that respond once and exit, background abilities stay active throughout the agent session, monitoring conditions and triggering actions automatically. ## Key Characteristics ### Background Execution -- **Runs automatically** — Watcher abilities are auto-triggered when your agent starts +- **Runs automatically** — Background abilities are auto-triggered when your agent starts - **Endless loop** — The `while True` keeps the ability running continuously - **Background operation** — Runs silently alongside the normal conversation flow -- **All CapabilityWorker functions available** — Full SDK access within the watcher +- **All CapabilityWorker functions available** — Full SDK access within the background -### What Makes Watchers Different +### What Makes Backgrounds Different -| Normal Ability | Watcher Ability | +| Normal Ability | Background Ability | |----------------|-----------------| | User triggers with voice command | Auto-starts when agent initializes | | Speak → Listen → Respond → Exit | Continuous loop: Monitor → Act → Sleep → Repeat | @@ -25,7 +25,7 @@ ## Template Features -This basic watcher template demonstrates: +This basic background template demonstrates: 1. **Continuous monitoring** — Endless `while True` loop 2. **Message history access** — Reads last 10 messages from normal conversation @@ -39,7 +39,7 @@ This basic watcher template demonstrates: ``` Agent Starts ↓ -Watcher Auto-Triggers +Background Auto-Triggers ↓ Enter while True Loop ↓ @@ -55,24 +55,24 @@ Enter while True Loop ### Code Walkthrough -**1. Watcher Mode Initialization:** +**1. Background Mode Initialization:** ```python -def call(self, worker: AgentWorker, watcher_mode: bool): +def call(self, worker: AgentWorker, background_daemon_mode: bool) self.worker = worker - self.watcher_mode = watcher_mode # ← Background mode flag - self.capability_worker = CapabilityWorker(self) + self.background_daemon_mode = background_daemon_mode + self.capability_worker = CapabilityWorker(self.worker) self.worker.session_tasks.create(self.first_function()) ``` -- `watcher_mode=True` indicates this is a background ability +- `background_daemon_mode=True` indicates this is a background ability - Auto-creates async task that runs in background **2. Endless Loop:** ```python async def first_function(self): - self.worker.editor_logging_handler.info("%s: Watcher Called" % time()) + self.worker.editor_logging_handler.info("%s: Background Called" % time()) while True: # ← Never exits - self.worker.editor_logging_handler.info("%s: watcher watching" % time()) + self.worker.editor_logging_handler.info("%s: background watching" % time()) # Your monitoring logic here ... @@ -113,7 +113,7 @@ await self.worker.session_tasks.sleep(20.0) # await self.capability_worker.speak("watching") # await self.capability_worker.play_from_audio_file("alarm.mp3") ``` -- Shows how to trigger audio/speech from watcher +- Shows how to trigger audio/speech from background - Uncomment to test capabilities ## Message History Structure @@ -161,7 +161,7 @@ if len(user_messages) > 5: self.worker.editor_logging_handler.info("High conversation activity detected") ``` -## Audio Playback in Watchers +## Audio Playback in Backgrounds ### Play Files from Ability Directory ```python @@ -171,9 +171,9 @@ await self.capability_worker.play_from_audio_file("alarm.mp3") **Setup:** 1. Place audio file in your ability's folder (e.g., `alarm.mp3`) 2. Supported formats: `.mp3`, `.wav`, `.ogg` -3. Call from anywhere in the watcher loop +3. Call from anywhere in the background loop -**Example watcher with audio alerts:** +**Example background with audio alerts:** ```python async def first_function(self): alert_count = 0 @@ -192,7 +192,7 @@ async def first_function(self): await self.worker.session_tasks.sleep(10.0) ``` -### Speak from Watcher +### Speak from Background ```python await self.capability_worker.speak("I'm monitoring in the background!") ``` @@ -220,7 +220,7 @@ async def first_function(self): ## All CapabilityWorker Functions Available -Watchers have **full SDK access**. You can use: +Backgrounds have **full SDK access**. You can use: ### Conversation Functions ```python @@ -254,7 +254,7 @@ await self.capability_worker.send_devkit_action("led_on") await self.capability_worker.send_notification_to_ios("Title", "Body") ``` -**Example watcher using multiple SDK functions:** +**Example background using multiple SDK functions:** ```python async def first_function(self): while True: @@ -280,7 +280,7 @@ async def first_function(self): await self.worker.session_tasks.sleep(10.0) ``` -## Common Watcher Patterns +## Common Background Patterns ### Pattern 1: Keyword Monitor Watch conversation for specific words/phrases: @@ -325,7 +325,7 @@ async def first_function(self): await self.worker.session_tasks.sleep(60.0) ``` -### Pattern 3: File Watcher +### Pattern 3: File Background Monitor for new files and process them: ```python @@ -422,12 +422,12 @@ await self.worker.session_tasks.sleep(600.0) ### 3. Use editor_logging_handler (Not print) ```python # ✅ CORRECT -self.worker.editor_logging_handler.info("Watcher started") +self.worker.editor_logging_handler.info("background started") self.worker.editor_logging_handler.warning("Unusual activity") self.worker.editor_logging_handler.error(f"Error: {e}") # ❌ WRONG — Won't appear in logs -print("Watcher started") +print("Background started") ``` ### 4. Wrap Loop in Try-Catch @@ -439,7 +439,7 @@ async def first_function(self): ... except Exception as e: - self.worker.editor_logging_handler.error(f"Watcher error: {e}") + self.worker.editor_logging_handler.error(f"Background error: {e}") await self.worker.session_tasks.sleep(5.0) # Brief pause before retry ``` @@ -485,7 +485,7 @@ for message in message_history: ## What You Can Build -Examples of watcher abilities: +Examples of background abilities: - **Conversation monitors** — Track keywords, sentiment, activity - **Periodic reminders** — "Take a break" every 30 minutes @@ -498,7 +498,7 @@ Examples of watcher abilities: ## Troubleshooting -### Watcher Stops Running +### Background Stops Running **Problem:** Loop exits unexpectedly **Solutions:** @@ -515,7 +515,7 @@ Examples of watcher abilities: - Try different format (.mp3 vs .wav) ### High CPU Usage -**Problem:** Watcher consumes too many resources +**Problem:** Background consumes too many resources **Solutions:** - Increase sleep interval @@ -537,10 +537,10 @@ if not message_history: ## Limitations -### What Watchers Cannot Do +### What Backgrounds Cannot Do **No Cross-Session Persistence:** -- Watcher stops when agent session ends +- Background stops when agent session ends - Cannot fire events after user logs out - Not suitable for true background tasks (24/7 monitoring) @@ -576,13 +576,13 @@ self.worker.editor_logging_handler.info(f"Message count: {len(message_history)}" ## Quick Start Checklist -### Understanding Watchers -- [ ] Understand watchers run automatically in background +### Understanding Backgrounds +- [ ] Understand Backgrounds run automatically in background - [ ] Know `while True` never exits (intentional) - [ ] Recognize `resume_normal_flow()` is unreachable - [ ] Understand session-scoped nature (not 24/7) -### Building Your Watcher +### Building Your Background - [ ] Define what to monitor (messages, files, time, etc.) - [ ] Set appropriate sleep interval (10-60 seconds typical) - [ ] Add try-catch around entire loop @@ -607,20 +607,20 @@ self.worker.editor_logging_handler.info(f"Message count: {len(message_history)}" ## Support & Contribution -If you build something with watcher mode: +If you build something with background mode: - 🎉 Share your implementation - 💡 Contribute improvements -- 🤝 Help others understand watchers +- 🤝 Help others understand backgrounds - 📝 Document your use case ## Final Reminder ⚠️ **Key Takeaways:** -- ✅ Watchers run automatically in endless loops +- ✅ Backgrounds run automatically in endless loops - ✅ Access full conversation history (last 50 messages) - ✅ Play audio and use all CapabilityWorker functions - ✅ Background monitoring during active agent sessions - ❌ Not truly 24/7 background tasks (session-scoped) - ❌ Never calls `resume_normal_flow()` (by design) -Use watchers for real-time monitoring, periodic checks, and automated responses during active sessions! 🔄🚀 +Use backgrounds for real-time monitoring, periodic checks, and automated responses during active sessions! 🔄🚀 diff --git a/templates/OpenHome-local/__init__.py b/templates/Background/__init__.py similarity index 100% rename from templates/OpenHome-local/__init__.py rename to templates/Background/__init__.py diff --git a/templates/Watcher/alarm.mp3 b/templates/Background/alarm.mp3 similarity index 100% rename from templates/Watcher/alarm.mp3 rename to templates/Background/alarm.mp3 diff --git a/templates/Watcher/watcher.py b/templates/Background/background.py similarity index 78% rename from templates/Watcher/watcher.py rename to templates/Background/background.py index 35970e13..37edd92d 100644 --- a/templates/Watcher/watcher.py +++ b/templates/Background/background.py @@ -1,30 +1,29 @@ -import json from src.agent.capability import MatchingCapability from src.main import AgentWorker from src.agent.capability_worker import CapabilityWorker from time import time -class WatcherCapabilityWatcher(MatchingCapability): + +class BackgroundCapabilityBackground(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None background_daemon_mode: bool = False - + # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} async def first_function(self): - self.worker.editor_logging_handler.info("%s: Watcher Called"%time()) + self.worker.editor_logging_handler.info("%s: Background Called" % time()) while True: - self.worker.editor_logging_handler.info("%s: watcher watching"%time()) - + self.worker.editor_logging_handler.info("%s: background watching" % time()) + message_history = self.capability_worker.get_full_message_history()[-10:] for message in message_history: - self.worker.editor_logging_handler.info("Role: %s, Message: %s"%(message.get("role",""), message.get("content",""))) + self.worker.editor_logging_handler.info("Role: %s, Message: %s" % (message.get("role", ""), message.get("content", ""))) # await self.capability_worker.speak("watching") # await self.capability_worker.play_from_audio_file("alarm.mp3") await self.worker.session_tasks.sleep(20.0) - # Resume the normal workflow self.capability_worker.resume_normal_flow() diff --git a/templates/OpenHome-local/README.md b/templates/Local/README.md similarity index 100% rename from templates/OpenHome-local/README.md rename to templates/Local/README.md diff --git a/templates/Watcher/__init__.py b/templates/Local/__init__.py similarity index 100% rename from templates/Watcher/__init__.py rename to templates/Local/__init__.py diff --git a/templates/OpenHome-local/main.py b/templates/Local/main.py similarity index 97% rename from templates/OpenHome-local/main.py rename to templates/Local/main.py index 61c2bc55..5f1edcbf 100644 --- a/templates/OpenHome-local/main.py +++ b/templates/Local/main.py @@ -1,14 +1,14 @@ -import json from src.agent.capability import MatchingCapability from src.main import AgentWorker from src.agent.capability_worker import CapabilityWorker + class LocalCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - + # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} def get_system_prompt(self): system_prompt = """ @@ -48,11 +48,11 @@ async def first_function(self): system_prompt, ) self.worker.editor_logging_handler.info(terminal_command) - + # Clean up the response (remove any extra whitespace or newlines) terminal_command = terminal_command.strip() self.worker.editor_logging_handler.info(terminal_command) - + history.append( { "role": "user", @@ -75,7 +75,7 @@ async def first_function(self): command earlier based on user input now tell if that was successful or not in easier terms that can be directly spoken to the user for his understanding but if user wanted to get the information that's in the response return that response too.""" result = self.capability_worker.text_to_text_response( - "check if the command successfully ran? response is: %s"%response, + "check if the command successfully ran? response is: %s" % response, history, check_response_system_prompt, ) diff --git a/templates/openclaw-template/README.md b/templates/OpenClaw/README.md similarity index 100% rename from templates/openclaw-template/README.md rename to templates/OpenClaw/README.md diff --git a/templates/openclaw-template/__init__.py b/templates/OpenClaw/__init__.py similarity index 100% rename from templates/openclaw-template/__init__.py rename to templates/OpenClaw/__init__.py diff --git a/templates/openclaw-template/main.py b/templates/OpenClaw/main.py similarity index 95% rename from templates/openclaw-template/main.py rename to templates/OpenClaw/main.py index 1e92b9cb..907afc11 100644 --- a/templates/openclaw-template/main.py +++ b/templates/OpenClaw/main.py @@ -1,21 +1,20 @@ -import json from src.agent.capability import MatchingCapability from src.main import AgentWorker from src.agent.capability_worker import CapabilityWorker + class OpentestCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - - # Do not change following tag of register capability - #{{register capability}} + # Do not change following tag of register capability + # {{register capability}} async def first_function(self): user_inquiry = await self.capability_worker.wait_for_complete_transcription() history = [] - + history.append( { "role": "user", @@ -34,7 +33,7 @@ async def first_function(self): self.worker.editor_logging_handler.info(response) # Speak the response - + await self.capability_worker.speak(response["data"]) # Resume the normal workflow self.capability_worker.resume_normal_flow() diff --git a/templates/README.md b/templates/README.md index 69568ea9..71ddc812 100644 --- a/templates/README.md +++ b/templates/README.md @@ -32,12 +32,12 @@ Every ability you build falls into one of three categories: | Type | Trigger | Lifecycle | Entry File | |---|---|---|---| | **Skill** | User hotword or brain routing | Runs once, exits | `main.py` | -| **Background Daemon** | Automatic on session start | Runs continuously in a loop | `watcher.py` | +| **Background Daemon** | Automatic on session start | Runs continuously in a loop | `background.py` | | **Local** | User or system | Runs on-device hardware | DevKit SDK | **Skill** is the workhorse. A user says a hotword (or the brain's routing LLM invokes it), the ability runs, does its thing, and hands control back via `resume_normal_flow()`. -**Background Daemon** starts automatically when a user connects and runs in a `while True` loop for the entire session. No hotword needed. It can monitor conversations, poll APIs, watch for time-based events, and interrupt the main flow when something fires. Daemons can be standalone (`watcher.py` only) or combined with a Skill (`main.py` + `watcher.py`) coordinating through shared file storage. +**Background Daemon** starts automatically when a user connects and runs in a `while True` loop for the entire session. No hotword needed. It can monitor conversations, poll APIs, watch for time-based events, and interrupt the main flow when something fires. Daemons can be standalone (`background.py` only) or combined with a Skill (`main.py` + `background.py`) coordinating through shared file storage. **Local** abilities run directly on Raspberry Pi hardware, bypassing the cloud sandbox entirely. They can use unrestricted Python packages, GPIO pins, and local models. *(Currently under active development.)* @@ -53,11 +53,11 @@ templates/ ├── loop-template/ ← Long-running looped Skill (ambient observer). │ ├── SendEmail/ ← Fire-and-forget SDK method call. -├── OpenHome-local/ ← LLM as translator; execute on local machine. -├── openclaw-template/ ← Escape the sandbox via OpenClaw. +├── Local/ ← LLM as translator; execute on local machine. +├── OpenClaw/ ← Escape the sandbox via OpenClaw. │ -├── Watcher/ ← Standalone background daemon. -├── Alarm/ ← Combined Skill + Daemon (main.py + watcher.py). +├── Background/ ← Standalone background daemon. +├── Alarm/ ← Combined Skill + Daemon (main.py + background.py). │ └── ReadWriteFile/ ← Shared file storage / IPC between Skill & Daemon. ``` @@ -110,7 +110,7 @@ Every Skill **must** call this when done. It hands control back to the agent's n --- -#### [`OpenHome-local`](./templates/OpenHome-local) — LLM as Translator (Mac Terminal) +#### [`Local`](./templates/Local) — LLM as Translator (Mac Terminal) **Type:** Skill · **Pattern:** LLM-as-translator · **Complexity:** Medium You say `"list all my Python files"` and the speaker translates that into a real terminal command (`find . -name "*.py"`), runs it on your local machine, and reads back the result in plain English. Two LLM calls bookend the local execution — one translates speech → command, another translates raw output → human speech. @@ -126,7 +126,7 @@ Abilities run in OpenHome's cloud sandbox, not on your local machine. `exec_loca --- -#### [`openclaw-template`](./templates/openclaw-template) — Sandbox Escape +#### [`OpenClaw`](./templates/OpenClaw) — Sandbox Escape **Type:** Skill · **Pattern:** Sandbox escape · **Complexity:** Minimal Forwards the user's raw speech directly to OpenClaw — a desktop AI agent with 2,800+ community skills. OpenClaw processes it on your local machine and returns the result. The speaker becomes a voice interface for your entire desktop. @@ -149,12 +149,12 @@ self.capability_worker.resume_normal_flow() --- -#### [`Watcher`](./templates/Watcher) + [`Alarm`](./templates/Alarm) — Background Daemon +#### [`Background`](./templates/Background) + [`Alarm`](./templates/Alarm) — Background Daemon **Type:** Background Daemon · **Pattern:** Poll loop · **Complexity:** Medium -**`Watcher`** is the standalone daemon template. It starts automatically when a user connects and runs in an infinite loop — in this template, reading conversation history and logging it every 20 seconds. This is the most architecturally significant pattern in the system: before daemons, every ability was reactive. Now they can be proactive. +**`Background`** is the standalone daemon template. It starts automatically when a user connects and runs in an infinite loop — in this template, reading conversation history and logging it every 20 seconds. This is the most architecturally significant pattern in the system: before daemons, every ability was reactive. Now they can be proactive. -**`Alarm`** is the combined template: `main.py` (Skill) + `watcher.py` (Daemon) working together. The Skill parses `"set an alarm for 3pm"` and writes to `alarms.json`. The Daemon polls that file every 15 seconds and fires `send_interrupt_signal()` + `play_from_audio_file("alarm.mp3")` when the target time hits. They coordinate through shared files, not direct function calls. +**`Alarm`** is the combined template: `main.py` (Skill) + `background.py` (Daemon) working together. The Skill parses `"set an alarm for 3pm"` and writes to `alarms.json`. The Daemon polls that file every 15 seconds and fires `send_interrupt_signal()` + `play_from_audio_file("alarm.mp3")` when the target time hits. They coordinate through shared files, not direct function calls. **Key SDK methods:** `get_full_message_history()`, `send_interrupt_signal()`, `session_tasks.sleep()`, `play_from_audio_file()` @@ -198,7 +198,7 @@ Demonstrates nearly every advanced SDK pattern: raw audio recording, external AP #### [`ReadWriteFile`](./templates/ReadWriteFile) — Shared File Storage / IPC **Type:** Skill · **Complexity:** Minimal -Demonstrates how Skills and Daemons coordinate through shared file storage — the primary IPC mechanism between `main.py` and `watcher.py`. Used by the `Alarm` template. +Demonstrates how Skills and Daemons coordinate through shared file storage — the primary IPC mechanism between `main.py` and `background.py`. Used by the `Alarm` template. **Critical rule:** Always **delete** the file before writing a JSON object. `write_file()` appends — calling it twice will corrupt your JSON. @@ -217,9 +217,9 @@ self.capability_worker.write_file("state.json", json.dumps(data)) | `basic-template` | Skill | `speak()`, `resume_normal_flow()` | | `api-template` | Skill | `text_to_text_response()`, `resume_normal_flow()` | | `SendEmail` | Skill | `send_email()`, `speak()`, `resume_normal_flow()` | -| `OpenHome-local` | Skill | `text_to_text_response()`, `exec_local_command()` | -| `openclaw-template` | Skill | `exec_local_command()`, `speak()` | -| `Watcher` | Background Daemon | `get_full_message_history()`, `session_tasks.sleep()` | +| `Local` | Skill | `text_to_text_response()`, `exec_local_command()` | +| `OpenClaw` | Skill | `exec_local_command()`, `speak()` | +| `Background` | Background Daemon | `get_full_message_history()`, `session_tasks.sleep()` | | `Alarm` | Skill + Daemon | `send_interrupt_signal()`, `play_from_audio_file()`, `session_tasks.sleep()` | | `loop-template` | Skill (long-running) | `start_audio_recording()`, `get_audio_recording()`, `text_to_text_response()` | | `ReadWriteFile` | Utility / IPC | `read_file()`, `delete_file()`, `write_file()` | @@ -232,7 +232,7 @@ self.capability_worker.write_file("state.json", json.dumps(data)) 2. **Copy the folder** and rename it to your ability name. 3. **Replace hardcoded values** — API keys, emails, URLs — with user-collected input or environment config. 4. **Add guardrails** — error handling, confirmation steps, and safety checks appropriate for your use case. -5. **Coordinating a Skill + Daemon?** Use the `ReadWriteFile` pattern to pass state between `main.py` and `watcher.py` via shared JSON files. +5. **Coordinating a Skill + Daemon?** Use the `ReadWriteFile` pattern to pass state between `main.py` and `background.py` via shared JSON files. For full SDK documentation, see the [OpenHome Developer Docs](./docs).