From 6c1d2a5c0ab15a16865846a8b4bb60d9f9c43744 Mon Sep 17 00:00:00 2001 From: markdelta24 Date: Thu, 19 Feb 2026 17:58:17 +0530 Subject: [PATCH 1/6] feat: add N8N Commander ability --- community/n8n-commander/README.md | 192 +++++ community/n8n-commander/__init__.py | 0 community/n8n-commander/config.json | 28 + community/n8n-commander/main.py | 696 ++++++++++++++++++ .../n8n_commander_prefs_example.json | 89 +++ 5 files changed, 1005 insertions(+) create mode 100644 community/n8n-commander/README.md create mode 100644 community/n8n-commander/__init__.py create mode 100644 community/n8n-commander/config.json create mode 100644 community/n8n-commander/main.py create mode 100644 community/n8n-commander/n8n_commander_prefs_example.json diff --git a/community/n8n-commander/README.md b/community/n8n-commander/README.md new file mode 100644 index 00000000..561f630e --- /dev/null +++ b/community/n8n-commander/README.md @@ -0,0 +1,192 @@ +# N8N Commander Ability + +A voice-driven automation bridge that turns your OpenHome speaker into a universal voice remote for your entire software stack. Say what you want done — "post to Slack," "create a task," "log this to the spreadsheet" — and the ability routes the request to the right n8n webhook, which triggers a pre-built automation workflow. + +One ability, unlimited integrations. n8n has 400+ built-in service integrations. + +## Features + +- Trigger any n8n webhook workflow by voice +- Two-pass intent classification (keyword prefilter + LLM) +- Fire-and-forget workflows (quick confirmations) +- Round-trip response workflows (n8n returns data, ability speaks it back) +- Confirmation loop for sensitive actions +- Optional webhook authentication via custom headers +- Optional Twilio SMS for long responses and URLs +- Voice-optimized output formatting +- Multi-turn conversation loop with follow-up support + +## Requirements + +- Python 3.8+ +- `requests` library +- An n8n instance (Cloud at [n8n.io](https://n8n.io) or self-hosted) with webhook workflows + +## Installation + +1. Copy the ability folder into your agent's abilities directory. + +2. Create your `n8n_commander_prefs.json` preferences file (see `n8n_commander_prefs_example.json` for the full template). The ability will create a default empty prefs file on first run if none exists. + +3. Register the ability with your agent: + +```python +N8nCommanderCapability.register_capability() +``` + +## How It Works + +``` +User speaks trigger phrase + | +OpenHome triggers N8N Commander ability + | +Ability listens -> captures user intent via voice + | +Two-pass classification: + 1. Keyword prefilter (scan trigger_phrases) + 2. LLM classifies: which workflow? what parameters? + | +HTTP POST -> n8n webhook URL (with JSON payload) + | +n8n workflow runs (Slack post, Jira ticket, Sheets row, etc.) + | +If expects_response: speak back the result +If fire-and-forget: speak confirmation + | +Loop: "Anything else?" or exit +``` + +## Setting Up n8n Workflows + +### Step 1: Create a Workflow in n8n + +1. Open n8n (Cloud at app.n8n.cloud or self-hosted) +2. Click "New Workflow" +3. Add a **Webhook** node as the trigger +4. Set HTTP Method to **POST** +5. Note the **Production URL** — this goes in your prefs file + +### Step 2: Configure the Webhook Node + +- **Fire-and-forget** workflows: Set Respond to "Immediately" +- **Response** workflows: Set Respond to "When Last Node Finishes" + +### Step 3: Add Action Nodes + +After the Webhook node, add whatever n8n nodes you need (Slack, Google Sheets, Jira, Gmail, Home Assistant, etc.) + +### Step 4: Activate and Test + +Activate the workflow, then test with curl: + +```bash +curl -X POST "https://your-instance.app.n8n.cloud/webhook/your-path" \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello from OpenHome!", "params": {}}' +``` + +### Step 5: Add to Preferences + +Paste the webhook URL into your `n8n_commander_prefs.json` under the appropriate workflow entry. + +## Webhook Payload Format + +Every webhook call sends this JSON structure: + +```json +{ + "workflow_id": "slack", + "action": "Post to Slack", + "message": "the deploy is done", + "params": {"channel": "#general"}, + "raw_utterance": "tell the team the deploy is done", + "timestamp": "2026-02-19T09:00:00Z", + "source": "openhome_voice" +} +``` + +## Webhook Response Format (for expects_response workflows) + +n8n should return: + +```json +{ + "success": true, + "spoken_response": "Your task was created. Ticket ID is MAIN-4527." +} +``` + +The `spoken_response` field controls what the ability says back. If omitted, the ability falls back to "Done. The workflow ran successfully." + +## Configuration + +### Preferences File Fields + +| Field | Purpose | +|---|---| +| `n8n_base_url` | Base URL of your n8n instance | +| `workflows` | Dictionary of named workflows | +| `webhook_url` | The full production webhook URL from n8n | +| `description` | Human-readable — the LLM uses this to match intent | +| `default_params` | Pre-filled values merged with LLM-extracted params | +| `trigger_phrases` | Keyword hints for faster routing | +| `confirm_before_send` | If true, asks for confirmation before firing | +| `expects_response` | If true, waits for n8n's JSON response | +| `webhook_auth` | Optional header-based authentication | +| `phone_number` | Optional phone number for Twilio SMS | + +### Webhook Authentication (Optional) + +Add to your prefs file: + +```json +{ + "webhook_auth": { + "type": "header", + "header_name": "X-OpenHome-Key", + "header_value": "your-secret-key-here" + } +} +``` + +Then set the same header auth in your n8n Webhook node. + +### Twilio SMS (Optional) + +For long responses that are better sent as text: + +```json +{ + "phone_number": "+15125551234", + "twilio_account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "twilio_auth_token": "your_auth_token_here", + "twilio_from_number": "+18005551234" +} +``` + +## Exit Words + +Users can end the session at any time by saying: + +> exit, stop, quit, done, cancel, bye, goodbye, never mind + +## Error Handling + +| Error | Spoken Response | +|---|---| +| No workflows configured | "You haven't configured any workflows yet..." | +| Webhook returns 404 | "That workflow's webhook isn't responding..." | +| Webhook returns 401 | "The webhook requires authentication..." | +| Webhook returns 500 | "Something went wrong inside the n8n workflow..." | +| Request timeout | "The workflow is taking too long..." | +| Network error | "I can't reach your n8n instance..." | +| Can't classify intent | "I'm not sure which workflow to use. Your options are..." | + +## Logging + +The ability logs to `worker.editor_logging_handler` with the prefix `[N8nCommander]`. Check these logs to debug intent classification, webhook calls, and response handling. + +## License + +Refer to your agent framework's license. n8n usage is subject to their [terms of service](https://n8n.io/legal). diff --git a/community/n8n-commander/__init__.py b/community/n8n-commander/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/n8n-commander/config.json b/community/n8n-commander/config.json new file mode 100644 index 00000000..17a48a79 --- /dev/null +++ b/community/n8n-commander/config.json @@ -0,0 +1,28 @@ +{ + "unique_name": "n8n_commander", + "matching_hotwords": [ + "trigger workflow", + "run workflow", + "n8n", + "automation", + "post to slack", + "create a task", + "add a task", + "log this", + "file a ticket", + "create a bug", + "smart home", + "turn on", + "turn off", + "daily summary", + "morning briefing", + "draft an email", + "trigger automation", + "run automation", + "run my workflow", + "execute workflow", + "what can you do", + "report an issue", + "create an issue" + ] +} diff --git a/community/n8n-commander/main.py b/community/n8n-commander/main.py new file mode 100644 index 00000000..08270e2d --- /dev/null +++ b/community/n8n-commander/main.py @@ -0,0 +1,696 @@ +import json +import os +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple + +import requests +from requests.auth import HTTPBasicAuth +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +PREFS_FILENAME = "n8n_commander_prefs.json" + +DEFAULT_PREFS = { + "n8n_base_url": "https://your-n8n-instance.app.n8n.cloud", + "workflows": {}, + "webhook_auth": {}, + "phone_number": "", + "twilio_account_sid": "", + "twilio_auth_token": "", + "twilio_from_number": "", + "times_used": 0, +} + +EXIT_WORDS = {"exit", "stop", "quit", "done", "cancel", "bye", "goodbye", "never mind", "nevermind"} + +HELP_PHRASES = {"help", "what can you do", "list workflows", "what are my workflows", "show workflows"} + +# Replace with your own n8n webhook URL from https://n8n.io +PLACEHOLDER_WEBHOOK = "REPLACE_WITH_YOUR_WEBHOOK_URL" + +CLASSIFY_PROMPT = """You are a voice command router. Given the user's spoken input and the list of available workflows, determine: +1. Which workflow to trigger (or "none" if no match) +2. What parameters to extract from the speech +3. A brief confirmation message to read back to the user + +Available workflows: +{workflow_list} + +User said: "{user_input}" + +Return ONLY valid JSON: +{{ + "workflow_id": "string or none", + "confidence": 0.0 to 1.0, + "extracted_params": {{}}, + "message_content": "the core message or action description", + "confirmation_text": "what to say back to confirm" +}}""" + + +class N8nCommanderCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + initial_request: Optional[str] = None + last_utterance: str = "" + + @classmethod + def register_capability(cls) -> "MatchingCapability": + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + ) as file: + data = json.load(file) + return cls( + unique_name=data["unique_name"], + matching_hotwords=data["matching_hotwords"], + ) + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.initial_request = None + try: + self.initial_request = worker.transcription + except Exception: + pass + if not self.initial_request: + try: + self.initial_request = worker.last_transcription + except Exception: + pass + if not self.initial_request: + try: + self.initial_request = worker.current_transcription + except Exception: + pass + self.worker.session_tasks.create(self.run()) + + # ── Logging ────────────────────────────────────────────────────── + + def _log_info(self, msg: str): + if self.worker: + self.worker.editor_logging_handler.info(msg) + + def _log_error(self, msg: str): + if self.worker: + self.worker.editor_logging_handler.error(msg) + + # ── JSON Helpers ───────────────────────────────────────────────── + + def _clean_json(self, raw: str) -> str: + cleaned = (raw or "").strip().replace("```json", "").replace("```", "").strip() + start = cleaned.find("{") + end = cleaned.rfind("}") + if start != -1 and end != -1 and end > start: + return cleaned[start : end + 1] + return cleaned + + # ── Prefs Management ───────────────────────────────────────────── + + async def load_prefs(self) -> dict: + try: + exists = await self.capability_worker.check_if_file_exists( + PREFS_FILENAME, False + ) + if exists: + raw = await self.capability_worker.read_file(PREFS_FILENAME, False) + if raw and raw.strip(): + return json.loads(raw) + except Exception as e: + self._log_error(f"[N8nCommander] Failed to load prefs: {e}") + return dict(DEFAULT_PREFS) + + async def save_prefs(self, prefs: dict): + try: + if await self.capability_worker.check_if_file_exists(PREFS_FILENAME, False): + await self.capability_worker.delete_file(PREFS_FILENAME, False) + await self.capability_worker.write_file( + PREFS_FILENAME, json.dumps(prefs, indent=2), False + ) + except Exception as e: + self._log_error(f"[N8nCommander] Failed to save prefs: {e}") + + # ── Trigger Context ────────────────────────────────────────────── + + def _best_initial_input(self) -> str: + if self.initial_request and self.initial_request.strip(): + return self.initial_request.strip() + try: + history = self.worker.agent_memory.full_message_history or [] + for msg in reversed(history): + role = str(msg.get("role", "")).lower() + content = str(msg.get("content", "") or "").strip() + if role == "user" and content: + return content + except Exception: + pass + return "" + + # ── Exit / Help Detection ──────────────────────────────────────── + + def _is_exit(self, text: Optional[str]) -> bool: + lowered = (text or "").lower().strip() + if not lowered: + return False + return any(word in lowered for word in EXIT_WORDS) + + def _is_help(self, text: Optional[str]) -> bool: + lowered = (text or "").lower().strip() + if not lowered: + return False + return any(phrase in lowered for phrase in HELP_PHRASES) + + # ── Keyword Prefilter (Phase 1) ────────────────────────────────── + + def keyword_prefilter(self, user_input: str, workflows: dict) -> List[Tuple[str, int]]: + input_lower = (user_input or "").lower() + scores: Dict[str, int] = {} + for wf_id, wf in workflows.items(): + score = 0 + for phrase in wf.get("trigger_phrases", []): + if phrase.lower() in input_lower: + score += len(phrase) + if score > 0: + scores[wf_id] = score + return sorted(scores.items(), key=lambda x: x[1], reverse=True) + + # ── LLM Intent Classification (Phase 2) ────────────────────────── + + def build_workflow_list(self, workflows: dict) -> str: + lines = [] + for wf_id, wf in workflows.items(): + lines.append( + f'- ID: "{wf_id}" | Name: {wf.get("name", wf_id)} | ' + f'Description: {wf.get("description", "No description")} | ' + f'Default params: {json.dumps(wf.get("default_params", {}))}' + ) + return "\n".join(lines) + + def classify_intent(self, user_input: str, workflows: dict) -> dict: + workflow_list = self.build_workflow_list(workflows) + prompt = CLASSIFY_PROMPT.format( + workflow_list=workflow_list, user_input=user_input + ) + fallback = { + "workflow_id": "none", + "confidence": 0.0, + "extracted_params": {}, + "message_content": "", + "confirmation_text": "", + } + try: + raw = self.capability_worker.text_to_text_response(prompt) + cleaned = self._clean_json(raw) + parsed = json.loads(cleaned) + result = { + "workflow_id": str(parsed.get("workflow_id", "none")).strip(), + "confidence": float(parsed.get("confidence", 0.0)), + "extracted_params": parsed.get("extracted_params", {}), + "message_content": str(parsed.get("message_content", "")), + "confirmation_text": str(parsed.get("confirmation_text", "")), + } + self._log_info(f"[N8nCommander] LLM classified: {result['workflow_id']} " + f"(confidence: {result['confidence']:.2f})") + return result + except Exception as e: + self._log_error(f"[N8nCommander] LLM classify error: {e}") + return fallback + + # ── Classification Decision Logic ──────────────────────────────── + + def resolve_intent( + self, user_input: str, workflows: dict + ) -> dict: + keyword_matches = self.keyword_prefilter(user_input, workflows) + llm_result = self.classify_intent(user_input, workflows) + + llm_wf = llm_result.get("workflow_id", "none") + confidence = llm_result.get("confidence", 0.0) + + if confidence >= 0.8 and llm_wf != "none" and llm_wf in workflows: + llm_result["source"] = "llm_high_confidence" + return llm_result + + top_keyword = keyword_matches[0][0] if keyword_matches else None + + if confidence >= 0.5 and llm_wf != "none" and llm_wf in workflows: + if top_keyword and top_keyword == llm_wf: + llm_result["source"] = "llm_keyword_agree" + return llm_result + elif top_keyword and top_keyword != llm_wf: + llm_result["source"] = "ambiguous" + llm_result["keyword_suggestion"] = top_keyword + return llm_result + else: + llm_result["source"] = "llm_medium_confidence" + return llm_result + + if top_keyword and top_keyword in workflows: + return { + "workflow_id": top_keyword, + "confidence": 0.4, + "extracted_params": llm_result.get("extracted_params", {}), + "message_content": llm_result.get("message_content", ""), + "confirmation_text": llm_result.get("confirmation_text", ""), + "source": "keyword_only", + } + + return { + "workflow_id": "none", + "confidence": 0.0, + "extracted_params": {}, + "message_content": "", + "confirmation_text": "", + "source": "no_match", + } + + # ── Webhook Headers (Phase 8) ──────────────────────────────────── + + def build_headers(self, prefs: dict) -> dict: + headers = {"Content-Type": "application/json"} + auth = prefs.get("webhook_auth", {}) + if ( + auth.get("type") == "header" + and auth.get("header_name") + and auth.get("header_value") + ): + headers[auth["header_name"]] = auth["header_value"] + return headers + + # ── Webhook Calling (Phase 3) ──────────────────────────────────── + + def call_webhook( + self, + prefs: dict, + workflow_id: str, + message_content: str, + extracted_params: dict, + ) -> dict: + workflow = prefs["workflows"][workflow_id] + webhook_url = workflow["webhook_url"] + + if not webhook_url.startswith("https://"): + return {"success": False, "error": "invalid_url"} + + params = {**workflow.get("default_params", {}), **extracted_params} + + payload = { + "workflow_id": workflow_id, + "action": workflow.get("name", workflow_id), + "message": message_content, + "params": params, + "raw_utterance": self.last_utterance, + "timestamp": datetime.now(timezone.utc).isoformat(), + "source": "openhome_voice", + } + + headers = self.build_headers(prefs) + timeout = 30 if workflow.get("expects_response") else 15 + + try: + response = requests.post( + webhook_url, json=payload, headers=headers, timeout=timeout + ) + if response.status_code == 200: + if workflow.get("expects_response"): + try: + return {"success": True, "data": response.json()} + except json.JSONDecodeError: + return {"success": True, "data": None} + else: + return {"success": True, "data": None} + else: + self._log_error( + f"[N8nCommander] Webhook {workflow_id} returned " + f"{response.status_code}: {response.text[:200]}" + ) + return { + "success": False, + "error": f"HTTP {response.status_code}", + "status": response.status_code, + } + except requests.exceptions.Timeout: + return {"success": False, "error": "timeout"} + except requests.exceptions.ConnectionError: + return {"success": False, "error": "connection_failed"} + except Exception as e: + self._log_error(f"[N8nCommander] Webhook call failed: {e}") + return {"success": False, "error": str(e)} + + # ── Error-to-Speech Mapping (Phase 7) ──────────────────────────── + + def error_to_speech(self, result: dict) -> str: + error = result.get("error", "") + status = result.get("status", 0) + + if error == "timeout": + return ( + "The workflow is taking too long. " + "It might still be running. Check n8n to confirm." + ) + if error == "connection_failed": + return ( + "I can't reach your n8n instance. " + "Make sure it's running and the URL is correct." + ) + if error == "invalid_url": + return ( + "That workflow's webhook URL doesn't look right. " + "Make sure it starts with https in the preferences file." + ) + if status == 404: + return ( + "That workflow's webhook isn't responding. " + "Make sure the workflow is activated in n8n." + ) + if status == 401: + return ( + "The webhook requires authentication. " + "Check that your credentials are configured in n8n." + ) + if status == 500: + return ( + "Something went wrong inside the n8n workflow. " + "Check the execution log in n8n for details." + ) + return "Something went wrong with that workflow. Please try again." + + # ── Response Handling (Phase 6) ────────────────────────────────── + + async def handle_webhook_response(self, result: dict, workflow: dict, prefs: dict): + if not result.get("success"): + await self.capability_worker.speak(self.error_to_speech(result)) + return + + data = result.get("data") + workflow_name = workflow.get("name", "the workflow") + + if not workflow.get("expects_response") or data is None: + await self.capability_worker.speak(f"Done. {workflow_name} triggered.") + return + + if isinstance(data, dict): + if not data.get("success", True): + error_msg = data.get("error", "unknown error") + await self.capability_worker.speak( + f"The workflow ran but reported an error: {error_msg}." + ) + return + + spoken = data.get("spoken_response", "") + sms_body = data.get("sms_body", "") + url_field = data.get("url", "") + + if spoken: + if len(spoken) > 300: + summary = spoken[:250].rsplit(" ", 1)[0] + "..." + await self.capability_worker.speak(summary) + if self._has_twilio(prefs): + sent = self.send_sms(prefs, spoken) + if sent: + await self.capability_worker.speak( + "I also texted you the full details." + ) + else: + await self.capability_worker.speak(spoken) + else: + await self.capability_worker.speak( + "Done. The workflow ran successfully." + ) + + if sms_body and self._has_twilio(prefs): + sent = self.send_sms(prefs, sms_body) + if sent: + await self.capability_worker.speak("I also texted you extra details.") + + if url_field and self._has_twilio(prefs) and not sms_body: + sent = self.send_sms(prefs, f"Link from your workflow: {url_field}") + if sent: + await self.capability_worker.speak( + "I texted you the link from that workflow." + ) + else: + await self.capability_worker.speak("Done. The workflow ran successfully.") + + # ── Twilio SMS (Optional) ──────────────────────────────────────── + + def _has_twilio(self, prefs: dict) -> bool: + return all([ + prefs.get("twilio_account_sid"), + prefs.get("twilio_auth_token"), + prefs.get("twilio_from_number"), + prefs.get("phone_number"), + ]) + + def send_sms(self, prefs: dict, message_body: str) -> bool: + account_sid = prefs.get("twilio_account_sid", "") + auth_token = prefs.get("twilio_auth_token", "") + from_number = prefs.get("twilio_from_number", "") + to_number = prefs.get("phone_number", "") + + if not all([account_sid, auth_token, from_number, to_number]): + self._log_info("[N8nCommander] Twilio not configured, skipping SMS") + return False + + url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" + + try: + response = requests.post( + url, + data={"From": from_number, "To": to_number, "Body": message_body}, + auth=HTTPBasicAuth(account_sid, auth_token), + timeout=15, + ) + if response.status_code == 201: + self._log_info(f"[N8nCommander] SMS sent to {to_number}") + return True + else: + self._log_error( + f"[N8nCommander] SMS failed: {response.status_code}" + ) + return False + except Exception as e: + self._log_error(f"[N8nCommander] SMS error: {e}") + return False + + # ── Help Handler ───────────────────────────────────────────────── + + async def speak_help(self, workflows: dict): + if not workflows: + await self.capability_worker.speak( + "You haven't set up any workflows yet." + ) + await self.capability_worker.speak( + "Add your n8n webhook URLs in the preferences file to get started." + ) + return + + count = len(workflows) + names = [] + for wf in workflows.values(): + name = wf.get("name", "unnamed") + names.append(name) + + display_names = names[:5] + + if count > 1: + await self.capability_worker.speak(f"You have {count} workflows set up.") + else: + await self.capability_worker.speak("You have one workflow set up.") + + await self.capability_worker.speak(", ".join(display_names) + ".") + + if count > 5: + await self.capability_worker.speak(f"Plus {count - 5} more.") + + await self.capability_worker.speak("Which one would you like?") + + # ── Main Conversation Loop (Phase 5) ───────────────────────────── + + async def run(self): + try: + if self.worker: + await self.worker.session_tasks.sleep(0.2) + + prefs = await self.load_prefs() + workflows = prefs.get("workflows", {}) + + # Phase 0: Detect empty workflows + if not workflows: + await self.capability_worker.speak( + "Hey, I'm your automation assistant." + ) + await self.capability_worker.speak( + "You haven't configured any workflows yet. " + "Add your n8n webhook URLs in the preferences file to get started." + ) + # Create default prefs file on first run + exists = await self.capability_worker.check_if_file_exists( + PREFS_FILENAME, False + ) + if not exists: + await self.save_prefs(DEFAULT_PREFS) + return + + # Entry message + count = len(workflows) + await self.capability_worker.speak( + f"Hey, I'm your automation assistant. " + f"I can trigger your n8n workflows by voice." + ) + await self.capability_worker.speak( + f"You have {count} workflow{'s' if count != 1 else ''} set up. " + f"Say help to hear them, or tell me what you need." + ) + + # Check if the initial trigger already contains a command + initial_input = self._best_initial_input() + current_input = "" + + # See if the trigger phrase itself contains a workflow command + if initial_input: + keyword_hits = self.keyword_prefilter(initial_input, workflows) + if keyword_hits: + current_input = initial_input + + # Main loop + idle_count = 0 + while True: + # Get user input if we don't already have it + if not current_input: + current_input = await self.capability_worker.run_io_loop( + "What would you like to do?" + ) + + user_text = (current_input or "").strip() + self.last_utterance = user_text + current_input = "" + + # Empty input + if not user_text: + idle_count += 1 + if idle_count >= 2: + await self.capability_worker.speak( + "I'm still here if you need anything. Otherwise I'll sign off." + ) + final = await self.capability_worker.run_io_loop("") + if not final or not final.strip() or self._is_exit(final): + await self.capability_worker.speak("See you later.") + return + current_input = final.strip() + idle_count = 0 + continue + continue + + idle_count = 0 + + # Exit check + if self._is_exit(user_text): + await self.capability_worker.speak("See you later.") + return + + # Help check + if self._is_help(user_text): + await self.speak_help(workflows) + continue + + # Classify intent + await self.capability_worker.speak("One sec, figuring that out.") + intent = self.resolve_intent(user_text, workflows) + wf_id = intent.get("workflow_id", "none") + confidence = intent.get("confidence", 0.0) + source = intent.get("source", "") + + # No match + if wf_id == "none" or wf_id not in workflows: + wf_names = [ + wf.get("name", wf_id) + for wf_id, wf in workflows.items() + ] + names_str = ", ".join(wf_names[:5]) + await self.capability_worker.speak( + f"I'm not sure which workflow to use. " + f"Your options are: {names_str}. Which one?" + ) + continue + + # Ambiguous match — ask user to pick + if source == "ambiguous": + keyword_wf = intent.get("keyword_suggestion", "") + llm_name = workflows[wf_id].get("name", wf_id) + kw_name = workflows.get(keyword_wf, {}).get("name", keyword_wf) + clarify = await self.capability_worker.run_io_loop( + f"Did you mean {llm_name} or {kw_name}?" + ) + if clarify and clarify.strip(): + current_input = clarify.strip() + continue + + # Low confidence — ask for confirmation + if source == "keyword_only": + wf_name = workflows[wf_id].get("name", wf_id) + confirm_text = f"Did you mean {wf_name}?" + confirmed = await self.capability_worker.run_confirmation_loop( + confirm_text + ) + if not confirmed: + await self.capability_worker.speak( + "Okay. Which workflow did you want?" + ) + continue + + # We have a valid workflow — execute it + workflow = workflows[wf_id] + extracted_params = intent.get("extracted_params", {}) + message_content = intent.get("message_content", "") + confirmation_text = intent.get("confirmation_text", "") + + # Phase 4: Confirmation loop + if workflow.get("confirm_before_send", False): + if confirmation_text: + confirm_msg = confirmation_text + " Go ahead?" + else: + confirm_msg = ( + f"I'll trigger {workflow.get('name', wf_id)}. Go ahead?" + ) + confirmed = await self.capability_worker.run_confirmation_loop( + confirm_msg + ) + if not confirmed: + await self.capability_worker.speak( + "Okay, cancelled. What else?" + ) + continue + + # Phase 3 & 6: Execute webhook + await self.capability_worker.speak("Standby, running that now.") + result = self.call_webhook( + prefs, wf_id, message_content, extracted_params + ) + + # Handle response + await self.handle_webhook_response(result, workflow, prefs) + + # Update usage count + prefs["times_used"] = prefs.get("times_used", 0) + 1 + await self.save_prefs(prefs) + + # Continue loop + follow_up = await self.capability_worker.run_io_loop( + "Anything else?" + ) + if not follow_up or not follow_up.strip() or self._is_exit(follow_up): + await self.capability_worker.speak("See you later.") + return + + current_input = follow_up.strip() + + except Exception as e: + self._log_error(f"[N8nCommander] Unexpected error: {e}") + if self.capability_worker: + await self.capability_worker.speak( + "Sorry, something went wrong. Please try again." + ) + finally: + self.capability_worker.resume_normal_flow() diff --git a/community/n8n-commander/n8n_commander_prefs_example.json b/community/n8n-commander/n8n_commander_prefs_example.json new file mode 100644 index 00000000..9f532b34 --- /dev/null +++ b/community/n8n-commander/n8n_commander_prefs_example.json @@ -0,0 +1,89 @@ +{ + "n8n_base_url": "https://your-n8n-instance.app.n8n.cloud", + "workflows": { + "slack": { + "name": "Post to Slack", + "webhook_url": "REPLACE_WITH_YOUR_WEBHOOK_URL", + "description": "Posts a message to a Slack channel", + "default_params": { + "channel": "#general" + }, + "trigger_phrases": ["post to slack", "tell the team", "message the channel", "slack message"], + "confirm_before_send": true, + "expects_response": false + }, + "task": { + "name": "Create Task", + "webhook_url": "REPLACE_WITH_YOUR_WEBHOOK_URL", + "description": "Creates a task in the project management tool", + "default_params": { + "project": "General", + "priority": "medium" + }, + "trigger_phrases": ["add a task", "create a task", "new task", "add to my to-do", "remind me to"], + "confirm_before_send": true, + "expects_response": false + }, + "log_to_sheet": { + "name": "Log to Spreadsheet", + "webhook_url": "REPLACE_WITH_YOUR_WEBHOOK_URL", + "description": "Appends a row to a Google Sheet", + "default_params": { + "sheet_name": "Log" + }, + "trigger_phrases": ["log this", "add to the spreadsheet", "record this", "log to sheet"], + "confirm_before_send": false, + "expects_response": false + }, + "create_issue": { + "name": "Create Bug Report", + "webhook_url": "REPLACE_WITH_YOUR_WEBHOOK_URL", + "description": "Creates a Jira or GitHub issue", + "default_params": { + "project": "MAIN", + "type": "bug", + "priority": "medium" + }, + "trigger_phrases": ["create a bug", "file a ticket", "report an issue", "new jira ticket", "create an issue"], + "confirm_before_send": true, + "expects_response": true + }, + "daily_summary": { + "name": "Daily Summary", + "webhook_url": "REPLACE_WITH_YOUR_WEBHOOK_URL", + "description": "Fetches today's tasks, calendar, and unread messages", + "default_params": {}, + "trigger_phrases": ["daily summary", "what's on my plate", "what do I have today", "morning briefing"], + "confirm_before_send": false, + "expects_response": true + }, + "draft_email": { + "name": "Draft Email", + "webhook_url": "REPLACE_WITH_YOUR_WEBHOOK_URL", + "description": "Creates a Gmail draft (does NOT send)", + "default_params": {}, + "trigger_phrases": ["draft an email", "write an email", "compose an email", "email draft"], + "confirm_before_send": true, + "expects_response": true + }, + "smart_home": { + "name": "Smart Home Control", + "webhook_url": "REPLACE_WITH_YOUR_WEBHOOK_URL", + "description": "Controls Home Assistant devices", + "default_params": {}, + "trigger_phrases": ["turn on", "turn off", "set the", "lights", "thermostat", "lock the", "unlock the"], + "confirm_before_send": false, + "expects_response": false + } + }, + "webhook_auth": { + "type": "header", + "header_name": "X-OpenHome-Key", + "header_value": "REPLACE_WITH_YOUR_SECRET_KEY" + }, + "phone_number": "", + "twilio_account_sid": "", + "twilio_auth_token": "", + "twilio_from_number": "", + "times_used": 0 +} From d6b612742275951be032aee52d905844770e99ee Mon Sep 17 00:00:00 2001 From: markdelta24 Date: Thu, 19 Feb 2026 23:51:04 +0530 Subject: [PATCH 2/6] cleanup comments and docstrings --- community/n8n-commander/main.py | 209 ++++++++++++++++++++++++++------ 1 file changed, 171 insertions(+), 38 deletions(-) diff --git a/community/n8n-commander/main.py b/community/n8n-commander/main.py index 08270e2d..34747c0f 100644 --- a/community/n8n-commander/main.py +++ b/community/n8n-commander/main.py @@ -9,8 +9,10 @@ from src.agent.capability_worker import CapabilityWorker from src.main import AgentWorker +# where we store user's workflow config between sessions PREFS_FILENAME = "n8n_commander_prefs.json" +# fresh install defaults — no workflows configured yet DEFAULT_PREFS = { "n8n_base_url": "https://your-n8n-instance.app.n8n.cloud", "workflows": {}, @@ -22,13 +24,17 @@ "times_used": 0, } +# user says any of these and we bail out of the conversation EXIT_WORDS = {"exit", "stop", "quit", "done", "cancel", "bye", "goodbye", "never mind", "nevermind"} +# these trigger the help/listing flow instead of a workflow HELP_PHRASES = {"help", "what can you do", "list workflows", "what are my workflows", "show workflows"} -# Replace with your own n8n webhook URL from https://n8n.io +# sign up for free at https://n8n.io to get your own webhook URLs PLACEHOLDER_WEBHOOK = "REPLACE_WITH_YOUR_WEBHOOK_URL" +# prompt we feed to the LLM to figure out which workflow the user wants +# the double braces {{ }} are escaped so .format() doesn't eat them CLASSIFY_PROMPT = """You are a voice command router. Given the user's spoken input and the list of available workflows, determine: 1. Which workflow to trigger (or "none" if no match) 2. What parameters to extract from the speech @@ -50,13 +56,21 @@ class N8nCommanderCapability(MatchingCapability): + """Voice-controlled n8n workflow trigger for OpenHome. + + Listens for voice commands, classifies which workflow the user wants + using keywords + LLM, then fires the matching n8n webhook. + Supports fire-and-forget and round-trip response workflows. + """ + worker: AgentWorker = None capability_worker: CapabilityWorker = None initial_request: Optional[str] = None - last_utterance: str = "" + last_utterance: str = "" # raw text of what user last said, sent in webhook payload @classmethod def register_capability(cls) -> "MatchingCapability": + """Load trigger hotwords from config.json — standard pattern for all abilities.""" with open( os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") ) as file: @@ -67,9 +81,16 @@ def register_capability(cls) -> "MatchingCapability": ) def call(self, worker: AgentWorker): + """Entry point when the platform matches our hotwords. + + We try a few different attrs to grab the user's original speech + because different SDK versions expose it differently. + """ self.worker = worker self.capability_worker = CapabilityWorker(self.worker) self.initial_request = None + + # try to grab what the user actually said that triggered us try: self.initial_request = worker.transcription except Exception: @@ -84,9 +105,11 @@ def call(self, worker: AgentWorker): self.initial_request = worker.current_transcription except Exception: pass + + # kick off the main loop as an async task self.worker.session_tasks.create(self.run()) - # ── Logging ────────────────────────────────────────────────────── + # logging — uses the platform logger so output shows in editor console def _log_info(self, msg: str): if self.worker: @@ -96,9 +119,14 @@ def _log_error(self, msg: str): if self.worker: self.worker.editor_logging_handler.error(msg) - # ── JSON Helpers ───────────────────────────────────────────────── + # json helpers def _clean_json(self, raw: str) -> str: + """Strip markdown fences and grab just the JSON object. + + The LLM sometimes wraps its response in ```json ... ``` blocks, + so we need to peel that off before parsing. + """ cleaned = (raw or "").strip().replace("```json", "").replace("```", "").strip() start = cleaned.find("{") end = cleaned.rfind("}") @@ -106,9 +134,15 @@ def _clean_json(self, raw: str) -> str: return cleaned[start : end + 1] return cleaned - # ── Prefs Management ───────────────────────────────────────────── + # prefs management + # stored via the platform file API (not local disk) so they + # persist across sessions and devices for each user async def load_prefs(self) -> dict: + """Load user preferences from persistent storage. + + Returns defaults if file doesn't exist yet (first run). + """ try: exists = await self.capability_worker.check_if_file_exists( PREFS_FILENAME, False @@ -122,6 +156,12 @@ async def load_prefs(self) -> dict: return dict(DEFAULT_PREFS) async def save_prefs(self, prefs: dict): + """Save prefs back to persistent storage. + + Important: we have to delete first then write — the platform + file API appends on write, so writing over an existing JSON + file would corrupt it without the delete step. + """ try: if await self.capability_worker.check_if_file_exists(PREFS_FILENAME, False): await self.capability_worker.delete_file(PREFS_FILENAME, False) @@ -131,9 +171,16 @@ async def save_prefs(self, prefs: dict): except Exception as e: self._log_error(f"[N8nCommander] Failed to save prefs: {e}") - # ── Trigger Context ────────────────────────────────────────────── + # trigger context def _best_initial_input(self) -> str: + """Try to recover the user's trigger phrase. + + Sometimes the trigger phrase itself contains the command + (e.g. "post to slack that the deploy is done"), so we want + to capture it and skip asking again. + Falls back to recent message history if transcription is empty. + """ if self.initial_request and self.initial_request.strip(): return self.initial_request.strip() try: @@ -147,23 +194,32 @@ def _best_initial_input(self) -> str: pass return "" - # ── Exit / Help Detection ──────────────────────────────────────── + # exit and help detection def _is_exit(self, text: Optional[str]) -> bool: + """Check if the user wants to leave.""" lowered = (text or "").lower().strip() if not lowered: return False return any(word in lowered for word in EXIT_WORDS) def _is_help(self, text: Optional[str]) -> bool: + """Check if the user is asking what we can do.""" lowered = (text or "").lower().strip() if not lowered: return False return any(phrase in lowered for phrase in HELP_PHRASES) - # ── Keyword Prefilter (Phase 1) ────────────────────────────────── + # keyword prefilter + # fast first pass — scan user speech against trigger_phrases + # defined in each workflow. longer matches score higher. def keyword_prefilter(self, user_input: str, workflows: dict) -> List[Tuple[str, int]]: + """Score each workflow by how many trigger phrases match the input. + + Returns sorted list of (workflow_id, score) — highest first. + Longer phrase matches get more weight since they're more specific. + """ input_lower = (user_input or "").lower() scores: Dict[str, int] = {} for wf_id, wf in workflows.items(): @@ -175,9 +231,12 @@ def keyword_prefilter(self, user_input: str, workflows: dict) -> List[Tuple[str, scores[wf_id] = score return sorted(scores.items(), key=lambda x: x[1], reverse=True) - # ── LLM Intent Classification (Phase 2) ────────────────────────── + # LLM intent classification + # second pass — send workflow list + user input to the LLM + # and let it figure out the best match with extracted params def build_workflow_list(self, workflows: dict) -> str: + """Format workflows into a readable list for the LLM prompt.""" lines = [] for wf_id, wf in workflows.items(): lines.append( @@ -188,6 +247,12 @@ def build_workflow_list(self, workflows: dict) -> str: return "\n".join(lines) def classify_intent(self, user_input: str, workflows: dict) -> dict: + """Ask the LLM which workflow matches the user's voice input. + + Note: text_to_text_response() is synchronous (no await). + Returns a dict with workflow_id, confidence, extracted_params, etc. + Falls back to a "no match" result if anything goes wrong. + """ workflow_list = self.build_workflow_list(workflows) prompt = CLASSIFY_PROMPT.format( workflow_list=workflow_list, user_input=user_input @@ -200,6 +265,7 @@ def classify_intent(self, user_input: str, workflows: dict) -> dict: "confirmation_text": "", } try: + # sync call — no await here, that's how the SDK works raw = self.capability_worker.text_to_text_response(prompt) cleaned = self._clean_json(raw) parsed = json.loads(cleaned) @@ -217,35 +283,51 @@ def classify_intent(self, user_input: str, workflows: dict) -> dict: self._log_error(f"[N8nCommander] LLM classify error: {e}") return fallback - # ── Classification Decision Logic ──────────────────────────────── + # classification decision logic + # combines keyword + LLM results to make a final call: + # high confidence LLM → trust it + # medium + keyword agrees → trust it + # medium + keyword disagrees → ask user + # keyword only → confirm first def resolve_intent( self, user_input: str, workflows: dict ) -> dict: + """Two-pass classification: keywords first, then LLM. + + Merges both results and decides whether we're confident enough + to just go, or need to ask the user for clarification. + """ keyword_matches = self.keyword_prefilter(user_input, workflows) llm_result = self.classify_intent(user_input, workflows) llm_wf = llm_result.get("workflow_id", "none") confidence = llm_result.get("confidence", 0.0) + # high confidence from LLM — just go with it if confidence >= 0.8 and llm_wf != "none" and llm_wf in workflows: llm_result["source"] = "llm_high_confidence" return llm_result top_keyword = keyword_matches[0][0] if keyword_matches else None + # medium confidence — check if keyword agrees if confidence >= 0.5 and llm_wf != "none" and llm_wf in workflows: if top_keyword and top_keyword == llm_wf: + # keyword and LLM agree, good enough llm_result["source"] = "llm_keyword_agree" return llm_result elif top_keyword and top_keyword != llm_wf: + # keyword and LLM disagree — need to ask user llm_result["source"] = "ambiguous" llm_result["keyword_suggestion"] = top_keyword return llm_result else: + # no keyword match but LLM is somewhat confident llm_result["source"] = "llm_medium_confidence" return llm_result + # LLM wasn't confident — fall back to keyword match if we have one if top_keyword and top_keyword in workflows: return { "workflow_id": top_keyword, @@ -256,6 +338,7 @@ def resolve_intent( "source": "keyword_only", } + # nothing matched at all return { "workflow_id": "none", "confidence": 0.0, @@ -265,9 +348,14 @@ def resolve_intent( "source": "no_match", } - # ── Webhook Headers (Phase 8) ──────────────────────────────────── + # webhook headers (optional auth) def build_headers(self, prefs: dict) -> dict: + """Build request headers, optionally including auth from prefs. + + If the user configured a webhook secret in their prefs, + we attach it as a custom header so n8n can validate it. + """ headers = {"Content-Type": "application/json"} auth = prefs.get("webhook_auth", {}) if ( @@ -278,7 +366,7 @@ def build_headers(self, prefs: dict) -> dict: headers[auth["header_name"]] = auth["header_value"] return headers - # ── Webhook Calling (Phase 3) ──────────────────────────────────── + # webhook calling def call_webhook( self, @@ -287,14 +375,23 @@ def call_webhook( message_content: str, extracted_params: dict, ) -> dict: + """POST to the n8n webhook URL for the given workflow. + + Merges default_params from config with whatever the LLM extracted + from the user's speech. Uses 15s timeout for fire-and-forget, + 30s for workflows that return data. + """ workflow = prefs["workflows"][workflow_id] webhook_url = workflow["webhook_url"] + # basic sanity check — don't send to http or garbage URLs if not webhook_url.startswith("https://"): return {"success": False, "error": "invalid_url"} + # merge defaults with whatever the LLM pulled from the voice input params = {**workflow.get("default_params", {}), **extracted_params} + # standard payload format — n8n workflow can use any of these fields payload = { "workflow_id": workflow_id, "action": workflow.get("name", workflow_id), @@ -306,6 +403,8 @@ def call_webhook( } headers = self.build_headers(prefs) + + # longer timeout for workflows that send back data timeout = 30 if workflow.get("expects_response") else 15 try: @@ -317,8 +416,10 @@ def call_webhook( try: return {"success": True, "data": response.json()} except json.JSONDecodeError: + # got 200 but body wasn't JSON — still a success return {"success": True, "data": None} else: + # fire-and-forget — don't care about the body return {"success": True, "data": None} else: self._log_error( @@ -338,9 +439,11 @@ def call_webhook( self._log_error(f"[N8nCommander] Webhook call failed: {e}") return {"success": False, "error": str(e)} - # ── Error-to-Speech Mapping (Phase 7) ──────────────────────────── + # error to speech mapping + # each error gets a specific spoken message so user knows what went wrong def error_to_speech(self, result: dict) -> str: + """Convert a webhook error into a friendly voice response.""" error = result.get("error", "") status = result.get("status", 0) @@ -376,9 +479,17 @@ def error_to_speech(self, result: dict) -> str: ) return "Something went wrong with that workflow. Please try again." - # ── Response Handling (Phase 6) ────────────────────────────────── + # response handling async def handle_webhook_response(self, result: dict, workflow: dict, prefs: dict): + """Process the webhook result and speak it back to the user. + + For fire-and-forget: just say "done". + For expects_response: read back the spoken_response from n8n. + If the response is too long (>300 chars), summarize it and + optionally text the full version via Twilio. + """ + # webhook failed — tell user what went wrong if not result.get("success"): await self.capability_worker.speak(self.error_to_speech(result)) return @@ -386,11 +497,13 @@ async def handle_webhook_response(self, result: dict, workflow: dict, prefs: dic data = result.get("data") workflow_name = workflow.get("name", "the workflow") + # fire-and-forget or no data came back if not workflow.get("expects_response") or data is None: await self.capability_worker.speak(f"Done. {workflow_name} triggered.") return if isinstance(data, dict): + # n8n reported an error in its own response if not data.get("success", True): error_msg = data.get("error", "unknown error") await self.capability_worker.speak( @@ -403,6 +516,7 @@ async def handle_webhook_response(self, result: dict, workflow: dict, prefs: dic url_field = data.get("url", "") if spoken: + # if response is really long, truncate for voice and text the rest if len(spoken) > 300: summary = spoken[:250].rsplit(" ", 1)[0] + "..." await self.capability_worker.speak(summary) @@ -419,11 +533,13 @@ async def handle_webhook_response(self, result: dict, workflow: dict, prefs: dic "Done. The workflow ran successfully." ) + # n8n can also send a separate sms_body for extra info if sms_body and self._has_twilio(prefs): sent = self.send_sms(prefs, sms_body) if sent: await self.capability_worker.speak("I also texted you extra details.") + # or just a URL the user might want on their phone if url_field and self._has_twilio(prefs) and not sms_body: sent = self.send_sms(prefs, f"Link from your workflow: {url_field}") if sent: @@ -433,9 +549,12 @@ async def handle_webhook_response(self, result: dict, workflow: dict, prefs: dic else: await self.capability_worker.speak("Done. The workflow ran successfully.") - # ── Twilio SMS (Optional) ──────────────────────────────────────── + # twilio sms (optional) + # only kicks in when user has configured twilio creds in prefs + # useful for sending URLs or long text that doesn't work as speech def _has_twilio(self, prefs: dict) -> bool: + """Quick check — are all 4 twilio fields filled in?""" return all([ prefs.get("twilio_account_sid"), prefs.get("twilio_auth_token"), @@ -444,6 +563,7 @@ def _has_twilio(self, prefs: dict) -> bool: ]) def send_sms(self, prefs: dict, message_body: str) -> bool: + """Send a text message via Twilio. Returns True if sent ok.""" account_sid = prefs.get("twilio_account_sid", "") auth_token = prefs.get("twilio_auth_token", "") from_number = prefs.get("twilio_from_number", "") @@ -474,9 +594,10 @@ def send_sms(self, prefs: dict, message_body: str) -> bool: self._log_error(f"[N8nCommander] SMS error: {e}") return False - # ── Help Handler ───────────────────────────────────────────────── + # help handler async def speak_help(self, workflows: dict): + """List available workflows by name so user can pick one.""" if not workflows: await self.capability_worker.speak( "You haven't set up any workflows yet." @@ -492,6 +613,7 @@ async def speak_help(self, workflows: dict): name = wf.get("name", "unnamed") names.append(name) + # only read out the first 5 so we don't bore the user display_names = names[:5] if count > 1: @@ -506,17 +628,24 @@ async def speak_help(self, workflows: dict): await self.capability_worker.speak("Which one would you like?") - # ── Main Conversation Loop (Phase 5) ───────────────────────────── + # main conversation loop async def run(self): + """Main loop — handles the full voice conversation flow. + + Flow: greet → listen → classify → confirm → fire webhook → respond → loop + Every exit path (return, exception) hits the finally block + which calls resume_normal_flow() to hand control back. + """ try: + # small delay to let the audio pipeline settle if self.worker: await self.worker.session_tasks.sleep(0.2) prefs = await self.load_prefs() workflows = prefs.get("workflows", {}) - # Phase 0: Detect empty workflows + # no workflows configured — guide the user and exit if not workflows: await self.capability_worker.speak( "Hey, I'm your automation assistant." @@ -525,7 +654,7 @@ async def run(self): "You haven't configured any workflows yet. " "Add your n8n webhook URLs in the preferences file to get started." ) - # Create default prefs file on first run + # drop a default prefs file so user has a template to fill in exists = await self.capability_worker.check_if_file_exists( PREFS_FILENAME, False ) @@ -533,7 +662,7 @@ async def run(self): await self.save_prefs(DEFAULT_PREFS) return - # Entry message + # greet the user count = len(workflows) await self.capability_worker.speak( f"Hey, I'm your automation assistant. " @@ -544,20 +673,21 @@ async def run(self): f"Say help to hear them, or tell me what you need." ) - # Check if the initial trigger already contains a command + # check if the trigger phrase already contains a command + # e.g. user said "post to slack that we shipped" — no need to ask again initial_input = self._best_initial_input() current_input = "" - # See if the trigger phrase itself contains a workflow command if initial_input: keyword_hits = self.keyword_prefilter(initial_input, workflows) if keyword_hits: current_input = initial_input - # Main loop + # track consecutive empty inputs so we can auto-exit idle_count = 0 + while True: - # Get user input if we don't already have it + # prompt for input if we don't already have something to process if not current_input: current_input = await self.capability_worker.run_io_loop( "What would you like to do?" @@ -565,12 +695,13 @@ async def run(self): user_text = (current_input or "").strip() self.last_utterance = user_text - current_input = "" + current_input = "" # reset for next iteration - # Empty input + # user didn't say anything if not user_text: idle_count += 1 if idle_count >= 2: + # been quiet too long — offer to sign off await self.capability_worker.speak( "I'm still here if you need anything. Otherwise I'll sign off." ) @@ -585,24 +716,25 @@ async def run(self): idle_count = 0 - # Exit check + # user wants to leave if self._is_exit(user_text): await self.capability_worker.speak("See you later.") return - # Help check + # user wants to know what we can do if self._is_help(user_text): await self.speak_help(workflows) continue - # Classify intent + # --- Intent classification --- + # filler speech while LLM thinks (this takes a sec) await self.capability_worker.speak("One sec, figuring that out.") intent = self.resolve_intent(user_text, workflows) wf_id = intent.get("workflow_id", "none") confidence = intent.get("confidence", 0.0) source = intent.get("source", "") - # No match + # couldn't figure out what they want if wf_id == "none" or wf_id not in workflows: wf_names = [ wf.get("name", wf_id) @@ -615,7 +747,7 @@ async def run(self): ) continue - # Ambiguous match — ask user to pick + # keyword and LLM disagree — ask user to pick if source == "ambiguous": keyword_wf = intent.get("keyword_suggestion", "") llm_name = workflows[wf_id].get("name", wf_id) @@ -627,7 +759,7 @@ async def run(self): current_input = clarify.strip() continue - # Low confidence — ask for confirmation + # matched on keyword only (low confidence) — double check if source == "keyword_only": wf_name = workflows[wf_id].get("name", wf_id) confirm_text = f"Did you mean {wf_name}?" @@ -640,13 +772,13 @@ async def run(self): ) continue - # We have a valid workflow — execute it + # --- We have a match, prepare to fire --- workflow = workflows[wf_id] extracted_params = intent.get("extracted_params", {}) message_content = intent.get("message_content", "") confirmation_text = intent.get("confirmation_text", "") - # Phase 4: Confirmation loop + # some workflows need explicit user confirmation before firing if workflow.get("confirm_before_send", False): if confirmation_text: confirm_msg = confirmation_text + " Go ahead?" @@ -663,20 +795,20 @@ async def run(self): ) continue - # Phase 3 & 6: Execute webhook + # --- Fire the webhook --- await self.capability_worker.speak("Standby, running that now.") result = self.call_webhook( prefs, wf_id, message_content, extracted_params ) - # Handle response + # speak the result back to the user await self.handle_webhook_response(result, workflow, prefs) - # Update usage count + # bump the usage counter prefs["times_used"] = prefs.get("times_used", 0) + 1 await self.save_prefs(prefs) - # Continue loop + # ask if they want to do something else follow_up = await self.capability_worker.run_io_loop( "Anything else?" ) @@ -693,4 +825,5 @@ async def run(self): "Sorry, something went wrong. Please try again." ) finally: + # always hand control back to the platform — no matter what self.capability_worker.resume_normal_flow() From b6cb6df4657cc5852118949bfad8c3c9b80bc3c6 Mon Sep 17 00:00:00 2001 From: markdelta24 Date: Fri, 20 Feb 2026 12:43:23 +0530 Subject: [PATCH 3/6] feat: add N8N Commander ability Voice-controlled automation bridge that connects OpenHome speakers to n8n workflows. Supports fire-and-forget and round-trip webhooks, LLM-based intent classification, and conversation flow management. Demo: https://www.loom.com/share/4fa81c0a55f24b6ba2f6cf2f5cfa8d9c --- community/n8n-commander/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/community/n8n-commander/main.py b/community/n8n-commander/main.py index 34747c0f..9d2bdae8 100644 --- a/community/n8n-commander/main.py +++ b/community/n8n-commander/main.py @@ -665,12 +665,12 @@ async def run(self): # greet the user count = len(workflows) await self.capability_worker.speak( - f"Hey, I'm your automation assistant. " - f"I can trigger your n8n workflows by voice." + "Hey, I'm your automation assistant. " + "I can trigger your n8n workflows by voice." ) await self.capability_worker.speak( f"You have {count} workflow{'s' if count != 1 else ''} set up. " - f"Say help to hear them, or tell me what you need." + "Say help to hear them, or tell me what you need." ) # check if the trigger phrase already contains a command @@ -742,7 +742,7 @@ async def run(self): ] names_str = ", ".join(wf_names[:5]) await self.capability_worker.speak( - f"I'm not sure which workflow to use. " + "I'm not sure which workflow to use. " f"Your options are: {names_str}. Which one?" ) continue From b7e2ca34aee4581015abb54b1633608ccdb9dc04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 20 Feb 2026 07:19:52 +0000 Subject: [PATCH 4/6] style: auto-format Python files with autoflake + autopep8 --- community/n8n-commander/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/community/n8n-commander/main.py b/community/n8n-commander/main.py index 9d2bdae8..1b305c4c 100644 --- a/community/n8n-commander/main.py +++ b/community/n8n-commander/main.py @@ -131,7 +131,7 @@ def _clean_json(self, raw: str) -> str: start = cleaned.find("{") end = cleaned.rfind("}") if start != -1 and end != -1 and end > start: - return cleaned[start : end + 1] + return cleaned[start: end + 1] return cleaned # prefs management @@ -731,7 +731,7 @@ async def run(self): await self.capability_worker.speak("One sec, figuring that out.") intent = self.resolve_intent(user_text, workflows) wf_id = intent.get("workflow_id", "none") - confidence = intent.get("confidence", 0.0) + intent.get("confidence", 0.0) source = intent.get("source", "") # couldn't figure out what they want From 51bcd4f1ef42459ea1fb92cf5eee51c084fef4c1 Mon Sep 17 00:00:00 2001 From: markdelta24 Date: Sat, 21 Feb 2026 04:19:48 +0530 Subject: [PATCH 5/6] fix: replace register_capability with tag and remove raw open() Updated to match new validate_ability.py requirements: - Use #{{register capability}} tag instead of manual method - Removed raw open() call (now blocked by validator) - Removed unused os import --- community/n8n-commander/main.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/community/n8n-commander/main.py b/community/n8n-commander/main.py index 1b305c4c..e9dad818 100644 --- a/community/n8n-commander/main.py +++ b/community/n8n-commander/main.py @@ -1,5 +1,4 @@ import json -import os from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple @@ -63,23 +62,12 @@ class N8nCommanderCapability(MatchingCapability): Supports fire-and-forget and round-trip response workflows. """ + #{{register capability}} worker: AgentWorker = None capability_worker: CapabilityWorker = None initial_request: Optional[str] = None last_utterance: str = "" # raw text of what user last said, sent in webhook payload - @classmethod - def register_capability(cls) -> "MatchingCapability": - """Load trigger hotwords from config.json — standard pattern for all abilities.""" - with open( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") - ) as file: - data = json.load(file) - return cls( - unique_name=data["unique_name"], - matching_hotwords=data["matching_hotwords"], - ) - def call(self, worker: AgentWorker): """Entry point when the platform matches our hotwords. From b89797eb91ff8a425f6109836e461a6fc42254f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 05:26:36 +0000 Subject: [PATCH 6/6] style: auto-format Python files with autoflake + autopep8 --- community/n8n-commander/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/n8n-commander/main.py b/community/n8n-commander/main.py index e9dad818..fc63c80f 100644 --- a/community/n8n-commander/main.py +++ b/community/n8n-commander/main.py @@ -62,7 +62,7 @@ class N8nCommanderCapability(MatchingCapability): Supports fire-and-forget and round-trip response workflows. """ - #{{register capability}} + # {{register capability}} worker: AgentWorker = None capability_worker: CapabilityWorker = None initial_request: Optional[str] = None